diff --git a/.changes/unreleased/added-20261001-235811-any-type-name.yaml b/.changes/unreleased/added-20261001-235811-any-type-name.yaml index 82bcc204..9e4bdb65 100644 --- a/.changes/unreleased/added-20261001-235811-any-type-name.yaml +++ b/.changes/unreleased/added-20261001-235811-any-type-name.yaml @@ -1,4 +1,4 @@ kind: Added body: |- - **`Any::pack_message`, `try_pack_message`, `unpack_message`, `is_message` and `type_name`** (#499). The first four pack and match an `Any` by message type in place of a type URL string. `pack_message` and `try_pack_message` store `MessageName::TYPE_URL`. `is_message::()` and `unpack_message::()` compare the name after the last `/` of the URL with `MessageName::FULL_NAME`, so they accept an `Any` packed under any URL prefix; the JSON and text registries match by the same name after trying the exact URL first. `type_name()` returns that name. `is_type` and `unpack_if` still compare the whole URL. + **`Any::pack_message`, `try_pack_message`, `unpack_message`, `is_message` and `type_name`** (#499). The first four pack and match an `Any` by message type in place of a type URL string. `pack_message` and `try_pack_message` store `MessageName::TYPE_URL`. `is_message::()` and `unpack_message::()` compare the name after the last `/` of the URL with `MessageName::FULL_NAME`, so they accept an `Any` packed under any URL prefix; the JSON and text registries match by the same name after trying the exact URL first. `type_name()` returns that name. A URL without a `/` is read as a bare name (#692). `is_type` and `unpack_if` still compare the whole URL. time: 2026-10-01T23:58:11+02:00 diff --git a/.changes/unreleased/added-20261010-101508.yaml b/.changes/unreleased/added-20261010-101508.yaml new file mode 100644 index 00000000..bee2505c --- /dev/null +++ b/.changes/unreleased/added-20261010-101508.yaml @@ -0,0 +1,3 @@ +kind: Added +body: '**`JsonParseOptions::strict_any_type_urls` and `DynamicMessageSeed::strict_any_type_urls`** (#692) reject a `google.protobuf.Any` `@type` that has no `/` when parsing JSON, as C++ and Java do. Both are off by default, and the default accepts a bare message full name that resolves. The first covers generated messages and the second a `DynamicMessage`; each covers an `Any` nested at any depth.' +time: 2026-10-10T10:15:08.176026492-07:00 diff --git a/.changes/unreleased/changed-20261010-101508.yaml b/.changes/unreleased/changed-20261010-101508.yaml new file mode 100644 index 00000000..111248bd --- /dev/null +++ b/.changes/unreleased/changed-20261010-101508.yaml @@ -0,0 +1,5 @@ +kind: Changed +body: |- + **A `google.protobuf.Any` whose `type_url` is a bare message full name is read as that message** (#692). `any.proto` requires a `/` in a type URL, but protobuf-go and Python resolve a string without one as a full name, and so did `DynamicMessage`. The generated `Any` now does the same. Its JSON parser accepts `{"@type": "pkg.Message", ...}` when the type registry has a JSON entry for `pkg.Message`, `AnyRegistry::lookup` and `TypeRegistry::json_any_by_url` find a registered message by its bare name, and an entry registered under a bare name is found under any URL prefix. + This changes JSON output. An `Any` with a bare name and a registered message was written as `{"@type": "pkg.Message", "value": ""}`, which the generated parser rejected, and is now written with the fields of the message; a payload that does not decode as that message now fails to serialize. A bare name without a JSON entry is still a parse error, and so is an `@type` that is empty or ends in `/`. Text format is unchanged: it does not resolve a bare name to a registered message, because `[pkg.Message] { ... }` is the syntax of an extension field. +time: 2026-10-10T10:15:08.164120259-07:00 diff --git a/buffa-descriptor/src/reflect/dynamic.rs b/buffa-descriptor/src/reflect/dynamic.rs index 7a567a8b..a3b13bb1 100644 --- a/buffa-descriptor/src/reflect/dynamic.rs +++ b/buffa-descriptor/src/reflect/dynamic.rs @@ -1510,7 +1510,8 @@ impl DynamicMessage { /// Decode the message wrapped in this `google.protobuf.Any`. /// /// Reads `type_url` (field 1), resolves the type name (the segment after - /// the last `/`) against this message's pool, and decodes `value` + /// the last `/`, or the whole string when it has no `/`) against this + /// message's pool, and decodes `value` /// (field 2) into a [`DynamicMessage`] of that type. This is the binary /// counterpart of the `Any` JSON `@type` expansion; CEL `dyn` evaluation /// unpacks `Any` values this way. diff --git a/buffa-descriptor/src/reflect/json.rs b/buffa-descriptor/src/reflect/json.rs index 6e723047..989a97bd 100644 --- a/buffa-descriptor/src/reflect/json.rs +++ b/buffa-descriptor/src/reflect/json.rs @@ -556,7 +556,7 @@ impl DynamicMessage { pub struct DynamicMessageSeed { pool: Arc, msg_idx: MessageIndex, - ignore_unknown: bool, + flags: ParseFlags, element_memory_limit: usize, } @@ -567,7 +567,7 @@ impl DynamicMessageSeed { Self { pool, msg_idx, - ignore_unknown: false, + flags: ParseFlags::default(), element_memory_limit: buffa::DEFAULT_ELEMENT_MEMORY_LIMIT, } } @@ -583,7 +583,25 @@ impl DynamicMessageSeed { /// [element-memory limit](Self::with_element_memory_limit). #[must_use] pub fn ignore_unknown_fields(mut self, ignore: bool) -> Self { - self.ignore_unknown = ignore; + self.flags.ignore_unknown = ignore; + self + } + + /// Require a `/` in the `@type` of a `google.protobuf.Any` (default: + /// accept a bare full name too). + /// + /// By default `"@type": "my.pkg.Request"` resolves like + /// `"@type": "type.googleapis.com/my.pkg.Request"`, as it does in + /// protobuf-go and Python. With this set, an `@type` without a `/` is a + /// parse error, as it is in C++ and Java; `any.proto` says that a type + /// URL contains at least one `/`. The setting propagates to every nested + /// `Any`, including one inside another `Any`'s payload. + /// + /// The setting covers parsing only: [`DynamicMessage::to_json`] and + /// [`DynamicMessage::unpack_any`] resolve a bare full name. + #[must_use] + pub fn strict_any_type_urls(mut self, strict: bool) -> Self { + self.flags.strict_any_type_urls = strict; self } @@ -706,13 +724,23 @@ impl<'de> DeserializeSeed<'de> for DynamicMessageSeed { NestedSeed { pool: self.pool, msg_idx: self.msg_idx, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: &budget, } .deserialize(d) } } +/// The parse options that [`DynamicMessageSeed`] sets and every nested seed +/// carries. +#[derive(Clone, Copy, Default)] +struct ParseFlags { + /// Discard unknown fields instead of erroring. + ignore_unknown: bool, + /// Reject an `Any` `@type` that has no `/`. + strict_any_type_urls: bool, +} + /// The internal twin of [`DynamicMessageSeed`] that borrows the parse's /// element-memory budget instead of owning a limit. /// @@ -722,7 +750,7 @@ impl<'de> DeserializeSeed<'de> for DynamicMessageSeed { struct NestedSeed<'a> { pool: Arc, msg_idx: MessageIndex, - ignore_unknown: bool, + flags: ParseFlags, budget: &'a Cell, } @@ -732,18 +760,12 @@ impl<'de> DeserializeSeed<'de> for NestedSeed<'_> { fn deserialize>(self, d: D) -> Result { let md = self.pool.message(self.msg_idx); if let Some(wkt) = WktKind::from_full_name(&md.full_name) { - return wkt.deserialize_message( - self.pool, - self.msg_idx, - d, - self.ignore_unknown, - self.budget, - ); + return wkt.deserialize_message(self.pool, self.msg_idx, d, self.flags, self.budget); } d.deserialize_map(MessageVisitor { pool: self.pool, msg_idx: self.msg_idx, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, }) } @@ -752,7 +774,7 @@ impl<'de> DeserializeSeed<'de> for NestedSeed<'_> { struct MessageVisitor<'a> { pool: Arc, msg_idx: MessageIndex, - ignore_unknown: bool, + flags: ParseFlags, budget: &'a Cell, } @@ -814,7 +836,7 @@ impl<'de> Visitor<'de> for MessageVisitor<'_> { // lenient mode. There is no descriptor to deduplicate // against, and the spec's no-duplicates rule is in terms // of fields, not arbitrary keys. - if self.ignore_unknown { + if self.flags.ignore_unknown { map.next_value::()?; continue; } @@ -842,7 +864,7 @@ impl<'de> Visitor<'de> for MessageVisitor<'_> { pool: &self.pool, kind, enum_type, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, })?; // null → leave the field unset (per spec, except NullValue which @@ -904,7 +926,7 @@ struct FieldSeed<'a> { pool: &'a Arc, kind: FieldKind, enum_type: Option, - ignore_unknown: bool, + flags: ParseFlags, budget: &'a Cell, } @@ -920,7 +942,7 @@ impl<'de> DeserializeSeed<'de> for FieldSeed<'_> { pool: self.pool, kind: sk, enum_type: self.enum_type, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, } .deserialize(d), @@ -928,7 +950,7 @@ impl<'de> DeserializeSeed<'de> for FieldSeed<'_> { pool: self.pool, kind: sk, enum_type: self.enum_type, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, }), FieldKind::Map { key, value } => d.deserialize_any(MapFieldVisitor { @@ -936,7 +958,7 @@ impl<'de> DeserializeSeed<'de> for FieldSeed<'_> { key, value, enum_type: self.enum_type, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, }), } @@ -947,7 +969,7 @@ struct SingularSeed<'a> { pool: &'a Arc, kind: SingularKind, enum_type: Option, - ignore_unknown: bool, + flags: ParseFlags, budget: &'a Cell, } @@ -966,7 +988,7 @@ impl<'de> DeserializeSeed<'de> for SingularSeed<'_> { return NestedSeed { pool: Arc::clone(self.pool), msg_idx: midx, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, } .deserialize(d) @@ -976,7 +998,7 @@ impl<'de> DeserializeSeed<'de> for SingularSeed<'_> { d.deserialize_option(NestedMessageVisitor { pool: self.pool, midx, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, }) } @@ -1011,7 +1033,7 @@ fn deserialize_optional_scalar<'de, D: Deserializer<'de>>( struct NestedMessageVisitor<'a> { pool: &'a Arc, midx: MessageIndex, - ignore_unknown: bool, + flags: ParseFlags, budget: &'a Cell, } @@ -1034,7 +1056,7 @@ impl<'de> Visitor<'de> for NestedMessageVisitor<'_> { NestedSeed { pool: Arc::clone(self.pool), msg_idx: self.midx, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, } .deserialize(d) @@ -1238,7 +1260,7 @@ struct ListVisitor<'a> { pool: &'a Arc, kind: SingularKind, enum_type: Option, - ignore_unknown: bool, + flags: ParseFlags, budget: &'a Cell, } @@ -1256,7 +1278,7 @@ impl<'de> Visitor<'de> for ListVisitor<'_> { pool: self.pool, kind: self.kind, enum_type: self.enum_type, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, })? { // Per the spec, repeated fields cannot contain null elements. @@ -1276,7 +1298,7 @@ struct MapFieldVisitor<'a> { key: ScalarType, value: SingularKind, enum_type: Option, - ignore_unknown: bool, + flags: ParseFlags, budget: &'a Cell, } @@ -1298,7 +1320,7 @@ impl<'de> Visitor<'de> for MapFieldVisitor<'_> { pool: self.pool, kind: self.value, enum_type: self.enum_type, - ignore_unknown: self.ignore_unknown, + flags: self.flags, budget: self.budget, })?; let v = v.ok_or_else(|| de::Error::custom("null value in map field"))?; diff --git a/buffa-descriptor/src/reflect/json_wkt.rs b/buffa-descriptor/src/reflect/json_wkt.rs index 1a9877e5..f28bab8d 100644 --- a/buffa-descriptor/src/reflect/json_wkt.rs +++ b/buffa-descriptor/src/reflect/json_wkt.rs @@ -122,7 +122,7 @@ impl WktKind { pool: Arc, midx: MessageIndex, d: D, - ignore_unknown: bool, + flags: ParseFlags, budget: &Cell, ) -> Result { // Two WKTs consult `ignore_unknown`: `Any` recurses into a @@ -131,7 +131,7 @@ impl WktKind { // scalar (a type error, never an unknown field) or are open schemas // that accept any member by construction. match self { - Self::Any => deserialize_any(pool, midx, d, ignore_unknown, budget), + Self::Any => deserialize_any(pool, midx, d, flags, budget), Self::Timestamp => { let s: String = String::deserialize(d)?; let (secs, nanos) = parse_rfc3339(&s).map_err(de::Error::custom)?; @@ -191,7 +191,9 @@ impl WktKind { Ok(()) } } - d.deserialize_map(EmptyVisitor { ignore_unknown })?; + d.deserialize_map(EmptyVisitor { + ignore_unknown: flags.ignore_unknown, + })?; Ok(DynamicMessage::new(pool, midx)) } Self::Wrapper(sc) => { @@ -399,14 +401,15 @@ impl<'de> Visitor<'de> for StructVisitor<'_> { .ok_or_else(|| de::Error::custom("Value not in pool"))?; let mut fields: Vec<(MapKey, Value)> = Vec::new(); while let Some(key) = map.next_key::()? { - // The Value seed deliberately doesn't carry `ignore_unknown`: + // The Value seed deliberately doesn't carry the parse flags: // `google.protobuf.Value` is a closed schema (null/bool/number/ // string/Struct/ListValue) that cannot recurse into a - // user-defined message where unknown fields could appear. + // user-defined message where unknown fields could appear, or + // into an `Any`. let v = map.next_value_seed(NestedSeed { pool: Arc::clone(&self.pool), msg_idx: value_idx, - ignore_unknown: false, + flags: ParseFlags::default(), budget: self.budget, })?; // `Struct.fields` is `map`. @@ -449,11 +452,11 @@ impl<'de> Visitor<'de> for ListValueVisitor<'_> { .ok_or_else(|| de::Error::custom("Value not in pool"))?; let mut items = Vec::new(); // See `StructVisitor::visit_map` for why the Value seed doesn't - // carry `ignore_unknown`. + // carry the parse flags. while let Some(v) = seq.next_element_seed(NestedSeed { pool: Arc::clone(&self.pool), msg_idx: value_idx, - ignore_unknown: false, + flags: ParseFlags::default(), budget: self.budget, })? { // `ListValue.values` is `repeated Value`. @@ -559,7 +562,7 @@ fn deserialize_any<'de, D: Deserializer<'de>>( pool: Arc, midx: MessageIndex, d: D, - ignore_unknown: bool, + flags: ParseFlags, budget: &Cell, ) -> Result { use buffa::json_helpers::buffered; @@ -580,6 +583,11 @@ fn deserialize_any<'de, D: Deserializer<'de>>( let Some(serde_json::Value::String(type_url)) = obj.remove("@type") else { return Err(D::Error::custom("Any object missing string \"@type\"")); }; + if flags.strict_any_type_urls && !type_url.contains('/') { + return Err(D::Error::custom(format!( + "Any type_url {type_url:?} must contain a '/' (e.g. type.googleapis.com/pkg.Type)" + ))); + } let Some(inner_idx) = resolve_any_type(&pool, &type_url) else { return Err(D::Error::custom(format!( "Any type_url {type_url:?} not registered in the descriptor pool" @@ -596,7 +604,7 @@ fn deserialize_any<'de, D: Deserializer<'de>>( "Any with WKT type {type_url:?} requires a \"value\" key" )) })?; - if !ignore_unknown { + if !flags.ignore_unknown { if let Some(key) = obj.keys().next() { return Err(D::Error::custom(format!( "unknown field {key:?} in Any wrapper for {type_url:?}" @@ -616,7 +624,7 @@ fn deserialize_any<'de, D: Deserializer<'de>>( let inner = NestedSeed { pool: Arc::clone(&pool), msg_idx: inner_idx, - ignore_unknown, + flags, budget, } .deserialize(inner_json) @@ -645,7 +653,7 @@ fn deserialize_any<'de, D: Deserializer<'de>>( _pool: Arc, _midx: MessageIndex, _d: D, - _ignore_unknown: bool, + _flags: ParseFlags, _budget: &Cell, ) -> Result { Err(de::Error::custom( @@ -654,7 +662,8 @@ fn deserialize_any<'de, D: Deserializer<'de>>( } /// Resolve a `type_url` to a [`MessageIndex`]. Accepts `type.googleapis.com/` -/// and any other prefix; the type name is the segment after the last `/`. +/// and any other prefix; the type name is the segment after the last `/`, or +/// the whole string when it has no `/`. fn resolve_any_type(pool: &DescriptorPool, type_url: &str) -> Option { let name = type_url.rsplit('/').next()?; pool.message_index(name) diff --git a/buffa-descriptor/tests/options_any_symbol_e2e.rs b/buffa-descriptor/tests/options_any_symbol_e2e.rs index 587811f3..ae7ae720 100644 --- a/buffa-descriptor/tests/options_any_symbol_e2e.rs +++ b/buffa-descriptor/tests/options_any_symbol_e2e.rs @@ -132,6 +132,51 @@ fn any_json_spreads_payload_extensions() { assert_eq!(parsed.unpack_any().unwrap(), opts); } +#[cfg(feature = "json")] +#[test] +fn any_json_accepts_a_bare_type_name_unless_strict() { + use buffa_descriptor::DynamicMessageSeed; + + let p = pool(); + let any_idx = p.message_index("google.protobuf.Any").unwrap(); + let strict = DynamicMessageSeed::new(Arc::clone(&p), any_idx).strict_any_type_urls(true); + + let bare = r#"{"@type":"reflect.opt.Annotated"}"#; + let nested_bare = r#"{"@type":"type.googleapis.com/google.protobuf.Any","value":{"@type":"reflect.opt.Annotated"}}"#; + for input in [bare, nested_bare] { + let parsed = DynamicMessage::from_json(Arc::clone(&p), any_idx, input) + .unwrap_or_else(|err| panic!("{input}: {err}")); + assert_eq!(parsed.to_json().unwrap(), input); + + let err = strict.clone().parse_json(input).unwrap_err(); + assert!( + err.to_string() + .contains("\"reflect.opt.Annotated\" must contain a '/'"), + "{input}: {err}" + ); + } + + let parsed = DynamicMessage::from_json(Arc::clone(&p), any_idx, bare).unwrap(); + assert_eq!( + parsed + .unpack_any() + .unwrap() + .message_descriptor() + .full_name(), + "reflect.opt.Annotated" + ); + + // A bare name the pool does not know is an error in both modes, and a + // type URL parses in both. + let unknown = r#"{"@type":"no.Such"}"#; + assert!(DynamicMessage::from_json(Arc::clone(&p), any_idx, unknown).is_err()); + assert!(strict.clone().parse_json(unknown).is_err()); + let url = r#"{"@type":"type.googleapis.com/google.protobuf.Any","value":{"@type":"x/reflect.opt.Annotated"}}"#; + let parsed = DynamicMessage::from_json(Arc::clone(&p), any_idx, url).unwrap(); + assert_eq!(parsed.to_json().unwrap(), url); + assert_eq!(strict.parse_json(url).unwrap(), parsed); +} + /// Read a custom option off a re-encoded options message: decode it as a /// `DynamicMessage` of `options_type` and pull the extension's value. This /// is the documented generic flow for reading a custom option by name when diff --git a/buffa-test/src/tests/extensions_json.rs b/buffa-test/src/tests/extensions_json.rs index 13def4f1..0b94061a 100644 --- a/buffa-test/src/tests/extensions_json.rs +++ b/buffa-test/src/tests/extensions_json.rs @@ -301,6 +301,26 @@ fn register_types_populates_any_registry() { }); } +#[test] +fn any_json_resolves_a_generated_message_by_its_bare_name() { + use buffa::json::{with_json_parse_options, JsonParseOptions}; + use buffa_types::google::protobuf::Any; + + install_type_registry(); + let json = r#"{"@type":"buffa.test.extjson.Ann","doc":"bare"}"#; + let any: Any = serde_json::from_str(json).expect("bare name resolves"); + assert_eq!(any.type_url, "buffa.test.extjson.Ann"); + let ann: Ann = any.unpack_message().expect("decode").expect("is an Ann"); + assert_eq!(ann.doc.as_deref(), Some("bare")); + assert_eq!(serde_json::to_string(&any).expect("serialize"), json); + + let strict = JsonParseOptions::new().strict_any_type_urls(true); + with_json_parse_options(&strict, || { + let err = serde_json::from_str::(json).expect_err("strict rejects a bare name"); + assert!(err.to_string().contains("must contain a '/'"), "{err}"); + }); +} + #[test] fn json_any_roundtrips_message_through_json() { // Exercise the generated fn pointers directly: encode a message to wire diff --git a/buffa-types/src/any_ext.rs b/buffa-types/src/any_ext.rs index 640e5c61..f688036d 100644 --- a/buffa-types/src/any_ext.rs +++ b/buffa-types/src/any_ext.rs @@ -47,8 +47,8 @@ impl Any { /// [`MessageName::TYPE_URL`](buffa::MessageName::TYPE_URL). /// /// [`unpack_message`](Self::unpack_message) finds the result only if - /// `TYPE_URL` ends in `/` followed by - /// [`FULL_NAME`](buffa::MessageName::FULL_NAME), as it does in every + /// the [type name](Self::type_name) in `TYPE_URL` is + /// [`FULL_NAME`](buffa::MessageName::FULL_NAME), as it is in every /// generated impl. Use [`pack`](Self::pack) to store a URL with a /// different prefix, or to pack a hand-written /// [`Message`](buffa::Message) that does not implement @@ -164,17 +164,22 @@ impl Any { self.type_url == type_url } - /// Returns the type name in the type URL of this [`Any`]: the text after - /// the last `/`. + /// Returns the type name in the `type_url` of this [`Any`]: the text + /// after the last `/`, or the whole `type_url` when it has no `/`. /// /// For an [`Any`] packed by [`pack_message`](Self::pack_message), the /// type name is the message's /// [`MessageName::FULL_NAME`](buffa::MessageName::FULL_NAME). The text is - /// not checked to be a valid message name. The JSON and text registries - /// find a message by this name when its exact URL is not registered. + /// not checked to be a valid message name. The JSON registry finds a + /// message by this name when its exact URL is not registered. The text + /// registry does the same only for a `type_url` that contains a `/`. /// - /// Returns `None` when the URL has no `/`, or when the text after the - /// last `/` is empty. + /// `any.proto` requires a `/` in a type URL. A `type_url` without one is + /// read as a bare full name, as protobuf-go and Python read it; C++ and + /// Java do not match such an [`Any`] to a message. + /// + /// Returns `None` when the `type_url` is empty, or when the text after + /// its last `/` is empty. /// /// To test for one generated type, use [`is_message`](Self::is_message). /// For a name that is known only at run time, compare the result: @@ -193,7 +198,7 @@ impl Any { /// Some(Timestamp::FULL_NAME) => "a timestamp", /// Some(Duration::FULL_NAME) => "a duration", /// Some(_) => "another message", - /// None => "not a type URL", + /// None => "no type name", /// } /// } /// @@ -202,20 +207,27 @@ impl Any { /// assert_eq!(describe(&any), "a duration"); /// /// let no_slash = Any::pack(&Duration::default(), "google.protobuf.Duration"); - /// assert_eq!(no_slash.type_name(), None); - /// assert_eq!(describe(&no_slash), "not a type URL"); + /// assert_eq!(no_slash.type_name(), Some("google.protobuf.Duration")); + /// + /// let no_name = Any::pack(&Duration::default(), "example.com/v1/"); + /// assert_eq!(no_name.type_name(), None); + /// assert_eq!(describe(&no_name), "no type name"); /// ``` pub fn type_name(&self) -> Option<&str> { - let (_, type_name) = self.type_url.rsplit_once('/')?; + let type_url = self.type_url.as_str(); + let type_name = type_url + .rsplit_once('/') + .map_or(type_url, |(_, type_name)| type_name); (!type_name.is_empty()).then_some(type_name) } /// Returns `true` if the type URL of this [`Any`] identifies `T` under - /// any prefix: the name after the last `/` is the + /// any prefix: its [`type_name`](Self::type_name) is the /// [`MessageName::FULL_NAME`](buffa::MessageName::FULL_NAME) of `T`. /// - /// The prefix is not compared. Returns `false` when - /// [`type_name`](Self::type_name) is `None`. + /// The prefix is not compared, and a URL that is only the full name + /// matches too. Returns `false` when [`type_name`](Self::type_name) is + /// `None`. pub fn is_message(&self) -> bool { self.type_name() == Some(::FULL_NAME) } @@ -513,14 +525,23 @@ impl<'de> serde::Deserialize<'de> for Any { } }; - // The type URL must be non-empty, contain a '/', and have a non-empty - // fully-qualified type name after the final slash (e.g. - // "type.googleapis.com/google.protobuf.Duration"). - let type_name = type_url.rsplit('/').next().unwrap_or(""); - if type_url.is_empty() || !type_url.contains('/') || type_name.is_empty() { - return Err(serde::de::Error::custom( - "@type must be a valid type URL containing a '/' and a non-empty type name (e.g. type.googleapis.com/pkg.Type)", - )); + // A type URL has a non-empty fully-qualified type name after its + // final slash (e.g. "type.googleapis.com/google.protobuf.Duration"). + // A string with no slash is a bare type name, which is read only when + // the registry resolves it (below) and the parse options allow it. + let (bare_name, type_name) = match type_url.rsplit_once('/') { + Some((_, type_name)) => (false, type_name), + None => (true, type_url.as_str()), + }; + if type_name.is_empty() { + return Err(serde::de::Error::custom(alloc::format!( + "@type {type_url:?} is not a valid type URL: it names no type (e.g. type.googleapis.com/pkg.Type)" + ))); + } + if bare_name && buffa::__private::strict_any_type_urls() { + return Err(serde::de::Error::custom(alloc::format!( + "@type {type_url:?} must contain a '/' (e.g. type.googleapis.com/pkg.Type)" + ))); } let (registry_installed, lookup) = buffa::any_registry::with_any_registry(|reg| { @@ -541,6 +562,19 @@ impl<'de> serde::Deserialize<'de> for Any { let json_obj = serde_json::Value::Object(obj); from_json(json_obj).map_err(serde::de::Error::custom)? } + // The opaque form below is for a type URL. An unresolved bare + // name is as likely to be a mistyped value as a message name. + None if bare_name => { + return Err(if registry_installed { + serde::de::Error::custom(alloc::format!( + "Any: @type {type_url:?} has no '/', and the message it names has no JSON entry in the type registry; register the message (generated `register_types` or `TypeRegistry::register_json_any`), or send a type URL (e.g. type.googleapis.com/pkg.Type)" + )) + } else { + serde::de::Error::custom(alloc::format!( + "Any: no type registry is installed to resolve @type {type_url:?}, which has no '/'; install one that registers the message with `set_type_registry`, or send a type URL (e.g. type.googleapis.com/pkg.Type)" + )) + }); + } None => { // The type has no JSON entry, so the message's own JSON cannot // be read. What parses is the encoded message as base64 under @@ -812,12 +846,28 @@ mod tests { #[test] fn type_name_is_none_without_a_non_empty_final_segment() { - for type_url in ["", "/", "google.protobuf.Timestamp", "custom.example/v1/"] { + for type_url in ["", "/", "custom.example/v1/"] { let any = Any::pack(&Timestamp::default(), type_url); assert_eq!(any.type_name(), None, "{type_url:?}"); } } + #[test] + fn a_url_without_a_slash_is_a_bare_type_name() { + use crate::google::protobuf::Duration; + + let timestamp = Timestamp { + seconds: 7, + ..Default::default() + }; + let any = Any::pack(×tamp, "google.protobuf.Timestamp"); + assert_eq!(any.type_name(), Some("google.protobuf.Timestamp")); + assert!(any.is_message::()); + assert!(!any.is_message::()); + assert_eq!(any.unpack_message::().unwrap(), Some(timestamp)); + assert_eq!(any.unpack_message::().unwrap(), None); + } + #[test] fn type_name_accepts_an_empty_prefix() { let any = Any::pack(&Timestamp::default(), "/google.protobuf.Timestamp"); @@ -872,7 +922,7 @@ mod tests { assert!(!any.is_message::()); assert_eq!(any.unpack_message::().unwrap(), None); - for type_url in ["google.protobuf.Timestamp", "custom.example/v1/"] { + for type_url in ["", "custom.example/v1/"] { let malformed = Any::pack(&Timestamp::default(), type_url); assert!(!malformed.is_message::(), "{type_url}"); assert_eq!( @@ -1493,17 +1543,113 @@ mod tests { } #[test] - fn deserialize_rejects_empty_type_url() { - let json = r#"{"@type": "", "value": ""}"#; - let err = serde_json::from_str::(json).unwrap_err(); - assert!(err.to_string().contains("valid type URL"), "{err}"); + fn deserialize_rejects_a_type_url_that_names_no_type() { + for type_url in ["", "/", "type.googleapis.com/"] { + let json = serde_json::json!({"@type": type_url, "value": ""}).to_string(); + for err in [ + with_registry(|| serde_json::from_str::(&json).unwrap_err()), + without_registry(|| serde_json::from_str::(&json).unwrap_err()), + ] { + assert!(err.to_string().contains("names no type"), "{json}: {err}"); + } + } } #[test] - fn deserialize_rejects_type_url_without_slash() { - let json = r#"{"@type": "not_a_url", "value": ""}"#; - let err = serde_json::from_str::(json).unwrap_err(); - assert!(err.to_string().contains("valid type URL"), "{err}"); + fn registered_any_accepts_a_bare_type_name() { + with_registry(|| { + let json = r#"{"@type":"google.protobuf.Duration","value":"1.500s"}"#; + let any: Any = serde_json::from_str(json).unwrap(); + assert_eq!(any.type_url, "google.protobuf.Duration"); + assert_eq!( + any.unpack_message::().unwrap(), + Some(Duration::from_secs_nanos(1, 500_000_000)) + ); + // The serializer resolves the bare name too, so the expanded + // form round-trips. + assert_eq!(serde_json::to_string(&any).unwrap(), json); + }); + } + + #[test] + fn deserialize_rejects_an_unregistered_bare_type_name() { + // The opaque base64 form is read for a type URL only. The second + // input is the conformance suite's + // `AnyWktRepresentationWithBadType`. + for json in [ + r#"{"@type": "unknown.Type", "value": "CAI="}"#, + r#"{"@type": "not_a_url", "value": ""}"#, + ] { + let err = with_registry(|| serde_json::from_str::(json).unwrap_err()); + assert!( + err.to_string() + .contains("has no JSON entry in the type registry"), + "{json}: {err}" + ); + let err = without_registry(|| serde_json::from_str::(json).unwrap_err()); + assert!( + err.to_string().contains("no type registry is installed"), + "{json}: {err}" + ); + } + } + + #[test] + fn nested_any_accepts_a_bare_type_name() { + with_registry(|| { + for outer in [ + "type.googleapis.com/google.protobuf.Any", + "google.protobuf.Any", + ] { + let json = alloc::format!( + r#"{{"@type":"{outer}","value":{{"@type":"google.protobuf.Duration","value":"1.500s"}}}}"# + ); + let any: Any = serde_json::from_str(&json).unwrap(); + let inner: Any = any.unpack_message().unwrap().unwrap(); + assert_eq!(inner.type_url, "google.protobuf.Duration"); + assert_eq!(serde_json::to_string(&any).unwrap(), json); + } + }); + } + + #[test] + fn serialize_writes_an_unregistered_bare_type_name_as_base64() { + // The parser rejects this output: an `Any` whose bare name has no + // JSON entry does not round-trip through JSON. + let any = Any { + type_url: "unknown.Type".into(), + value: vec![0x08, 0x02].into(), + ..Default::default() + }; + with_registry(|| { + let json = serde_json::to_string(&any).unwrap(); + assert_eq!(json, r#"{"@type":"unknown.Type","value":"CAI="}"#); + assert!(serde_json::from_str::(&json).is_err()); + }); + } + + #[cfg(feature = "std")] + #[test] + fn strict_any_type_urls_rejects_a_bare_type_name() { + use buffa::json::{with_json_parse_options, JsonParseOptions}; + + let strict = JsonParseOptions::new().strict_any_type_urls(true); + with_registry(|| { + with_json_parse_options(&strict, || { + let bare = r#"{"@type":"google.protobuf.Duration","value":"1.500s"}"#; + let err = serde_json::from_str::(bare).unwrap_err(); + assert!(err.to_string().contains("must contain a '/'"), "{err}"); + + // The option covers an `Any` nested in an `Any`. + let nested = r#"{"@type":"x/google.protobuf.Any","value":{"@type":"google.protobuf.Duration","value":"1.500s"}}"#; + let err = serde_json::from_str::(nested).unwrap_err(); + assert!(err.to_string().contains("must contain a '/'"), "{err}"); + + let url = r#"{"@type":"x/google.protobuf.Duration","value":"1.500s"}"#; + let any: Any = serde_json::from_str(url).unwrap(); + assert_eq!(any.type_url, "x/google.protobuf.Duration"); + }); + }); } #[test] @@ -1534,15 +1680,6 @@ mod tests { }); } - #[test] - fn deserialize_rejects_type_url_with_empty_type_name() { - without_registry(|| { - let json = r#"{"@type": "type.googleapis.com/", "value": ""}"#; - let err = serde_json::from_str::(json).unwrap_err(); - assert!(err.to_string().contains("valid type URL"), "{err}"); - }); - } - // ── Non-WKT registered type (fields inlined at top level) ───── // WKTs use {"@type": ..., "value": } wrapping. // Regular messages use {"@type": ..., "field1": ..., "field2": ...}. @@ -1672,6 +1809,57 @@ mod tests { }); } + #[test] + fn non_wkt_accepts_a_bare_type_name() { + with_user_type_registry(|| { + let json = r#"{"@type":"user.Thing","id":99}"#; + let any: Any = serde_json::from_str(json).unwrap(); + assert_eq!(any.type_url, "user.Thing"); + assert_eq!(any.value, vec![0x08, 99]); + assert_eq!(serde_json::to_string(&any).unwrap(), json); + }); + } + + #[test] + fn an_entry_registered_under_a_bare_name_is_found_under_any_prefix() { + use buffa::type_registry::JsonAnyEntry; + + let _guard = REGISTRY_LOCK.lock().unwrap(); + let mut reg = TypeRegistry::new(); + reg.register_json_any(JsonAnyEntry { + type_url: "user.Bare", + to_json: user_type_to_json, + from_json: user_type_from_json, + is_wkt: false, + }); + set_type_registry(reg); + + for type_url in ["user.Bare", "x/user.Bare"] { + let json = alloc::format!(r#"{{"@type":"{type_url}","id":1}}"#); + let any: Any = serde_json::from_str(&json).unwrap(); + assert_eq!(any.type_url, type_url); + assert_eq!(any.value, vec![0x08, 1]); + } + + // The strict option rejects the bare form of an exact registration too. + #[cfg(feature = "std")] + { + use buffa::json::{with_json_parse_options, JsonParseOptions}; + let strict = JsonParseOptions::new().strict_any_type_urls(true); + with_json_parse_options(&strict, || { + assert!( + serde_json::from_str::(r#"{"@type":"user.Bare","id":1}"#).is_err() + ); + assert!( + serde_json::from_str::(r#"{"@type":"x/user.Bare","id":1}"#).is_ok() + ); + }); + } + + clear_any_registry(); + clear_text_registry(); + } + #[test] fn non_wkt_round_trip() { with_user_type_registry(|| { diff --git a/buffa/src/any_registry.rs b/buffa/src/any_registry.rs index 26b05c65..fd1e1a8d 100644 --- a/buffa/src/any_registry.rs +++ b/buffa/src/any_registry.rs @@ -28,8 +28,8 @@ use hashbrown::HashMap; pub struct JsonAnyEntry { /// The full type URL (e.g. `"type.googleapis.com/google.protobuf.Duration"`). /// - /// A URL with no `/` carries no message name, and is found only by an - /// exact match. + /// The message full name is the text after the last `/`, or the whole + /// string when it has no `/`. pub type_url: &'static str, /// Serialize: binary `Any.value` bytes → JSON representation of the @@ -90,7 +90,8 @@ impl AnyRegistry { /// another prefix that does not decode as it fails to serialize as JSON; /// one that does decode is written with this message's fields. pub fn register(&mut self, entry: JsonAnyEntry) { - if let Some(type_name) = crate::type_registry::any_type_name(entry.type_url) { + let type_name = message_name(entry.type_url); + if !type_name.is_empty() { self.by_type_name.insert(type_name, entry.type_url); } self.entries.insert(entry.type_url.to_owned(), entry); @@ -102,7 +103,9 @@ impl AnyRegistry { /// message full name after its last `/`, because `google.protobuf.Any` /// identifies the message by that name and leaves the prefix to the /// application. `custom.example/v1/pkg.Message` therefore finds a type - /// registered as `type.googleapis.com/pkg.Message`. + /// registered as `type.googleapis.com/pkg.Message`. A string without a + /// `/` is matched as a bare full name, so `pkg.Message` finds that type + /// too. /// /// The entry's `type_url` is the registered one. Keep the URL you looked /// up as the `Any`'s `type_url`. @@ -111,12 +114,20 @@ impl AnyRegistry { return Some(entry); } - let type_name = crate::type_registry::any_type_name(type_url)?; - let registered_url = *self.by_type_name.get(type_name)?; + let registered_url = *self.by_type_name.get(message_name(type_url))?; self.entries.get(registered_url) } } +/// Returns the message full name in a type URL: the text after its last `/`, +/// or the whole string when it has no `/`. Empty when the URL names no +/// message. +fn message_name(type_url: &str) -> &str { + type_url + .rsplit_once('/') + .map_or(type_url, |(_, type_name)| type_name) +} + impl core::fmt::Debug for AnyRegistry { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("AnyRegistry") @@ -230,8 +241,19 @@ mod tests { let other = registry.lookup("other.example/v1/test.Message").unwrap(); assert_eq!(other.type_url, "second.example/test.Message"); - // A URL with no `/`, or with nothing after it, carries no name. - assert!(registry.lookup("test.Message").is_none()); + // A string with no `/` is a bare full name. + let bare = registry.lookup("test.Message").unwrap(); + assert_eq!(bare.type_url, "second.example/test.Message"); + + // An entry registered under a bare name is found under a prefix too. + registry.register(entry!("test.Bare", false)); + let prefixed = registry.lookup("other.example/test.Bare").unwrap(); + assert_eq!(prefixed.type_url, "test.Bare"); + + // A name no entry was registered for, an empty string, and a URL with + // nothing after its last `/` find no entry. + assert!(registry.lookup("test.Other").is_none()); + assert!(registry.lookup("").is_none()); assert!(registry.lookup("other.example/").is_none()); } diff --git a/buffa/src/json.rs b/buffa/src/json.rs index 6064d445..4dbd54d9 100644 --- a/buffa/src/json.rs +++ b/buffa/src/json.rs @@ -6,8 +6,10 @@ //! implementations. //! //! The options here are runtime ones: unknown enum *values* -//! ([`JsonParseOptions::ignore_unknown_enum_values`]) and unregistered -//! extension keys ([`JsonParseOptions::strict_extension_keys`]). Unknown +//! ([`JsonParseOptions::ignore_unknown_enum_values`]), unregistered +//! extension keys ([`JsonParseOptions::strict_extension_keys`]) and +//! `google.protobuf.Any` `@type` values without a `/` +//! ([`JsonParseOptions::strict_any_type_urls`]). Unknown //! *field names* are not governed here: generated deserializers ignore them //! unless the code was generated with //! `buffa_build::Config::deny_unknown_json_fields` (or its path-scoped @@ -131,10 +133,32 @@ pub struct JsonParseOptions { /// which also rejects `"[pkg.ext]"` keys on a message generated with /// preservation off. pub strict_extension_keys: bool, + /// When `true`, the `@type` of a `google.protobuf.Any` must be a type + /// URL: it must contain a `/`. A bare message full name such as + /// `"pkg.Message"` is a parse error. + /// + /// With the default (`false`), a bare full name is accepted when the + /// type registry has a JSON entry for a message of that name, and the + /// `Any` keeps it as its `type_url`. protobuf-go and Python read such a + /// name. C++ and Java reject it, and `any.proto` says that a type URL + /// contains at least one `/`. Set `true` to reject what C++ and Java + /// reject. + /// + /// A bare name without a JSON entry is a parse error with the option on + /// or off, and so is an `@type` that is empty or ends in `/`. + /// + /// The option covers parsing, and generated message types only. + /// Serialization writes the `type_url` an `Any` holds, and + /// `Any::type_name` and `Any::is_message` in `buffa-types` read a bare + /// name with the option on or off: test `type_url` for a `/` yourself + /// where that matters. JSON parsing of a `DynamicMessage` does not read + /// the option. Set `DynamicMessageSeed::strict_any_type_urls` in + /// `buffa-descriptor` for that. + pub strict_any_type_urls: bool, } impl JsonParseOptions { - /// Create new parse options with all flags at their default (strict) values. + /// Create new parse options with every flag at its default, `false`. pub fn new() -> Self { Self::default() } @@ -155,6 +179,15 @@ impl JsonParseOptions { self.strict_extension_keys = strict; self } + + /// Set whether the `@type` of a `google.protobuf.Any` must contain a `/` + /// (`true`), or may also be the bare full name of a registered message + /// (`false`, the default). + #[must_use] + pub fn strict_any_type_urls(mut self, strict: bool) -> Self { + self.strict_any_type_urls = strict; + self + } } // ───────────────────────────────────────────────────────────────────────────── @@ -171,13 +204,14 @@ mod std_impl { Cell::new(JsonParseOptions { ignore_unknown_enum_values: false, strict_extension_keys: false, + strict_any_type_urls: false, }) }; } /// Run a closure with the given parse options active. /// - /// The options affect enum deserialization within the closure. This is + /// The options affect JSON deserialization within the closure. This is /// **thread-local** state — concurrent parses on different threads are /// independent. The previous options are restored when the closure returns /// (or panics), so scopes nest correctly. @@ -204,6 +238,10 @@ mod std_impl { pub(crate) fn strict_extension_keys() -> bool { OPTIONS.with(|c| c.get().strict_extension_keys) } + + pub(crate) fn strict_any_type_urls() -> bool { + OPTIONS.with(|c| c.get().strict_any_type_urls) + } } #[cfg(feature = "std")] @@ -238,6 +276,7 @@ mod global { static DEFAULT: JsonParseOptions = JsonParseOptions { ignore_unknown_enum_values: false, strict_extension_keys: false, + strict_any_type_urls: false, }; /// Set the global JSON parse options. @@ -294,6 +333,10 @@ mod global { pub(crate) fn strict_extension_keys() -> bool { get().strict_extension_keys } + + pub(crate) fn strict_any_type_urls() -> bool { + get().strict_any_type_urls + } } #[cfg(not(feature = "std"))] @@ -333,6 +376,19 @@ pub(crate) fn strict_extension_keys() -> bool { } } +/// Returns `true` if a `google.protobuf.Any` `@type` without a `/` should +/// produce a parse error. +pub(crate) fn strict_any_type_urls() -> bool { + #[cfg(feature = "std")] + { + std_impl::strict_any_type_urls() + } + #[cfg(not(feature = "std"))] + { + global::strict_any_type_urls() + } +} + // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── @@ -364,6 +420,17 @@ mod tests { assert!(!ignore_unknown_enum_values()); } + #[test] + fn thread_local_scope_enables_strict_any_type_urls() { + assert!(!strict_any_type_urls()); + let opts = JsonParseOptions::new().strict_any_type_urls(true); + with_json_parse_options(&opts, || { + assert!(strict_any_type_urls()); + assert!(!ignore_unknown_enum_values()); + }); + assert!(!strict_any_type_urls()); + } + #[test] fn thread_local_nested_scopes_restore_correctly() { let outer = JsonParseOptions { diff --git a/buffa/src/lib.rs b/buffa/src/lib.rs index 735c5e5c..358399ac 100644 --- a/buffa/src/lib.rs +++ b/buffa/src/lib.rs @@ -292,6 +292,15 @@ pub use view::{ /// release. Do not use them directly. #[doc(hidden)] pub mod __private { + /// Whether the active JSON parse options reject a `google.protobuf.Any` + /// `@type` without a `/`. The `Any` deserializer in `buffa-types` reads + /// it. + #[cfg(feature = "json")] + #[must_use] + pub fn strict_any_type_urls() -> bool { + crate::json::strict_any_type_urls() + } + /// The memory one element of a repeated field occupies in the collection /// holding it, for charging against /// [`DecodeContext::register_element_memory`](crate::DecodeContext::register_element_memory). diff --git a/buffa/src/type_registry.rs b/buffa/src/type_registry.rs index 79d3bd92..877a9620 100644 --- a/buffa/src/type_registry.rs +++ b/buffa/src/type_registry.rs @@ -35,11 +35,15 @@ use alloc::boxed::Box; -/// Return the protobuf full name carried by an `Any` type URL. +/// Return the protobuf full name carried by an `Any` type URL, for the text +/// registry. /// /// The `Any` contract identifies the embedded message by the path segment -/// after the final slash; the URL prefix is application-defined. -pub(crate) fn any_type_name(type_url: &str) -> Option<&str> { +/// after the final slash; the URL prefix is application-defined. A string +/// without a slash carries no name here: text format writes `[name] { ... }` +/// for an extension field, so only a URL is expanded. +#[cfg(feature = "text")] +fn any_type_name(type_url: &str) -> Option<&str> { let (_, type_name) = type_url.rsplit_once('/')?; (!type_name.is_empty()).then_some(type_name) } @@ -430,9 +434,9 @@ impl TypeRegistry { /// Look up a JSON `Any` entry by type URL. /// /// A registered URL finds its own entry. Any other URL is matched by the - /// message full name after its last `/`. The entry's `type_url` is the - /// registered one, so keep the URL you looked up as the `Any`'s - /// `type_url`. + /// message full name after its last `/`, and a string without a `/` as a + /// bare full name. The entry's `type_url` is the registered one, so keep + /// the URL you looked up as the `Any`'s `type_url`. #[cfg(feature = "json")] pub fn json_any_by_url(&self, type_url: &str) -> Option<&JsonAnyEntry> { self.json_any.lookup(type_url) @@ -453,8 +457,10 @@ impl TypeRegistry { /// Look up a text `Any` entry by type URL. /// /// A registered URL finds its own entry. Any other URL is matched by the - /// message full name after its last `/`. The entry's `type_url` is the - /// registered one, so keep the URL you looked up as the `Any`'s + /// message full name after its last `/`. A string without a `/` is found + /// only by an exact match, unlike in + /// [`json_any_by_url`](Self::json_any_by_url). The entry's `type_url` is + /// the registered one, so keep the URL you looked up as the `Any`'s /// `type_url`. #[cfg(feature = "text")] pub fn text_any_by_url(&self, type_url: &str) -> Option<&TextAnyEntry> { diff --git a/docs/guide.md b/docs/guide.md index df73be19..9568640f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -2030,6 +2030,13 @@ prefix. `type_name()` returns that name; for a name known only at run time, write `any.type_name() == Some(name)`. `is_type` and `unpack_if` take a URL and compare the whole of it, prefix included. +`any.proto` says that a type URL contains at least one `/`, and `pack_message` +and `try_pack_message` write one; `pack` and `try_pack` store the string you +pass. A `type_url` that is only a full name, such as +`my.package.MyMessage`, is read as that name: `type_name()` returns it, and +`is_message` and `unpack_message` match it. protobuf-go and Python read such +an `Any` the same way; C++ and Java do not match it to a message. + JSON and text format write an `Any` with the fields of the packed message expanded: `{"@type": "type.googleapis.com/my.package.MyMessage", "name": "x"}` in JSON, and `[type.googleapis.com/my.package.MyMessage] { name: "x" }` in @@ -2045,6 +2052,26 @@ lookup uses the message name after the last `/`. So a type registered under one prefix is found under any other. The lookup does not rewrite `type_url`: the `Any` keeps the URL it was given. +JSON also resolves a bare full name: `{"@type": "my.package.MyMessage", "name": +"x"}` parses when `my.package.MyMessage` has a JSON entry in the registry, and +an `Any` with that `type_url` is written in the same form. A bare name with no +JSON entry is a parse error, even in the base64 form described below, which is +read only for an `@type` that contains a `/`. To reject every `@type` without a +`/` when parsing, as C++ and Java do, set `strict_any_type_urls`: + +```rust,ignore +use buffa::json::{JsonParseOptions, with_json_parse_options}; + +let opts = JsonParseOptions::new().strict_any_type_urls(true); +let msg = with_json_parse_options(&opts, || serde_json::from_str::(json))?; +``` + +`DynamicMessageSeed::strict_any_type_urls` is the same setting for a +`DynamicMessage`; `JsonParseOptions` does not cover one. Text format does not +resolve a bare name to a registered message, because +`[my.package.MyMessage] { ... }` is the syntax of an extension field: such an +`Any` is written with its `type_url` and `value` fields. + For a type with no JSON entry in the registry, buffa writes `{"@type": "...", "value": ""}`: the encoded message as base64 under `value`. That form is specific to buffa; other protobuf implementations report