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.
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.
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.
Each of these has fuller migration notes in the section below.
WirePayloadis now opaque — a struct with private fields andWirePayload::borrowed(..)/::owned(..)constructors, in place of the 0.8.0Borrowed(&[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, soto_strnow reaches the validator on the custom-ProtoStringdecode path — the onestringsite 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, withis_owned()covering the take-the-Bytes-only-when-free pattern.- Singular message fields are inline — codegen emits
MessageField<T, ::buffa::Inline<T>>, laid out asOption<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 theMessageFieldAPI is unchanged, so most code that touches the structs needs no edit at all; what breaks is an explicitMessageField<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 costssize_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_messageis 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 theResultis now dead weight. Delete the?/.unwrap()at call sites whose receiver is anOwnedViewor a generatedFooOwnedView. Plain view types (FooView::to_owned_message) stay fallible, because hand-written impls andpush_raw-built views can still legitimately fail.- Encoders take
&mut impl EncodeSinkinstead of&mut impl BufMut, so that a sink can flush segments without copying them. Callers passingVec<u8>,BytesMut, or any otherBufMutare source-compatible through the blanket impl and need no change; a hand-writtenMessage/ViewEncodeimpl updates its signature, and one that reached forBufMutmethods 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
u64—types::put_len_delimited_header, andmap_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 au32variable widens withu64::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 earlierbuffa-codegenpasses au32intoput_len_delimited_headerand accumulatesfield_leninto au32. An externalExtensionCodecimpl also swaps its required method to the fallibletry_encode/try_encode_one.
MSRV remains 1.75.
-
WirePayloadis 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 newlen/is_empty/is_owned), andWirePayload::borrowed(&[u8])/WirePayload::owned(Bytes)constructors.ProtoString/ProtoBytesfrom_wireimplementations that use the accessors are unaffected; code that constructedWirePayload::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, soto_strnow reaches the slack-aware UTF-8 validator on the custom-ProtoStringdecode path (it was the onestringdecode site #241 didn't cover). -
Singular message fields are now stored inline by default: codegen emits
MessageField<T, ::buffa::Inline<T>>(laid out asOption<T>, no per-field heap allocation) for every non-recursive field. Recursive fields are detected and stay onBoxautomatically. (#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_messageand the generatedFooOwnedView::to_owned_messageare now infallible, returning the owned message directly instead ofResult<_, DecodeError>. EveryOwnedViewconstructor wire-decodes its view (or, forunsafe 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 theResultonly ever encoded an unreachable error path. Migration: on call sites whose receiver is anOwnedViewor a generatedFooOwnedViewhandle, delete the?/.unwrap()/.expect(...)— or otherwise unwrap the previously-returnedResult(amatchor.map_err(...)needs the same treatment). Call sites on plain view types (FooView::to_owned_messagevia theMessageViewtrait) are unchanged and stay fallible, since hand-written impls andpush_raw-built views can still legitimately fail.unsafe from_partscallers: 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 atto_owned_message, so auditfrom_partscall sites for provenance. A contract violation by a buggy hand-writtenMessageViewimpl wrapped inOwnedViewlikewise 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_headertakeslen: u64(wasu32), andbuffa::map_codec::field_len/message_field_lentake and returnu64(MapCodec::encoded_lenandFIXED_LENlikewise — that trait is sealed, so only the signatures are visible). Bare integer literals still infer; au32variable widens withu64::from(...). Checked-in generated code must be regenerated: code emitted by earlierbuffa-codegenversions passes__cache.consume_next()(au32) toput_len_delimited_headerand addsfield_lenresults into au32accumulator, so it fails to compile against this runtime. Regenerate with your build pipeline (orbuffa-build) after updating.ExtensionCodecandextension::codecs::SingularCodecswap their required encode method:try_encode/try_encode_one(fallible) are now required, and the panickingencode/encode_oneare provided wrappers — so a codec whose encode can fail cannot accidentally leave the fallibletry_set_extensionpath panicking. All in-tree codecs are updated; an external codec impl (if any exist) renames its method and wraps the result inOk. 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 thetry_encode*escape hatch. -
Message::write_to/encode(andViewEncode, thetypes::put_*/encode_*helpers, and generated code) now take&mut impl EncodeSinkinstead of&mut impl BufMut. Callers passingVec<u8>,BytesMut, or any otherBufMutare source-compatible via the blanket impl; manualMessage/ViewEncodeimplementations must update their method signatures, and generated code must be regenerated with the matching codegen version. Note thatEncodeSinkdeliberately exposes only theBufMutsubset the encoders use (put_u8,put_slice, and the little-endian fixed-width writers) — a manualwrite_tothat used otherBufMutmethods must assemble into a concrete buffer first. Generatedwrite_tobodies now emitput_shared_bytes_fieldforbytesfields — copy-equivalent forVec<u8>, segment-aware forbytes::Bytes.
-
DescriptorPoolnow implementsClone. (#253) -
ReflectMessageMut::try_set/try_clear: checked mutation variants that returnReflectError::FieldNotMemberinstead of mutating when handed aFieldDescriptorfrom a different message or pool. (#254) -
idiomatic_field_namescodegen 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) -
jifffeature forbuffa-types(#264). Adds conversions between the well-known types and jiff:Timestamp↔jiff::TimestampandDuration↔jiff::SignedDuration. Gated behind the newjiffCargo feature andno_std-compatible (jiffis pulled withdefault-features = false+alloc). Note forchronousers switching over: theDuration→jiff::SignedDurationconversion has noOverflowerror mode (SignedDurationstores fulli64seconds, so a well-formed protoDurationcan never overflow it) — code that relied onDurationChronoError::Overflowto reject huge durations must check the proto JSON spec bound (±315,576,000,000 s) itself, or let JSON serialization enforce it. -
exclude_packageprotoc plugin option (protoc-gen-buffaandprotoc-gen-buffa-packaging): drop a proto package and its subpackages from generation and from the emittedmod.rs. Repeatable; the leading dot is optional. Intended for option-only imports thatinclude_importspulls intofile_to_generatebut that are never referenced as message fields (e.g.buf.validate,gnostic). Both plugins route exclusion through the newbuffa_codegen::package_is_excludedpredicate, so their output sets stay in sync. (#279) -
MessageField<T, P>now has a consumingmapcombinator, equivalent tointo_option().map(f), for Option-style transformation of optional message fields. (#280, #282) -
Vectored ("rope") encode: the new
EncodeSinktrait abstracts the encode output, and theRopesink captures largebytes::Bytesfields (and, viaRope::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: everyBufMutis anEncodeSinkthrough a blanket impl.ProtoBytesgains a providedas_sharedmethod so customBytes-backed representations can opt in, andRopeBufadapts a finished rope tobytes::Bufwith vectored-I/O support. -
Package-root
FILE_DESCRIPTOR_SET_BYTESre-export (#278). The embedded descriptor-set bytes were only reachable through the reserved__buffasentinel module; generated packages now re-export the constant at the package root alongsidedescriptor_pool(), makingpkg::FILE_DESCRIPTOR_SET_BYTESthe supported access path. -
buffa-remote-derive: derive macros for newtypes over remote types (#212, #251). New proc-macro crate withProtoString,ProtoBytes,ProtoList,ProtoBox, andMapStoragederives that generate the buffa owned-type trait impls (plusDeref/AsRef/Fromconversions) for a single-field newtype wrapping a foreign type, mirroring serde'sremoteattribute 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 theFeatureOverrideallowlist enum; the first entry isenum_type:OPEN, withopen_enums_in(&[...])as shorthand: selected closed enums (or individual closed enum fields) generate asEnumValue<E>, making unknown wire values directly visible asEnumValue::Unknown(n)for prost-parity migrations or memory-optimized builds withpreserve_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: optionalas_sharedoverride onderive(ProtoBytes)(#294).#[buffa(remote = ..., as_shared = path)]generates the encode-sideProtoBytes::as_sharedhook, letting a remote bytes newtype that stores abytes::Bytessplice 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_limitbounds 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 andsize_of::<T>()in theVecit lands in — measured at 256 bytes for a message of a fewVec/Stringfields, a 128x ratio, so 4 MiB of them forced ~512 MiB.with_max_message_sizecannot help, because it bounds the bytes going in rather than what they expand into. Emptybytesandstringelements amplify 16x and 12x by the same route, and amap<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-bytei32, 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 withDecodeError::ElementMemoryLimitExceeded; raise the budget withwith_element_memory_limitfor trusted input that legitimately decodes into more. NoteVecgrows 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.
-
smoothutf8bumped to 0.2.3. The decode hot path picks up the 0.2.1–0.2.3 performance work:verify_with_slackASCII improves 38–64% at 1–32 bytes (inline-partition fix) and 28–39% at 32–512 bytes on aarch64 (NEONascii_skip), and the safeverifytail rewrite improves 1–4-byte inputs up to 5.9×.wasm32targets now run the portable shift-DFA validator instead of delegating tocore::str::from_utf8. buffa's call site moves from the deprecatedto_stralias to its new namefrom_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
Messagetrait docs. (#202, #244) -
Tag::newis now#[inline], allowing the per-field-write call in generatedwrite_tocode 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-inlinedbuffa::types::decode_*_packedhelpers, 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% ondecode_viewacross every benchmark shape (via the resultingdecode_varintcode-placement shift), at the cost of ~+2–4% on one dense-small-message synthetic (google_message1owned decode/merge). Pair regenerated code with a buffa runtime that has thedecode_*_packedhelpers — code generated by thisbuffa-codegendoes not compile against older runtimes, so a stale lockfile resolving an older caret version fails withcannot find function decode_*_packed;cargo update -p buffafixes it. The plaindecode_*helpers are unchanged. -
source_code_infois stripped from the embeddedFileDescriptorSet(#278). Codegen consumes source info for doc comments, but the runtimeDescriptorPoolnever 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__buffasentinel 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 withprotoc --include_source_infoorbuf build. -
Docs: document the idiomatic
.into()conversions forMessageFieldandEnumValue, and explain why message types are generated from.protorather 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 viachunks_exact+from_le_bytes, which optimizes to a bulk copy on little-endian targets. Applies to view decode and to owned decode into the defaultVecwhen 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 fromcount_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 defaultVecwhen 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. -
DescriptorPoolnow 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 aoneof_indexthat names no oneof in the containing message. The last of these also fixes a real bug — an out-of-u16index 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 setdeprecated_legacy_json_field_conflicts, so every descriptor set protoc emits still loads. protoc rejects the rest at compile time. (#300) -
DescriptorPoolnow 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 underallow_aliasare 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)
-
DescriptorPool::add_file_descriptor_setis 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 withDuplicateName. (#253) -
DynamicMessage::set/clearnow 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 adebug_assert!only. (#254) -
JSON: quoted integer strings in decimal/exponent notation (e.g.
"9007199254740993.0","9.007199254740993e15") now parse exactly instead of routing throughf64, 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 wrappedu32arithmetic into corrupt output. Generatedcompute_sizenow accumulates inu64and saturates at each node's return (buffa::saturate_size), and every provided encode entry point onMessage,ViewEncode, generated lazy views, andDynamicMessagechecks the total against the newbuffa::MAX_MESSAGE_BYTESconstant. 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); newtry_encode,try_encode_with_cache,try_encoded_len,try_encode_length_delimited,try_encode_to_vec, andtry_encode_to_bytestwins returnErr(EncodeError::MessageTooLarge)instead —EncodeErrorgains its first variant. Fallible variants also cover the eager re-encode paths:ExtensionSet::try_set_extensionandAny::try_pack(message-typed extension values andAny::packencode their payload on the spot, so the panicking originals document the new panic and these twins return the error instead). In debug buildsSizeCache::consume_nextadditionally rejects over-limit slots as a backstop for callers drivingcompute_size/write_todirectly. -
DynamicMessage::try_setnow rejectsValues whose runtime shape does not match the target field descriptor — repeated elements, map keys and values, and nested messages of the wrong type included — andsetconsequently 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, withencoded_lenin 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-buildProtoc mode now emitscargo:rerun-if-changedfor imported.protofiles 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_REQUIREDfields with an explicitdefault = ...now honor the declared default consistently inDefault::default(),clear(), and reflection presence checks. Previouslyclear()reset such fields to the type default instead of the declared default. -
Reflective
setadopts message values from another descriptor pool (#297). A generated type reflects against its defining crate'sDescriptorPool, so a nested well-known-type (or any cross-package) message reached through theReflectMessagevtable could never be pointer-identical to the referencing pool.DynamicMessage::try_setrejected such values with a self-contradictory error (expects message google.protobuf.Duration, got message google.protobuf.Duration), which made the rebuild walkfor_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 compareReflectMessage::poolthemselves. AddsMapValue::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 testdownstream. An unannotated fence becomestextand any other keeps its language and gainsignore, so arustfence 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_fieldmethods 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 asValue::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.
- 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 whatto_owned_messagere-materializes, which gives views a guarantee: a view produced bydecode_viewalways converts viato_owned_messagewithout error (theResultremains for hand-written impls andpush_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 withUnknownFieldLimitExceeded. 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 withDecodeOptions::with_unknown_field_limitif 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.
-
WirePayload::to_str(#241) — borrow the payload as a&strif valid UTF-8, using buffa's UTF-8 validator (so it picks upfast-utf8). Convenience forProtoString::from_wireimplementations;buffa-smolstrnow uses it. -
HasMessageView::decode_view/decode_view_with_options(#240) — defaulted methods so generic code bounded onM: HasMessageViewcan writeM::decode_view(buf)instead of the associated-type path<M as HasMessageView>::View::decode_view(buf). Additive; the existingMessageView::decode_viewis unchanged. -
MessageField::unwrap/expectandFrom<MessageField<T, P>> for Option<T>(#240) — consume aMessageFielddirectly (field.unwrap(),field.expect("…"),field.into()) without the.into_option().unwrap()round-trip. Both are#[track_caller]and panic on an unset field; preferok_or/ok_or_elsefor fallible contexts. Additive. -
buffa::SizeCachePool— opt-in reuse of the encode size-cache spill allocation (#225). Everyencode/encoded_lenbuilds a freshSizeCache; 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 heapVecon every encode.SizeCachePoolis a caller-owned free-list of those spill buffers — keep one in athread_local!or a request/connection context and callpool.encode,pool.encode_view, orpool.encoded_lento reuse one allocation across many encodes. buffa holds no global state; only the spillVecis pooled (each cache's inline array stays on the stack), so routing small messages through a pool costs only aVecpop/push of an empty buffer — no allocation, no thread-local, no synchronization — and the pool isalloc-only (no_std-OK). Bounded bymax_buffers(free-list length) andmax_capacity(per-buffer capacity, shrunk on return). Also addsSizeCache::with_spill_buffer/into_spill_bufferto source/sink the spill buffer for manual reuse. Additive and non-breaking; the defaultencodepath is unchanged. -
Custom owned
stringtypes formapkeys and values (#222). Astring_typerule (string_type_custom/string_type_custom_in) now also applies to amap<string, V>key and amap<K, string>value — one rule on the map field path covers both slots of amap<string, string>— mirroring howbytes_typealready reachesmap<K, bytes>values. The element decodes/encodes through the new sealedbuffa::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(orOrdformap_type(BTreeMap)) for a key;serde::Serialize/Deserializefor JSON; and — because the map paths have no per-key generic shim — a crate-local newtype (vtable reflection emitsReflectMapKey/ReflectElementfor it) and its ownArbitraryimpl undergenerate_arbitrary. Custom-string-keyed maps whose value needs proto3-JSON encoding (int64/float/bytes) serialize through a newproto_str_key_mapwith-module (the existingproto_maprequiresDisplay + FromStr, which aProtoStringneed not implement). -
Pluggable owned map container for
map<K, V>fields (#210). A newbuffa::MapStoragetrait (with associatedKey/Valuetypes) selects the owned map collection, viabuffa_build'smap_type/map_type_customknobs. The default staysHashMap;BTreeMapis a zero-dependency built-in giving deterministic (reproducible) encoded bytes, and a crate-local newtype can wrap any other map (e.g.IndexMap). JSON andarbitrarywork for every proto map key/value type regardless of the container — the proto-JSONwith-modules and thearbitraryshim are generic overMapStorage. The wire format is unchanged; only the in-memory collection changes, and view types are unaffected. -
Pluggable owned
string/bytestypes viafrom_wire(#206). A newbuffa::ProtoString/buffa::ProtoBytestrait selects the owned representation ofstring/bytesfields, viabuffa_build'sstring_type_custom/bytes_type_customknobs. 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 transientString/Vec<u8>allocation aFrom<String>decode path would force. The built-inStringandVec<u8>are the default implementations;buffa-smolstris the worked newtype example for a foreign type. Breaking removal: the curatedstring_typepresets (the named-crate enum that previously selectedcompact_str/ecow/smol_str) are dropped — usestring_type_customwith a crate-local newtype instead. The wire format is unchanged. -
Pluggable owned collection for
repeatedfields (#208). A newbuffa::ProtoList<T>trait (Deref<Target = [T]> + FromIterator<T> + From<Vec<T>> + Default { push, reserve }) selects the owned collection forrepeatedfields, viabuffa_build'srepeated_type/repeated_type_customknobs. The default staysVec<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'sMessageFieldwraps — and the pointer of a boxed oneof message/group variant — viabuffa_build'sbox_type/box_type_customknobs (the custom path takes a*-templated type, e.g."::my_crate::SmallBox<*>"). A oneof variant opted into inline storage viaunbox_oneoftakes precedence and gets no pointer; recursive variants stay pointered and so accept a custom pointer. The default staysBox<T>and generated output is byte-identical. Only exclusively-owned pointers qualify (Rc/Arcare excluded — the decoder merges in place viaDerefMut); inline pointers likeSmallBoxavoid the per-field heap allocation. Source-breaking note:MessageField<T>gained a defaulted pointer type parameter (MessageField<T, P = Box<T>>), so a standaloneMessageField::some(x)/none()with no pinning context now needs a type annotation (MessageField::<T>::some(x)); struct-literal and typed-assignment construction are unaffected. AddedMessageField::from_pointer(the generic counterpart to theBox-onlyfrom_box). -
idiomatic_importsoption (#189).buffa_build::Config::idiomatic_imports(true)(alsoCodeGenConfigandprotoc-gen-buffa'sidiomatic_imports=true) emits ause-backed short-name re-export at the package root underfile_per_packagelayout, so a generated type is reachable aspkg::Fooinstead ofpkg::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, andMapStoragenow carry diagnostic hints so a*_type_customknob 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 behindrustversion::attr(since(1.78), …)for the MSRV. -
examples/custom-types— end-to-end pluggable owned types (#234). A runnable example crate that compiles a.protowith every owned-type knob (string_type_custom,bytes_type_custom,repeated_type_custom,map_type_custom,box_type_custom) pointed at crate-local newtypes wrappingflexstr,smallvec,indexmap, andsmallbox, 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-localbuildsconformance_test_runnerfrom the pinned protobuf tag into.local/bin/andtask conformance-localexecutes 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
FooLazyViewfamily (#188). WithConfig::lazy_views(true)(plugin:lazy_views=true), each message additionally generates aFooLazyView<'a>implementing the newbuffa::LazyMessageViewtrait — the eagerFooViewfamily is unchanged and output is byte-identical with or without the flag.decode_lazyperforms 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), soDecodeOptionslimits flow throughdecode_lazy_view. Conversions are fallible (to_owned_message() -> Result), the lazySerializeimpl 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 dedicatedBUFFA_VIA_LAZYconformance runner mode covers the lazy decoder against the full corpus. -
Customizable feature-gate names (#183).
CodeGenConfig::feature_gate_names(exposed asbuffa_build::Config::{json,views,text,reflect}_feature_nameandprotoc-gen-buffa's{json,views,text,reflect}_feature=options) renames the crate features thatgate_impls_on_crate_featuresconditions the generated impls on — e.g. gating the serde JSON impls behind a feature namedserdeinstead ofjson. 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 astype_attribute. Completes thetype/message/enum/fieldattribute 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 bywith_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 newDecodeError::UnknownFieldLimitExceededwhen 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 withDecodeOptions::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 bywith_max_message_size. The limit covers owned-message andDynamicMessagedecoding; zero-copy views store unknown fields as borrowed spans and are not affected by the amplification. -
chronointerop forbuffa-types(#163). A new off-by-default,no_std-compatiblechronofeature adds conversions between the well-knownTimestamp/Durationtypes andchrono::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, andTryFrom<Duration> for TimeDelta. The last returns a newDurationChronoErrorbecauseTimeDelta's range (±i64::MAXmilliseconds) is narrower than protoDuration's. Contributed by @yordis. -
New
buffa-yamlcrate: 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 throughserde_norway, so YAML I/O gets the full protobuf JSON mapping:camelCase/snake_casefield names, quotedint64/uint64, base64 bytes, enum string names, and canonical well-known-type encodings. Public API:to_string,to_writer,from_str,from_slice,from_reader, plusto_string_view/to_writer_viewfor zero-copy views, and anErrortype exposing a carrier-agnosticLocation { line, column }. Requires message types generated withjson = true. Contributed by @rsd-darshan. -
Proto2 required-field presence on views (#200). Generated view types (
FooViewandFooLazyView) for messages with proto2/editionsLEGACY_REQUIREDsingular fields now exposehas_<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 toMessageFieldView::is_set()/LazyMessageFieldView::is_set(). The viewReflectMessage::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 reportshas() == falsefor a required field at its default value. Messages without required fields are byte-identical to before.MessageFieldView::is_set/is_unsetare nowconst fn. -
type_name_prefixoption (#199).buffa_build::Config::type_name_prefix("Rpc")(alsoCodeGenConfig::type_name_prefixandprotoc-gen-buffa'stype_name_prefix=option) prepends a prefix to every generated message struct and enum type name —message User {}generatesstruct 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.
-
UTF-8 validation on the decode path now uses
smoothutf8(default-onfast-utf8feature). The view-decode hot path (borrow_str) and the contiguous-input branch of the owned-decode helpers (decode_string,merge_string) takeverify_with_slackagainst 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 andWirePayload::to_strtake the safesmoothutf8::to_str, withsimdutf8delegation for inputs of 128 bytes or more whenstdis 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 gainsmoothutf8(andsimdutf8understd) as new dependencies; consumers already ondefault-features = falseshould addfast-utf8to their feature list to keep the faster validator, or omit it to stay oncore::str::from_utf8. Ano_stdbuild withfast-utf8adds onlysmoothutf8(zero-dependency, formally verified). (#241) -
(breaking)
protoc-gen-buffanow rejects malformed plugin parameters instead of stderr-warning or silently defaulting (#235). An unknown option key, a missing=, a non-true/falseboolean value, an invalidreflect_mode, or a malformedextern_pathnow fails generation viaCodeGeneratorResponse.error. Previously the default-on options (unknown_fields,register_types,with_setters) treated any value other thanfalseas 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-versionnow 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-positionimpl Traitin traits, used byMapStorage::storage_iter. Reaching it required only mechanical respellings of newer stdlib conveniences —Option::is_none_or,i32::cast_unsigned,f64::absinconst fn— and gating the six#[diagnostic::on_unimplemented]hints behindrustversion::attr(since(1.78), …)so they remain active on modern toolchains. Addsrustversionas a dependency ofbuffaandbuffa-descriptor. (#228) -
DecodeOptions::with_max_message_sizenow clamps values above the protobuf 2 GiB - 1 message-size limit (with a debug assertion to catch accidental sentinel use).DecodeOptions::without_reader_size_limitis the explicitstd-only opt-out for EOF-boundeddecode_readerinput; slice,Buf, view, and length-delimited decode paths keep their configured cap, and length-delimited declared lengths never exceed 2 GiB - 1. Callers that usedwith_max_message_size(usize::MAX)for unbounded reader input should switch towithout_reader_size_limit; in release builds, the old spelling now caps at 2 GiB - 1. (#236) -
MapValueDecode::mergenow returnsResult<MapValueDecodeStatus, _>instead ofResult<(), _>, and a newmerge_entry_with_unknownscarries the closed-enum-map preservation path. The trait is sealed, so downstream implementations are unaffected; direct callers ofmerge(rare) must handle the new return value. (#218) -
SizeCacheno longer zeroes its inline slot array on construction. A fresh cache is built for everyencode/compute_size, and because it is passed by&mutto an out-of-linecompute_sizethe compiler cannot elide the unused tail, so the previous[0u32; N]initializer emittedN/4SSE 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 byreservebeforelenadvances past it and read only at indices< len, so the singleassume_initinconsume_nextis sound. This invariant is private to thesize_cachemodule (no external code can break it — worst case is a panic, never UB) and is checked mechanically in CI by a Miri job over thesize_cachetests. No API or wire-format change. (#223) -
Default
map<K,V>hasher is nowfoldhash::fast::RandomStateonstdbuilds (previouslystd::hash::RandomState/ SipHash-1-3). The container remainsstd::collections::HashMap; only theStype parameter changes. This brings thestdbuild in line withno_std(which already usedfoldhashviahashbrown's default) and matches the hasher class used by Google'sprotobuf-v4(upb / Wyhash). On the LogRecord benchmark — a string-and-map-heavy shape — this is roughly a 12% owned-decode speedup.foldhash::fastis 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 decodingmapfields with attacker-controlled keys who need a hardened bound can selectMapRepr::BTreeMap(no hashing) or supply a SipHash-backed map viaMapRepr::Custom. TheMapStorageandReflectMapimpls are now generic over the hasherS, so a custom-hasherstd::collections::HashMapworks without a newtype. Migration: the concrete map field type changes, so code that namesstd::collections::HashMap<K,V>(defaultS) for a generated field no longer type-checks — use thebuffa::Map<K,V>alias instead. Construct empty maps withbuffa::Map::default()(HashMap::new()/HashMap::with_capacity()are unavailable onstdbuilds because they are pinned to std's default hasher; usedefault()on bothstdandno_stdfor portability). Array-literal construction viaMap::from([...])/.into()is likewise unavailable; use[...].into_iter().collect().buffa::Mapandbuffa::foldhashare 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_typecall 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_codecmodule (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_tobodies use new fusedput_*_fieldruntime 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 reachingTag::newconst-folds and the inlinedencode_varintcollapses to a single byte store for tags below 128 — restoring the encode fast path that the call boundary had blocked. (#195, #207) -
DefaultInstance/DefaultViewInstance/ViewReborrowimpls 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
Serializeimpls use new internal (#[doc(hidden)])buffa::json_helpersadapter newtypes (ProtoJson,BytesJson,MapKeyJson, sequence variants, ...) instead of ~65 per-site local_W*wrapper structs. JSON output is unchanged. (#197) -
Breaking:
MessageViewgains a requiredmerge_view_fieldmethod, and the per-view decode tag loop is now a provided trait method (merge_into_view), mirroring the owned side'sMessage::merge/merge_fieldsplit. Generated views supply only the field match — regenerate code from earlier releases. Hand-writtenMessageViewimpls must addmerge_view_field; the trait docs include the canonical shape, the unknown-field-preserving arm, and thedecode_view→decode_view_ctxwiring. Sub-message arms call the new provideddecode_view_ctx/merge_into_viewinstead of the removed inherent_decode_ctx/_merge_into_viewhelpers. (#198) -
Breaking: the decode-path
Messagetrait methods (merge,merge_field,merge_to_limit,merge_group,merge_length_delimited),encoding::decode_unknown_field, andmessage_set::merge_itemnow take aDecodeContext<'_>— carrying the remaining recursion depth and the shared unknown-field allowance — in place of the baredepth: 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
Vecslot 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 viaDecodeOptions::with_unknown_field_limitand honored byDecodeOptions::decode_view. As part of this, the view decode path now threadsDecodeContext<'_>:MessageView::decode_view_with_limit(buf, depth)is replaced bydecode_view_with_ctx(buf, ctx), and generated views' hidden_decode_depthhelpers 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_messageandto_owned_from_source(and theOwnedViewwrapper) now returnResult<Owned, DecodeError>: generated conversions previously swallowed unknown-field re-materialization errors viaunwrap_or_default(), silently dropping every unknown field.UnknownFieldsView::to_ownedalso now re-materializes under the unknown-field allowance that remained when the view recorded its first unknown field — so a tightwith_unknown_field_limitconfigured atdecode_viewtime carries through conversion, where each ownedUnknownFieldcounts individually (unlike the coalesced spans the view stores). Views built manually viapush_rawfall back to the default limit.
-
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_pathreferences 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 inmoney_because sub-packagepb.lyft.moneyalso exists), a consumer referencing.pb.lyft.Money.Currencyviaextern_pathpreviously emitted the un-deconflicted…::money::Currencyand 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_customandrepeated_type_customnow compile undergenerate_json(true). Twojson_helpersfunctions were still hard-wired to the default representations:skip_if::is_unset_message_fieldonly accepted&MessageField<T>(defaultBox<T>pointer), andproto_seq::deserializeonly returnedVec<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 overP: ProtoBox<T>and the latter overC: From<Vec<T>>; both are inferred from the field type, so default-representation code is unchanged. (#234) -
DecodeOptions::decode_readerno longer overflows when the read size is unbounded. The internalread_limitedhelper computedmax_message_size as u64 + 1to 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 viaDecodeOptions::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 matchingbuffa-codegento 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 negativeint32/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_readerno 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 tomax_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 reportUnexpectedEofexactly 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.
-
unbox_oneofopt-out forBoxed message oneof variants (#126).Config::unbox_oneof_in(&[paths])stores the matching message-typed oneof variants inline in the owned enum instead of behindBox<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 generatedDebugimpls. Fields carrying the standarddebug_redactfield option print[REDACTED]instead of their value in the owned message'sDebugimpl, 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 coversDebugformatting only — text-format and JSON serialization are intentionally unaffected. A view struct containing a redacted field now lists proto fields only in itsDebugoutput (matching owned messages), so__buffa_unknown_fields/ phantom internals no longer appear there. The reflectiveDynamicMessageDebugimpl 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
RepeatedViewcapacity. Generated view decode arms for packed repeated scalar / enum fields now callRepeatedView::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) reservepayload.len()as a safe upper bound (every wire varint is ≥ 1 byte). The hiddenRepeatedView::reservehook 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.
-
TimestampError::Overflow'sDisplaymessage generalized. It now reads "timestamp is out of range for the target type" instead of namingSystemTime, since the same error is returned by the newTimestamp→chrono::DateTime<Utc>conversion. Code matching on the enum variant is unaffected. -
HasMessageViewcarries 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.
- 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
DynamicMessagesnapshots at the boundary instead of failing to compile: vtable accessors for message-typed fields route through the field type's ownReflectable::reflect(), and bridge mode now also emitsReflectElementsorepeated/mapfields degrade too. View reflection still requires vtable-grade types throughout — that limitation is now documented. (Code matching exhaustively onReflectCowmay now observeOwnedfor bridge-grade message fields; all-vtable builds are unchanged.) - Missing-reflection compile errors point at the fix (#179).
ReflectMessage,Reflectable, andReflectElementcarry#[diagnostic::on_unimplemented]hints, so building vtable codegen against an extern-path crate without its reflection feature (e.g.buffa-typeswithoutreflect) names the missing cargo feature instead of emitting a bare unsatisfied-trait error. Thereflect_modedocs state the requirement. - The owned message
Debugimpl now labels keyword-named fields without the raw-identifier prefix (typeinstead ofr#type), matching what#[derive(Debug)]prints and what the viewDebugimpl emits. - Octal escapes above
\377(255) in a proto2 bytes field'sdefault_valueare now rejected with a codegen error instead of silently wrapping to a wrong byte (\400previously decoded to0x00), matching protobuf C++'sUnescapeCEscapeStringbehavior (#164). Such escapes never appear in protoc-emitted descriptors, so this only affects hand-built or corruptedFileDescriptorSetinput. - Hex escapes in a proto2 bytes field's
default_valuenow consume the full run of hex digits and reject accumulated values above\xff(255) with a codegen error, matching protobuf C++'sUnescapeCEscapeStringbehavior (#173). Previously exactly two digits were read, so\xfffdecoded to the byte0xFFfollowed by a literalfinstead of erroring, and a single-digit escape such as\x1at 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 corruptedFileDescriptorSetinput.
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.
-
Runtime reflection:
DescriptorPoolandDynamicMessage.buffa-descriptorgains areflectfeature with a descriptor-driven reflection runtime.DescriptorPool::decodebuilds linked, feature-resolved descriptors (MessageDescriptor,FieldDescriptor,EnumDescriptor,ServiceDescriptor, …) from aFileDescriptorSet, treating the input as untrusted (malformed sets returnPoolErrorrather than panicking) and retaining the rawFileDescriptorProtos plus a symbol index (file_by_name,file_containing_symbol) for gRPC server reflection.DynamicMessagedecodes and encodes any message by descriptor — no generated types required — with unknown-field preservation, in-place mutation (field_mut/field_by_number_mut),Anypack/unpack, extension fields, and custom-option access (options()on every linked descriptor,DynamicMessage::from_options). With thejsonfeature it also speaks proto3 canonical JSON (Serialize,DynamicMessage::from_json, lenientfrom_json_ignoring_unknown, duplicate-key rejection). The dyn-safeReflectMessage/ReflectMessageMuttraits and theReflectCow/Value/ValueReftypes are the surface generated types plug into (see vtable mode below). Generated code opts in withbuffa_build::Config::generate_reflection(true)(plugin:reflection=true), which embeds the package'sFileDescriptorSetand exposes a lazily-built pool aspkg::descriptor_pool(). The reflection codec passes the protobuf conformance suite through a dedicatedDynamicMessage-only runner mode. -
Vtable reflection mode. Generated types now implement
buffa_descriptor::reflect::ReflectMessagedirectly — on both the owned structs and the zero-copy view types — sofoo.reflect()borrowsfooin 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 newbuffa_build::ReflectModeenum:buffa_build::Config::new() .reflect_mode(buffa_build::ReflectMode::VTable) // or ::Bridge / ::Off .compile()?;
The
protoc-gen-buffaequivalent isreflect_mode=off|bridge|vtable. Vtable mode does not require view generation: with views off, only the ownedReflectMessageis emitted.generate_reflection(true)selects vtable mode;reflect_mode(ReflectMode::Bridge)opts into the smaller round-trip implementation (oneDynamicMessageencode/decode perreflect()call) instead of oneimpl ReflectMessageper generated type. -
buffa-typesreflectfeature. Well-known types (Timestamp,Duration,Struct/Value,Any, wrappers, …) now implementReflectMessage, so messages that embed WKTs reflect end to end. -
Pluggable owned types for
stringandbytesfields (#127, #156, #206). Generatedstring/bytesfields can use a custom in-memory type chosen at code-generation time, with no change to the wire format.buffa_build::Configgainsstring_type(StringRepr)/string_type_inand the conveniencestring_type_custom("::path::To::Type")/string_type_custom_in, wherebuffa_build::StringRepris{ String (default), Custom(path) }. The newbytescounterpart isbytes_type(BytesRepr)/bytes_type_in/bytes_type_custom/bytes_type_custom_in, whereBytesRepris{ Vec (default), Bytes, Custom(path) };use_bytes_type/use_bytes_type_inremain as aliases forBytesRepr::Bytes. Rules accumulate and the last match wins. Only the owned struct field type changes — view types still borrow&str/&[u8], andmapkeys/values keep their default type.The chosen type must implement the marker traits
buffa::ProtoString/buffa::ProtoBytes. Each requires afrom_wire(WirePayload<'_>) -> Result<Self, DecodeError>constructor (alongside the supertraitsClone + PartialEq + Default + Debug + Send + Sync,Dereftostr/[u8],AsRef, andFrom<String>/From<Vec<u8>>).from_wirelets each representation own validation and borrow-vs-own:WirePayloadisBorrowed(&[u8])(zero-copy) orOwned(Bytes), withas_slice()andinto_bytes(). A representation that enforces extra invariants can reject a value fromfrom_wirewith the newDecodeError::Custom(&'static str)variant. buffa ships the built-in impls forString,Vec<u8>, andbytes::Bytes; a foreign type (e.g.smol_str::SmolStr) is wrapped in a local newtype that implements the trait — the newbuffa-smolstrcrate is the template (an inline, allocation-freefrom_wire). A custom type needs no nativeArbitraryimpl (a generic builder handles it). A custom type used as the element of arepeatedfield — or a custombytestype as amap<K, bytes>value — must be crate-local: codegen emitsReflectElement(vtable) and, for bytes, base64ProtoElemJson(JSON) impls for it, which the orphan rule forbids for a foreign type. A custombytesmap value is honored just like the built-inBytes(only themap<bytes, bytes>carve-out keepsVec<u8>). Singular / optional / oneof uses work with the newtype without the crate-local restriction.Why
from_wirerather than a blanketFrom-based impl: the decode path was first built as a blanket impl overFrom<String>/From<Vec<u8>>to learn the tradeoff, but that path always paysdecode_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_wirehands 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_typeshapes are removed — both theStringRepr::{SmolStr, EcoString, CompactString}presets (with thebuffa/buffa-descriptorsmol_str/ecow/compact_strfeatures and::buffa::{smol_str, …}re-exports) and the later blanketFrom-basedProtoString/ProtoBytes. Pointingstring_type_customat a foreign type directly no longer compiles; usebuffa-smolstr(or a local newtype implementingfrom_wire). Default output (String/Vec<u8>) is byte-for-byte unchanged.buffa-build/buffa-codegenonly — there is noprotoc-gen-buffaplugin option yet. -
Generated
FooOwnedViewwrapper types. When views are generated, each message now also gets aFooOwnedView— re-exported at the package root next toFooandFooView(canonical path__buffa::view::FooOwnedView): a self-contained'statichandle wrappingOwnedView<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 staysSend + Syncfor async handlers and spawned tasks. The wrapper forwardsdecode/decode_with_options/from_owned/to_owned_message/bytes/into_bytes, exposes the full view viaview(), converts to and from the rawOwnedView, and serializes to protobuf JSON whengenerate_jsonis enabled. A field or oneof whose name collides with one of the wrapper's reserved method names keeps working throughview(); its accessor is skipped with a build warning (CodeGenWarning::OwnedViewAccessorSuppressed). -
HasMessageViewview-family trait. Generated code now implementsbuffa::HasMessageViewfor every message (when views are generated), linking the owned type to its view types:Foo::View<'a>=FooView<'a>andFoo::ViewHandle=FooOwnedView, with a provideddecode_view_handle()helper. The generated wrapper additionally implementsFrom<OwnedView<FooView<'static>>>andAsRef<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 acceptMand work withM::View<'_>andM::ViewHandlegenerically. -
Idiomatic
UpperCamelCaseenum value aliases (#13). Generated enums now also carry associatedconstaliases with the enum-name prefix stripped and the value converted toUpperCamelCase—RuleLevel::RULE_LEVEL_HIGHis reachable asRuleLevel::High— usable in expressions and in pattern position with exhaustiveness preserved. TheSHOUTY_SNAKE_CASEvariants remain the definitive variants andDebugoutput 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 newCodeGenWarningdiagnostics (buffa_codegen::generate_with_diagnostics). Default on; opt out per compilation unit withbuffa_build::Config::idiomatic_enum_aliases(false)/CodeGenConfig::idiomatic_enum_aliases = false.
OwnedView<V>no longer implementsDeref<Target = V>. Breaking. TheDerefimpl exposed the inner view asFooView<'static>, so borrowed fields appeared'staticto the compiler and could be held past the point where theOwnedView(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 throughreborrow()(one extra call per scope:let person = owned.reborrow(); person.name) or, more conveniently, the new generatedFooOwnedViewaccessor 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 tomap<K, bytes>values (#76). Previously map values were alwaysVec<u8>regardless of config — the onlybytes-context not covered. They now match the type used for singular / optional / repeated / oneof bytes fields under the same rule (bytes::Byteswhen configured), soview → ownedconversion of map values participates in theto_owned_from_sourcezero-copyslice_refpath just like the other shapes. Breaking for code that already enableduse_bytes_type()on a proto containingmap<K, bytes>: at construction sites, rewrite map-value construction fromVec<u8>tobytes::Bytes(b"v".to_vec()→bytes::Bytes::from_static(b"v")for literals,bytes::Bytes::from(v)for an ownedVec<u8>, orbytes::Bytes::copy_from_slice(s)for a non-'staticborrow). At read sites,bytes::Byteshas no inherentas_slice, so anyas_slice()on the value needs replacing — e.g.map.get(k).map(Vec::as_slice)becomesmap.get(k).map(|b| &b[..]). One carve-out: an effectivemap<bytes, bytes>keepsVec<u8>values; this requiresstrict_utf8_mapping(true)and amap<string, bytes>whose key carries[features.utf8_validation = NONE](strict_utf8_mappingalone keeps a plainmap<string, bytes>value asBytes). See theuse_bytes_type_indocs. Undergenerate_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.
-
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 Oofandpackage foo.ooflegally coexist — but both mapped tomod 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 fromfoo::oof::…tofoo::oof_::…; (2) both packages must be generated in the samebuffa_build::Config::compile()call — deconfliction cannot span separate compilations, since each only sees its own descriptor set. -
Per-type
extern_pathmappings were silently ignored (#111). Anextern_pathentry 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 internaldescriptor.protorouting, 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 usualsnake_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
-
Generated message structs now include
with_<field>(value) -> Selfbuilder-style setter methods for every explicit-presence field (proto3optional, proto2optional, and editions fields withfield_presence = EXPLICIT). This allows chained construction withoutSome(...)wrapping:let req = GetSecretRequest::default() .with_name("alice") .with_timeout_ms(30_000) .with_enabled(true);
String fields accept
impl Into<String>(&strworks directly); bytes fields acceptimpl Into<Vec<u8>>orimpl Into<bytes::Bytes>(byte array literals likeb"data"work directly); enum fields acceptimpl Into<EnumValue<E>>(bare variant works directly, noEnumValue::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, proto2requiredfields, and implicit-presence fields are unaffected. To clear a field, assignNonedirectly. Setters are pure inherent methods with no runtime dependency, so they're emitted unconditionally regardless ofgate_impls_on_crate_features. Disable per compilation unit withCodeGenConfig::generate_with_setters = false,buffa_build::Config::generate_with_setters(false), or thewith_setters=falseplugin opt. Consumers with checked-in generated code will see new methods on regen. (#30, #93, by @tejas-dharani) -
buffa::MessageNametrait exposes a generated message's protobuf identifiers as compile-time&'static strconstants. Codegen emitsimpl MessageName for #Msg(andfor #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"), andTYPE_URL("type.googleapis.com/my.pkg.Outer.Inner"— thegoogle.protobuf.Any.type_urlform). All four are computed at codegen time as string literals, so there's no runtime allocation or concatenation — unlikeprost::Name, whosefull_name()andtype_url()are runtimeformat!calls.PACKAGEandNAMEare separate consts because the dottedFULL_NAMEcannot be split unambiguously (foo.Bar.Bazcould be packagefoo.Bar+ messageBazor packagefoo+ nestedBar.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: MessageNameand 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 inherentFoo::TYPE_URLconst generated since 0.4.0 is unchanged and equal to<Foo as MessageName>::TYPE_URL; for messages that also implementExtensionSet,FULL_NAMEis equal toExtensionSet::PROTO_FQN(all derive from the same codegen source).MessageNameis not object-safe (associatedconstonly) — use it as a bound, notdyn MessageName. Migrating fromprost::Name: rename the bound and replace runtimeM::full_name()/M::type_url()calls with the consts. (#108, by @yordis) -
buf.build/anthropics/buffais published to the public Buf Schema Registry.buf generatecan now referenceprotoc-gen-buffaas aremote:plugin with no local install:remote: buf.build/anthropics/buffawithopt: [file_per_package=true]and a small hand-writtenpub modtree, or paired with a locally-installedprotoc-gen-buffa-packagingfor a generatedmod.rs. The README quick-start,docs/guide.md"Using buf" section, and a newexamples/bsr-quickstart/project document the workflow. The stale in-repoprotoc-gen-buffa/buf.plugin.yamlmetadata file is removed — the canonical plugin definition lives in bufbuild/plugins. -
buffa-codegen:CodeGenConfig::gate_impls_on_crate_features. Whentrue, generated impls controlled bygenerate_json,generate_views, andgenerate_textare 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. Thegenerate_*flags still control whether an impl kind is emitted; the new flag only controls how. Defaultfalse— no change to existing output. This is the codegen mechanism that will letbuffa-descriptorandbuffa-typesship every impl while keeping the codegen toolchain (buffa-codegen/buffa-build/protoc-gen-buffa) lean — it depends on them withdefault-features = false. Tracked in #113. Exposed asbuffa_build::Config::gate_impls_on_crate_features(bool)and thegate_impls=trueplugin opt, both default-off. -
buffa-descriptor: regenerated with views, JSON, text, and arbitrary impls behind crate features.descriptor.protoandcompiler/plugin.prototypes now ship the full impl surface — gated onviews,json,text, andarbitraryCargo features so the codegen toolchain (buffa-codegen/buffa-build/protoc-gen-buffa) can depend onbuffa-descriptorwithdefault-features = falseand stay free ofserde/serde_json/base64/arbitrary. Consumers whose protos reference adescriptor.prototype as a field (most commonly anything depending onbuf/validate/validate.proto, orbuf.registry.module.v1/buf.alpha.image.v1which embedFileDescriptorSet/FileDescriptorProto) must enable thebuffa-descriptorfeatures matching their codegen modes —views = ["buffa-descriptor/views"],json = ["buffa-descriptor/json"], etc., or justbuffa-descriptor = { ..., features = ["views", "json"] }. This closes #113: the fullbufbuild/registryandbufbuild/bufmodules now generate and compile cleanly withviews=true+json=true.Migration: if your
Cargo.tomlalready declaresbuffa-descriptoras 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-descriptordirectly, 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); addbuffa-descriptorwith the right features.The
buffa_descriptor::generatedmodule tree now nestsgoogle.protobuf.compilerinsidegoogle.protobufto mirror the proto package hierarchy (so cross-packagesuper::*references in the view code resolve); the previous sibling-stylebuffa_descriptor::generated::compilerandbuffa_descriptor::generated::{FileDescriptorProto, GeneratedCodeInfo}paths are preserved withpub usere-exports. -
serde::Serializeis now implemented for generated view types whengenerate_jsonis enabled, allowing zero-copy JSON serialization without.to_owned_message().OwnedView<V>also gains a blanketSerializeimpl soserde_json::to_string(&owned_view)works directly. Well-known type views (TimestampView,DurationView,AnyView, etc.) also implementSerialize(delegating to the owned form) when thebuffa-types/jsonfeature is enabled, so messages that nest WKT fields work out of the box.MapViewgainsiter_unique()andlen_unique()helpers (last-write-wins deduplication) so map fields with duplicate wire keys serialize to a valid JSON object. The protobuf conformance suite gains aBUFFA_VIEW_JSON=1run 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 usesserialize_map(None), which is fine forserde_jsonbut will be rejected at runtime by length-prefixed formats likebincodeorpostcard; use the owned form for those serializers. (#83)
-
buffa/buffa-codegen:serde_jsonre-exported frombuffafor generated extension JSON deserialize. Messages withextensions N to M;ranges andjson=truecodegen get a hand-writtenDeserializeimpl that buffers"[pkg.ext]"JSON keys into aserde_json::Valuebefore dispatching toextension_registry::deserialize_extension_key. The emitted path was a bare::serde_json::Value, which silently required every consumer ofjson=truecodegen to declareserde_jsondirectly in its ownCargo.toml— a footgun reported by Buf forbufbuild_registry_*SDKs generated againstbuf/validate/validate.proto(which has 21 extension ranges).buffanow re-exportsserde_json(gated on thejsonfeature,#[doc(hidden)], matching the existingbytesre-export) and codegen emits::buffa::serde_json::Value, so consumers only needbuffa,buffa-types, andserde(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.prototypes now resolve tobuffa-descriptor, notbuffa-types. The auto-injected WKT extern_path.google.protobuf→::buffa_types::google::protobufcovers everything in thegoogle.protobufpackage, includingdescriptor.prototypes — butbuffa-typesonly ships the JSON-mappable WKTs. Any proto referencing adescriptor.prototype as a field — e.g.buf/validate/validate.proto, which has threeoptional google.protobuf.FieldDescriptorProto.Typefields — produced a generated path that doesn't exist:::buffa_types::google::protobuf::field_descriptor_proto::Type. An internal file-level extern resolution now routesgoogle/protobuf/descriptor.prototo::buffa_descriptor::generated::descriptorandgoogle/protobuf/compiler/plugin.prototo::buffa_descriptor::generated::compiler, taking priority over the package-level WKT mapping. Suppression mirrors the WKT mapping: a user.google.protobufextern_path overrides it (preserving the long-standing behaviour that the override covers descriptor types too), and a file infiles_to_generateresolves locally. Consumers whose protosimport "google/protobuf/descriptor.proto"and reference its types as fields must addbuffa-descriptorto their[dependencies]— the same way protos that reference WKTs requirebuffa-types. The user-facingextern_pathAPI is unchanged (still package-prefix keyed). -
buffa: closed-enum JSON helpers no longer require the enum toimpl Deserialize.opt_closed_enum,repeated_closed_enum, andmap_closed_enumdeserialized viaserde_json::from_value::<E>(), which boundE: DeserializeOwned. That meant a closed-enum field whose enum type lives in an externally-generated crate built withoutgenerate_json— e.g.google.protobuf.FieldDescriptorProto.Typefrombuffa-descriptor, referenced bybuf/validate/validate.proto— could not satisfy the bound and refused to compile underjson=truecodegen. The helpers now decode the bufferedserde_json::Valuedirectly via theEnumerationtrait (from_proto_name,from_i32, default fornull), which is the same dispatch the codegen-emittedDeserializeimpl performs anyway. TheDeserializeOwnedbound 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 underno_std: the previous implementation needed thestd-only scoped strict-mode override to surface a distinguishable error from the inner deserialize, but the newEnumeration-direct dispatch has no inner deserialize to override.
-
The workspace
[profile.release]now setslto = trueandcodegen-units = 1. This shrinks the prebuiltprotoc-gen-buffa/protoc-gen-buffa-packagingrelease 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 ofbuffa/buffa-builddo not inherit this — set[profile.release]in your own workspace (orCARGO_PROFILE_RELEASE_LTO=trueforcargo install) to get the same benefit. (#60) -
buffa-codegen: empty ancillary content files and modules are no longer emitted. A.protowith no oneofs / no extension declarations /views=falsepreviously produced placeholder<stem>.__oneof.rs/<stem>.__ext.rs/<stem>.__view.rs/<stem>.__view_oneof.rsfiles containing only the@generatedheader, and the package stitcher unconditionally authored apub mod __buffa { pub mod oneof { ... } pub mod ext { ... } ... }tree thatinclude!d them. Codegen now omits an ancillary content file when it would be empty, the stitcher onlyinclude!s files that exist, and the__buffawrapper (and eachview/oneof/extsubmodule inside it) is itself omitted when it would be empty — so a package with only owned messages emits no__buffablock 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-writtenuse 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
buffa-codegen: oneofSerializematch arms now useSelf::#variant.generate_oneof_serializeemits the manual JSON serde impl asimpl Serialize for #enum_ident { fn serialize(&self, …) { match self { … } } }, whereSelfresolves to the oneof enum. The match arms used the fully-qualified#enum_ident::#variantform, which tripsclippy::use_selfin workspaces that opt it on — particularly visible underconnectrpc-build, which doesn't carry an inner#![allow(...)]the wayprotoc-gen-buffa-packagingdoes, so the oneof companion file inherits the surrounding mod's lint set. The deserialize arms inoneof_variant_deser_armremain qualified because they construct the oneof from inside the message'sDeserializeimpl, whereSelfwould 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 positionalformat!("enum value {} out of i32 range", v)etc., which tripclippy::uninlined_format_argsfor the same reason as above (the enum impls live in the per-proto Owned content, outside the__buffa#[allow(...)]block). Nowformat!("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
buffa-codegen:ALLOW_LINTSnow includesunused_qualifications. Cross-proto references within the same package are emitted through the canonicalsuper::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 optunused_qualifications = "warn"and build with-D warningswere getting false positives from generated code; the lint is now in the package stitcher's#[allow(...)]block alongsidedead_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.
buffa_codegen::GeneratedFileKindis 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.
-
buffa_codegen::GeneratedFileKind::Companionandapply_companionslet 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 asGeneratedFileKind::Ownedand rely on filename matching. Companion files areinclude!d at package root alongside owned message types. (#81) -
OwnedView<V>gains areborrow<'b>(&'b self) -> &'b V::Reborrowed<'b>method that makes the internal'staticlifetime visible as'b(the lifetime of the borrow), so view fields can be passed into functions or return types bounded by theOwnedView's lifetime. RequiresV: ViewReborrow, a safe trait whosereborrowmethod body is a covariance-checked subtype coercion; codegen emits theimplautomatically for every generated view type. Hand-written view types opt in withimpl 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 nounsafeis needed. (#82) -
Codegen now emits "natural-path"
pub usere-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 FooViewnext tomessage 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 (seeexamples/conflictsfor 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-buffaandprotoc-gen-buffa-packagingnow respond to--version/-Vand--help/-hinstead 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_bytesreads a length-delimitedbytesfield into abytes::BytesviaBuf::copy_to_bytes. When decoding from aBytes-backed buffer this is a zero-copy refcount bump. Generatedmerge_fieldarms forbytes_fields-tagged fields (singular, optional, repeated, and oneof) now use it instead ofBytes::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)
-
buffa-types --features arbitrarynow compiles.Any.valueisbytes::Bytes(since 0.4.0 / #51), which has noArbitraryimpl. Codegen now emits#[arbitrary(with = ::buffa::__private::arbitrary_bytes*)]on everybytes_fields-typed field — singular, optional, and repeated struct fields plus oneof variant inner fields — whengenerate_arbitrary = true, so the struct-levelderive(Arbitrary)succeeds. Map values are unaffected (they are alwaysVec<u8>regardless ofbytes_fields). The same fix covers any user crate that usesbytes_fields+generate_arbitrary.cargo doc --workspace --all-featuresandcargo clippy --workspace --all-featuresare also unblocked, and CI now runscargo check --workspace --all-featuresto prevent recurrence. (#88) -
write_tonow 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
-
Ancillary generated types moved under
pkg::__buffa::. View structs, oneof enums, view-of-oneof enums, extension consts, andregister_typesno longer share the package-level Rust namespace with owned message structs. The new layout:Item Before After View struct pkg::FooViewpkg::__buffa::view::FooViewNested view pkg::foo::BarViewpkg::__buffa::view::foo::BarViewOneof enum pkg::foo::KindOneofpkg::__buffa::oneof::foo::KindView-of-oneof pkg::foo::KindOneofViewpkg::__buffa::view::oneof::foo::KindExtension const pkg::FOOpkg::__buffa::ext::FOORegistration 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/OneofViewsuffixes are dropped — the parallel module tree disambiguates.This makes name collisions between user proto types and codegen-derived ancillary names structurally impossible.
__buffais 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__buffais rejected withCodeGenError::ReservedModuleName. -
Consumer include pattern: use
buffa::include_proto!("dotted.pkg"). Codegen now emits a per-package<dotted.pkg>.mod.rsstitcher alongside the per-proto content files. Hand-authoredinclude!(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'sgenerate_include_file()already emits the correct structure; consumers using that helper need no change. -
__buffa_cached_sizeis removed from all generated structs (owned and view);Message::compute_size/write_toandViewEncode::compute_size/write_tonow take a&mut SizeCacheparameter. Sizes are recorded in an external pre-orderVec<u32>cache that the providedencode*methods construct internally, so generated types contain only their proto fields plus__buffa_unknown_fields— no interior mutability, structurallySend + Sync, and concurrentencode()of the same&msgfrom multiple threads is sound.Message::cached_size()andViewEncode::cached_size()are removed; use the new providedencoded_len()to get the size alone.__private::CachedSizeis removed. Hand-writtenMessage/ViewEncodeimpls must add thecache: &mut SizeCacheparameter, dropcached_size(), and (for nested message fields) wrap recursion incache.reserve()/cache.set(); see the custom-types section of the user guide for the pattern. (#14, #22) -
DefaultInstanceandDefaultViewInstanceare no longerunsafetraits, andHasDefaultViewInstanceis removed. The liveness and immutability invariants are fully encoded by the return type and cannot be violated by a safe implementation.DefaultViewInstanceis now implemented forFooView<'v>at every lifetime (not just'static), withfn 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 inDeref for MessageFieldView. Hand-written impls must drop theunsafekeyword and adopt the new method signature; the separateHasDefaultViewInstanceimpl is no longer needed. (#68, #69) -
CodeGenError::OneofNameConflictand::ViewNameConflictremoved. These collisions are now structurally impossible (the inputs that previously triggered them produce valid output). -
google.protobuf.Any.valueis now::bytes::Bytesinstead ofVec<u8>. MakesAny::clone()a cheap refcount bump (up to ~170x faster for large payloads) instead of a full memcpy. Call sites constructing anAnyby hand need.into()on the payload (e.g.value: my_vec.into(), or passBytesdirectly). Readingany.valueis unchanged —Bytesderefs to&[u8].buffa-typesnow depends onbytesunconditionally.
buffa_build::proto_path_to_rust_module— consumers should use the per-package<pkg>.mod.rsstitcher path viabuffa::include_proto!instead.
ViewEncode<'a>— serialization from borrowed view types. Generated*View<'a>types implementViewEncode(whenever views are generated, i.e.generate_views(true), the default) with the same two-passcompute_size/write_tomodel asMessage. Views can be constructed from borrowed&'a str/&'a [u8]and encoded without intermediateString/Vecallocation. 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.rsstitcher; the canonical consumer integration point.MapView::new(Vec)/From<Vec>/FromIteratorfor constructing map views directly (forViewEncode).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 providedencode*()methods construct one internally; for hot loops, the newencode_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 (replacescompute_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=trueandallow_message_set=trueplugin parameters — match the existingbuffa-buildconfig flags.#[must_use]onMessage/ViewEncodecompute_size,encoded_len,encode_to_vec,encode_to_bytes.
- A proto message named
Option— anywhere in the proto package, including nested in a sibling message or in another file — no longer shadowscore::option::Optionin 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→ variantSelf) are now escaped. (#47) - Nested type and oneof sharing the same name (the gh#31
RegionCodescase) andFoonext toFooView(gh#32) — both now structurally resolved by the__buffa::namespacing above.
0.3.0 - 2026-04-01
Extension::new(number)→Extension::new(number, extendee). Same forExtension::with_default. Codegen consumers are unaffected — thepub constitems are regenerated. Hand-writtenExtensionconsts (unusual) need the extendee string added.ExtensionSettrait gained a requiredconst 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 returnedNone/ no-op).has_extension()returnsfalsegracefully. Catchesfield_options.extension(&MESSAGE_OPTION)bugs at the first call site; matches protobuf-go (panics) and protobuf-es (throws).
set_any_registry,set_extension_registry— usebuffa::type_registry::set_type_registryinstead, which installs all maps in one call. The deprecated functions still work.AnyTypeEntry→JsonAnyEntry,ExtensionRegistryEntry→JsonExtEntry. Type aliases for one release cycle. The text-format fields have moved to separateTextAnyEntry/TextExtEntrystructs intype_registry.
- Full extension support.
Extension<C>typed descriptors,ExtensionSettrait withextension/set_extension/has_extension/clear_extension/extension_or_default, codec types for every proto field type (includingGroupCodecfor editionsDELIMITED/ proto2 groups), proto2[default = ...]on extension declarations, and MessageSet wire format behindCodeGenConfig::allow_message_set. See the Extensions section of the user guide. TypeRegistry— unified registry coveringAnytype entries and extension entries for both JSON and text formats. Codegen emitsregister_types(&mut TypeRegistry)per file; call once per generated file, thenset_type_registry(reg). JSON entries (JsonAnyEntry,JsonExtEntry) and text entries (TextAnyEntry,TextExtEntry) live in feature-split maps sojsonandtextare 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) — thebuffa::textmodule providesTextFormattrait,TextEncoder,TextDecoder, andencode_to_string/decode_from_strconveniences. Enable withfeatures = ["text"](zero-dependency,no_std-compatible) andConfig::generate_text(true). CoversAnyexpansion ([type.googleapis.com/...] { ... }), extension brackets ([pkg.ext] { ... }), and group/DELIMITED naming.Anyexpansion and extension brackets consult the text maps inTypeRegistry— thejsonandtextfeatures are independently enableable. Passes the full text-format conformance suite (883/883). - Conformance:
TestAllTypesEdition2023enabled; binary+JSON 5539 → 5549 passing (std). Text format suite 0 → 883 passing (was entirely skipped). buffa-descriptorcrate —FileDescriptorProtoand friends are now in a standalone crate that depends only onbuffa, so descriptor types are usable without pulling inquote/syn/prettyplease.buffa-codegenre-exports the module so existingbuffa_codegen::generated::*paths still resolve. (#8)- Proto source comments → rustdoc. Comments from
.protofiles are now emitted as///doc comments on generated structs, fields, enums, variants, and view types. Requires--include_source_info(set automatically bybuffa-buildand the protoc plugins). (#7) buffa::encoding::MAX_FIELD_NUMBERconstant ((1 << 29) - 1), replacing the magic number at all call sites. (#21)
buffa-buildskips writing unchanged outputs, avoiding mtime bumps that trigger needless downstream recompilation. (#17)- Generated code emits
Selfinimplblocks instead of repeating the type name, so consumer crates that enableclippy::use_selfget clean output. (#15)
- Codegen no longer reports a false name collision between a nested type
and a proto3
optionalfield 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
-
protoc-gen-buffa: themod_file=<name>option is removed. Module tree assembly (mod.rsgeneration) is now a separate plugin,protoc-gen-buffa-packaging. The codegen plugin emits per-file.rsonly and no longer requiresstrategy: 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).
-
protoc-gen-buffa-packaging- new protoc plugin that emits amod.rsmodule 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. Optionalfilter=servicesrestricts the tree to proto files that declare at least oneservice, for packaging service-stub-only output from plugins layered on buffa. Released as standalone binaries for the same five targets asprotoc-gen-buffa, with SLSA provenance and cosign signatures. -
buffa-codegen:"."accepted as a catch-allextern_pathprefix.extern_path = (".", "crate::proto")maps every proto package to an absolute Rust path rooted atcrate::proto. More-specific mappings (including the auto-injected WKT mapping) still win via longest-prefix-match.
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.
| 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 |
|
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).
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_stdhasset_global_json_parse_optionsfor singular-enum accept-with-default but not container filtering) - 2,797 passing via-view mode (binary →
decode_view→to_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)
- 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
.protofiles in the Google Cloud API set - protoc compatibility: plugin tested against protoc v21–v33
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.
This release publishes:
buffa— core runtimebuffa-types— well-known types (Timestamp, Duration, Any, etc.)buffa-codegen— descriptor → Rust source (for downstream code generators)buffa-build—build.rsintegrationprotoc-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.