diff --git a/Cargo.lock b/Cargo.lock index 1b46c5505d8..08d530cadfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -772,6 +772,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1140,7 +1149,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -2505,6 +2514,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -4038,6 +4053,18 @@ dependencies = [ "prost 0.14.4", ] +[[package]] +name = "proto-descriptors" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "convert_case 0.11.0", + "prost 0.14.4", + "prost-types", + "relay-serialization", +] + [[package]] name = "psl" version = "2.1.216" @@ -4986,6 +5013,8 @@ dependencies = [ name = "relay-serialization" version = "26.7.2" dependencies = [ + "leb128", + "prost 0.14.4", "serde", "serde_json", ] @@ -5060,6 +5089,7 @@ dependencies = [ "relay-redis", "relay-replays", "relay-sampling", + "relay-serialization", "relay-spans", "relay-statsd", "relay-system", diff --git a/Cargo.toml b/Cargo.toml index 5ca51c5fce8..a5539d4ccfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,6 +145,7 @@ insta = { version = "1", features = ["json", "redactions", "ron"] } ipnetwork = "0.21" itertools = "0.14" json-forensics = "0.1" +leb128 = "0.2.7" libc = "0.2" liblzma = "0.4" lru = "0.16" diff --git a/relay-serialization/Cargo.toml b/relay-serialization/Cargo.toml index acef4aaf4a7..f3bffed128b 100644 --- a/relay-serialization/Cargo.toml +++ b/relay-serialization/Cargo.toml @@ -13,7 +13,9 @@ publish = false workspace = true [dependencies] +prost = { workspace = true } serde = { workspace = true } +leb128 = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/relay-serialization/src/lib.rs b/relay-serialization/src/lib.rs index c7b71d62048..93d586b858a 100644 --- a/relay-serialization/src/lib.rs +++ b/relay-serialization/src/lib.rs @@ -4,4 +4,7 @@ #![warn(missing_docs)] +mod meter; + +pub mod prost; pub mod serde; diff --git a/relay-serialization/src/meter.rs b/relay-serialization/src/meter.rs new file mode 100644 index 00000000000..382034d8963 --- /dev/null +++ b/relay-serialization/src/meter.rs @@ -0,0 +1,56 @@ +//! The operation budget shared by every bounded deserializer in this crate. + +use std::fmt; + +/// A budget for the ops a single deserialization is allowed to spend. +pub(crate) struct Meter { + limit: usize, + remaining: usize, + exceeded: bool, +} + +impl Meter { + /// Creates a meter which allows spending at most `limit` operations. + pub fn new(limit: usize) -> Self { + Self { + limit, + remaining: limit, + exceeded: false, + } + } + + /// Returns the number of ops spent. + pub fn spent(&self) -> usize { + self.limit - self.remaining + } + + /// Returns true if we've exceeded our budget. + pub fn exceeded(&self) -> bool { + self.exceeded + } + + /// Tries to charge `amount` operations to the budget. If we exceed, we return an error, + /// set the remaining budget to 0, and mark the budget as exceeded. + pub fn spend(&mut self, amount: usize) -> Result<(), LimitExceeded> { + match self.remaining.checked_sub(amount) { + Some(remaining) => { + self.remaining = remaining; + Ok(()) + } + None => { + self.remaining = 0; + self.exceeded = true; + Err(LimitExceeded) + } + } + } +} + +/// The error produced when a [`Meter`] runs out of budget. +pub(crate) struct LimitExceeded; + +impl fmt::Display for LimitExceeded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "deserialization exceeds the operation budget") + } +} diff --git a/relay-serialization/src/prost/mod.rs b/relay-serialization/src/prost/mod.rs new file mode 100644 index 00000000000..2b6867f69bf --- /dev/null +++ b/relay-serialization/src/prost/mod.rs @@ -0,0 +1,9 @@ +//! Deserialization routines for prost. +//! This implements a scanner to record the number of "operations" needed to decode a proto, +//! allowing a caller to enforce a hard limit on how much work to be done. +mod scan; + +pub use scan::Error; +pub use scan::MessageDesc; +pub use scan::decode; +pub use scan::scan; diff --git a/relay-serialization/src/prost/scan.rs b/relay-serialization/src/prost/scan.rs new file mode 100644 index 00000000000..808ac740fb2 --- /dev/null +++ b/relay-serialization/src/prost/scan.rs @@ -0,0 +1,427 @@ +use prost::{DecodeError, Message}; +use std::fmt; + +use crate::meter::{LimitExceeded, Meter}; + +/// Costs associated with different kinds of operations; right now, just one cost for every field +/// occurrence on the wire (but leave the door open for more.) +mod cost { + pub const FIELD: usize = 1; +} + +/// The maximum nesting depth the scanner walks before giving up. This matches prost's own +/// `RECURSION_LIMIT`. +const RECURSION_LIMIT: u32 = 100; + +/// Protobuf wire types, as encoded in the bottom three bits of a field key. +mod wire_type { + pub const VARINT: u8 = 0; + pub const SIXTY_FOUR_BIT: u8 = 1; + pub const LENGTH_DELIMITED: u8 = 2; + pub const START_GROUP: u8 = 3; + pub const END_GROUP: u8 = 4; + pub const THIRTY_TWO_BIT: u8 = 5; +} + +/// Describes the parts of the proto message we're interested in: the name, as well as the nested +/// types into which we'll need to descend. +pub struct MessageDesc { + /// The name of the message. + pub name: &'static str, + + /// The types nested within this message. + pub nested: &'static [(u32, &'static MessageDesc)], +} + +impl MessageDesc { + /// Returns the descriptor for the nested message at `tag`, if that field holds one. + fn nested(&self, tag: u32) -> Option<&'static MessageDesc> { + self.nested + .iter() + .find(|(nested_tag, _)| *nested_tag == tag) + .map(|(_, desc)| *desc) + } +} + +/// An error returned by [`scan`] or [`decode`]. +#[derive(Debug)] +pub enum Error { + /// A scanner error (reading a primitive, reaching a recursion limit, etc.) + ScanError(String), + /// Actually hitting the op budget for the decode. + LimitExceeded, + /// A decoder error, originating from prost. + Decode(DecodeError), +} + +impl Error { + /// Returns `true` if the message decoding exceeded the budget. + pub fn is_limit_exceeded(&self) -> bool { + matches!(self, Self::LimitExceeded) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ScanError(msg) => write!(f, "scanner error: {}", msg), + Self::LimitExceeded => write!(f, "message exceeds the operation limit"), + Self::Decode(error) => error.fmt(f), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ScanError(_) => None, + Self::LimitExceeded => None, + Self::Decode(error) => Some(error), + } + } +} + +/// Decodes an `M` from `buf`, spending at most `max_ops` doing so. Returns +/// [`Error::LimitExceeded`] if the message exceeds the budget. +pub fn decode(buf: &[u8], desc: &MessageDesc, max_ops: usize) -> Result +where + M: Message + Default, +{ + scan(buf, desc, max_ops)?; + M::decode(buf).map_err(Error::Decode) +} + +/// Checks that the message in `buf` fits within `max_ops`, without decoding it. Returns +/// the number of ops spent doing the scan. +pub fn scan(buf: &[u8], desc: &MessageDesc, max_ops: usize) -> Result { + let mut meter = Meter::new(max_ops); + + match scan_message(buf, desc, &mut meter, 0) { + Ok(()) => Ok(meter.spent()), + // The budget is checked first, because it travels as an ordinary decode error. + Err(_) if meter.exceeded() => Err(Error::LimitExceeded), + Err(error) => Err(error), + } +} + +impl From for Error { + fn from(_: LimitExceeded) -> Self { + Error::LimitExceeded + } +} + +fn scan_message( + buf: &[u8], + desc: &MessageDesc, + meter: &mut Meter, + depth: u32, +) -> Result<(), Error> { + if depth > RECURSION_LIMIT { + return Err(Error::ScanError(format!( + "{}: recursion limit reached", + desc.name + ))); + } + + let mut reader = Reader(buf); + while !reader.is_empty() { + let (tag, wire_type) = key(&mut reader)?; + meter.spend(cost::FIELD)?; + scan_field(&mut reader, tag, wire_type, Some(desc), meter, depth)?; + } + + Ok(()) +} + +/// Consumes the body of a single field, recursing if the schema says it holds a message. +fn scan_field( + reader: &mut Reader<'_>, + tag: u32, + wire_type: u8, + desc: Option<&MessageDesc>, + meter: &mut Meter, + depth: u32, +) -> Result<(), Error> { + match wire_type { + wire_type::VARINT => { + reader.read_varint()?; + } + wire_type::SIXTY_FOUR_BIT => { + reader.read_exact(8)?; + } + wire_type::THIRTY_TWO_BIT => { + reader.read_exact(4)?; + } + wire_type::LENGTH_DELIMITED => { + let len: usize = usize::try_from(reader.read_varint()?) + .map_err(|_| Error::ScanError("buffer underflow".to_owned()))?; + let payload = reader.read_exact(len)?; + + // Only recurse where our generated schema tells us we have a nested message. This is + // how we distinguish between strings/repeated bytes, and genuine nested messages. + if let Some(nested) = desc.and_then(|desc| desc.nested(tag)) { + scan_message(payload, nested, meter, depth + 1)?; + } + } + // proto3 has no groups, so nothing inside one can have a descriptor. This exists so that a + // payload carrying group wire types is walked rather than rejected, matching what prost's + // `skip_field` accepts. + wire_type::START_GROUP => scan_group(reader, tag, meter, depth + 1)?, + wire_type::END_GROUP => { + return Err(Error::ScanError("unexpected end group tag".to_owned())); + } + _ => return Err(Error::ScanError("invalid wire type value".to_owned())), + } + + Ok(()) +} + +fn scan_group( + reader: &mut Reader<'_>, + group_tag: u32, + meter: &mut Meter, + depth: u32, +) -> Result<(), Error> { + if depth > RECURSION_LIMIT { + return Err(Error::ScanError("recursion limit reached".to_owned())); + } + + loop { + let (tag, wire_type) = key(reader)?; + meter.spend(cost::FIELD)?; + + if wire_type == wire_type::END_GROUP { + if tag != group_tag { + return Err(Error::ScanError("unexpected end group tag".to_owned())); + } + return Ok(()); + } + + scan_field(reader, tag, wire_type, None, meter, depth)?; + } +} + +fn key(reader: &mut Reader<'_>) -> Result<(u32, u8), Error> { + let key = reader.read_varint()?; + let wire_type = (key & 0b111) as u8; + let tag = + u32::try_from(key >> 3).map_err(|_| Error::ScanError("invalid tag value".to_owned()))?; + + if tag == 0 { + return Err(Error::ScanError("invalid tag value".to_owned())); + } + + Ok((tag, wire_type)) +} + +// A little wrapper to assist with reading and consuming bytes from a proto byte-buffer. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn read_varint(&mut self) -> Result { + leb128::read::unsigned(&mut self.0) + .map_err(|_| Error::ScanError("invalid varint".to_owned())) + } + + fn read_exact(&mut self, len: usize) -> Result<&'a [u8], Error> { + if len > self.0.len() { + return Err(Error::ScanError("buffer underflow".to_owned())); + } + + let (payload, rest) = self.0.split_at(len); + self.0 = rest; + Ok(payload) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A leaf message with one field of every shape the scanner dispatches on. + #[derive(Clone, PartialEq, Message)] + struct Leaf { + #[prost(string, tag = "1")] + text: String, + #[prost(uint64, tag = "2")] + number: u64, + #[prost(double, tag = "3")] + double: f64, + #[prost(fixed32, tag = "4")] + fixed: u32, + #[prost(bytes = "vec", tag = "5")] + blob: Vec, + } + + static LEAF: MessageDesc = MessageDesc { + name: "Leaf", + nested: &[], + }; + + /// A message which nests, so the scanner has to recurse to see the leaves. + #[derive(Clone, PartialEq, Message)] + struct Branch { + #[prost(message, repeated, tag = "1")] + leaves: Vec, + #[prost(message, optional, boxed, tag = "2")] + branch: Option>, + #[prost(string, tag = "3")] + label: String, + } + + static BRANCH: MessageDesc = MessageDesc { + name: "Branch", + nested: &[(1, &LEAF), (2, &BRANCH)], + }; + + fn ops(buf: &[u8], desc: &MessageDesc) -> usize { + let mut meter = Meter::new(usize::MAX); + scan_message(buf, desc, &mut meter, 0).unwrap(); + meter.spent() + } + + #[test] + fn test_scan_charges_each_empty_element() { + // The case a size bound cannot see: every element is tiny, but there are a great many. + for count in [0, 1, 2, 512] { + let branch = Branch { + leaves: vec![Leaf::default(); count], + ..Default::default() + }; + + // One op for each `leaves` occurrence. An empty `Leaf` has no fields of its own, and + // default scalars are not encoded at all in proto3. + assert_eq!(ops(&branch.encode_to_vec(), &BRANCH), count); + } + } + + #[test] + fn test_scan_charges_opaque_payload_once() { + // A megabyte in one field costs one op, where a byte meter would charge a megabyte. + let branch = Branch { + label: "a".repeat(1 << 20), + ..Default::default() + }; + + assert_eq!(ops(&branch.encode_to_vec(), &BRANCH), 1); + } + + #[test] + fn test_scan_charges_every_depth() { + let branch = Branch { + leaves: vec![ + Leaf { + text: "one".to_owned(), + number: 1, + double: 1.5, + fixed: 2, + blob: vec![1, 2, 3], + }, + Leaf { + number: 7, + ..Default::default() + }, + ], + branch: Some(Box::new(Branch { + label: "inner".to_owned(), + ..Default::default() + })), + label: "outer".to_owned(), + }; + + // Two `leaves` occurrences carrying five and one field, one `branch` carrying one field, + // and the outer `label`. + let expected = (1 + 5) + (1 + 1) + (1 + 1) + 1; + assert_eq!(ops(&branch.encode_to_vec(), &BRANCH), expected); + } + + #[test] + fn test_scan_exceeds_budget() { + let branch = Branch { + leaves: vec![Leaf::default(); 4096], + ..Default::default() + }; + + let error = scan(&branch.encode_to_vec(), &BRANCH, 128).unwrap_err(); + assert!(error.is_limit_exceeded()); + assert_eq!(error.to_string(), "message exceeds the operation limit"); + } + + #[test] + fn test_scan_exceeds_budget_when_nested() { + // The budget has to survive recursion: the fields are all four levels down. + let leaves = vec![Leaf::default(); 4096]; + let branch = Branch { + branch: Some(Box::new(Branch { + branch: Some(Box::new(Branch { + leaves, + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + }; + + let error = scan(&branch.encode_to_vec(), &BRANCH, 256).unwrap_err(); + assert!(error.is_limit_exceeded()); + } + + #[test] + fn test_decode_rejects_before_decoding() { + let branch = Branch { + leaves: vec![Leaf::default(); 4096], + ..Default::default() + }; + let payload = branch.encode_to_vec(); + + assert!(decode::(&payload, &BRANCH, 128).is_err()); + // The same payload decodes once the budget accommodates it. + let decoded = decode::(&payload, &BRANCH, 1 << 20).unwrap(); + assert_eq!(decoded, branch); + } + + #[test] + fn test_scan_accepts_what_prost_accepts() { + // An unknown field prost would skip: tag 9, length delimited, holding a nested message the + // scanner has no descriptor for. It is charged once and walked over. + let payload = [0x4a, 0x04, 0x08, 0x01, 0x10, 0x02]; + + assert_eq!(ops(&payload, &BRANCH), 1); + assert!(Branch::decode(payload.as_slice()).is_ok()); + assert!(scan(&payload, &BRANCH, 1).is_ok()); + } + + #[test] + fn test_scan_rejects_malformed_payloads() { + // A truncated length delimiter, a tag of zero, and a varint with no terminator. + for payload in [ + [0x1a, 0x08, 0x61].as_slice(), + [0x00, 0x01].as_slice(), + [0x08, 0xff].as_slice(), + ] { + let error = scan(payload, &BRANCH, 1 << 20).unwrap_err(); + assert!(!error.is_limit_exceeded(), "{payload:?}"); + // Whatever the scanner rejects, prost rejects too. + assert!(Branch::decode(payload).is_err(), "{payload:?}"); + } + } + + #[test] + fn test_scan_bounds_its_own_recursion() { + // Deeper than the recursion limit, so the scanner must not run out of stack walking it. + let mut payload = Vec::new(); + for _ in 0..RECURSION_LIMIT + 10 { + let mut framed = vec![0x12, payload.len() as u8]; + framed.extend_from_slice(&payload); + payload = framed; + } + + let error = scan(&payload, &BRANCH, 1 << 20).unwrap_err(); + assert!(!error.is_limit_exceeded()); + assert!(Branch::decode(payload.as_slice()).is_err()); + } +} diff --git a/relay-serialization/src/serde/de.rs b/relay-serialization/src/serde/de.rs index ecf5dfed8bb..abd24f50e05 100644 --- a/relay-serialization/src/serde/de.rs +++ b/relay-serialization/src/serde/de.rs @@ -5,73 +5,19 @@ use serde::de::{ use std::fmt; use std::marker::PhantomData; +use crate::meter::Meter; + /// Costs associated with different kinds of operations; right now, just have one cost for /// all operations (but leave the door open for more.) mod cost { pub const UNIT: usize = 1; } -/// A budget for the ops a single deserialization is allowed to spend. -struct Meter { - #[cfg(test)] - limit: usize, - remaining: usize, - exceeded: bool, -} - impl Meter { - /// Creates a meter which allows spending at most `limit` operations. - pub fn new(limit: usize) -> Self { - Self { - #[cfg(test)] - limit, - remaining: limit, - exceeded: false, - } - } - /// Wraps `deserializer`, so that everything it produces is charged to this meter. - pub fn wrap<'de, D: Deserializer<'de>>( - &mut self, - deserializer: D, - ) -> MeteredDeserializer<'_, D> { + fn wrap<'de, D: Deserializer<'de>>(&mut self, deserializer: D) -> MeteredDeserializer<'_, D> { MeteredDeserializer::new(self, deserializer) } - - #[cfg(test)] - fn spent(&self) -> usize { - self.limit - self.remaining - } - - /// Returns true if we've exceeded our budget. - pub fn exceeded(&self) -> bool { - self.exceeded - } - - /// Tries to charge `amount` operations to the budget. If we exceed, we return an error, - /// set the remaining budget to 0, and mark the budget as exceeded. - pub fn spend(&mut self, amount: usize) -> Result<(), E> { - match self.remaining.checked_sub(amount) { - Some(remaining) => { - self.remaining = remaining; - Ok(()) - } - None => { - self.remaining = 0; - self.exceeded = true; - Err(serde_de::Error::custom(LimitExceeded {})) - } - } - } -} - -/// The error produced when a [`Meter`] runs out of budget. -struct LimitExceeded(); - -impl fmt::Display for LimitExceeded { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "deserialization exceeds the operation budget") - } } /// An error returned by [`deserialize`]. @@ -256,7 +202,7 @@ macro_rules! visit_scalar { ($($method:ident($ty:ty)),* $(,)?) => { $( fn $method(self, v: $ty) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.$method(v) } )* @@ -288,42 +234,42 @@ impl<'de, V: Visitor<'de>> Visitor<'de> for MeteredVisitor<'_, V> { } fn visit_str(self, v: &str) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.visit_str(v) } fn visit_borrowed_str(self, v: &'de str) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.visit_borrowed_str(v) } fn visit_string(self, v: String) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.visit_string(v) } fn visit_bytes(self, v: &[u8]) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.visit_bytes(v) } fn visit_borrowed_bytes(self, v: &'de [u8]) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.visit_borrowed_bytes(v) } fn visit_byte_buf(self, v: Vec) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.visit_byte_buf(v) } fn visit_none(self) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.visit_none() } fn visit_unit(self) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter.spend(cost::UNIT).map_err(E::custom)?; self.inner.visit_unit() } @@ -340,7 +286,9 @@ impl<'de, V: Visitor<'de>> Visitor<'de> for MeteredVisitor<'_, V> { } fn visit_seq>(self, seq: A) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter + .spend(cost::UNIT) + .map_err(serde_de::Error::custom)?; self.inner.visit_seq(MeteredSeqAccess { meter: self.meter, inner: seq, @@ -348,7 +296,9 @@ impl<'de, V: Visitor<'de>> Visitor<'de> for MeteredVisitor<'_, V> { } fn visit_map>(self, map: A) -> Result { - self.meter.spend(cost::UNIT)?; + self.meter + .spend(cost::UNIT) + .map_err(serde_de::Error::custom)?; self.inner.visit_map(MeteredMapAccess { meter: self.meter, @@ -465,7 +415,9 @@ impl<'de, A: VariantAccess<'de>> VariantAccess<'de> for MeteredVariantAccess<'_, type Error = A::Error; fn unit_variant(self) -> Result<(), Self::Error> { - self.meter.spend(cost::UNIT)?; + self.meter + .spend(cost::UNIT) + .map_err(serde_de::Error::custom)?; self.inner.unit_variant() } diff --git a/relay-server/Cargo.toml b/relay-server/Cargo.toml index 036ff8a2618..6083b42e555 100644 --- a/relay-server/Cargo.toml +++ b/relay-server/Cargo.toml @@ -91,6 +91,7 @@ relay-protocol = { workspace = true } relay-quotas = { workspace = true } relay-redis = { workspace = true } relay-replays = { workspace = true } +relay-serialization = { workspace = true } relay-conventions = { workspace = true } relay-sampling = { workspace = true } relay-spans = { workspace = true } diff --git a/tools/proto-descriptors/Cargo.toml b/tools/proto-descriptors/Cargo.toml new file mode 100644 index 00000000000..e8bcbd7cf93 --- /dev/null +++ b/tools/proto-descriptors/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "proto-descriptors" +version = "0.1.0" +authors = ["Sentry "] +description = "Generates bounded-decoding descriptors for protobuf schemas" +homepage = "https://getsentry.github.io/relay/" +repository = "https://github.com/getsentry/relay" +edition = "2024" +publish = false + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +prost = { workspace = true } +prost-types = { workspace = true } +convert_case = "0.11.0" + +[dev-dependencies] +relay-serialization = { workspace = true } diff --git a/tools/proto-descriptors/src/main.rs b/tools/proto-descriptors/src/main.rs new file mode 100644 index 00000000000..a3e1fd43baf --- /dev/null +++ b/tools/proto-descriptors/src/main.rs @@ -0,0 +1,425 @@ +//! Generates the `MessageDesc` tables which bound protobuf decoding. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use convert_case::{Case, Casing}; +use prost::Message; +use prost_types::field_descriptor_proto::Type; +use prost_types::{DescriptorProto, FileDescriptorSet}; + +#[derive(Debug, Parser)] +#[command(about = "Generates descriptor tables for bounded protobuf decoding")] +struct Cli { + /// Root directory of a set of protos to compile. + #[arg(long)] + proto_root: PathBuf, + + /// Where to write the generated Rust. + #[arg(long)] + out: PathBuf, + + // Rust 'use'-statements to include in the generated file; + #[arg(long)] + use_stmts: Option, + + /// Path to the specific proto file to process. + file: PathBuf, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let rendered = run(&cli.proto_root, &cli.out, &cli.use_stmts, &cli.file)?; + + fs::write(&cli.out, rendered).with_context(|| format!("cannot write {}", cli.out.display()))?; + format(&cli.out)?; + Ok(()) +} + +fn run( + proto_root: &Path, + out: &Path, + use_stmts: &Option, + file: &Path, +) -> Result { + let descriptor_set = compile(proto_root, file)?; + let descriptor_set = + FileDescriptorSet::decode(descriptor_set.as_slice()).context("malformed descriptor set")?; + let (root_messages, messages) = extract_roots_and_messages(file, &descriptor_set)?; + let reachable = walk(messages)?; + render_to_rust(proto_root, out, use_stmts, file, &reachable, &root_messages) +} + +/// Runs `rustfmt` over the generated file. +fn format(out: &Path) -> Result<()> { + let status = Command::new("rustfmt") + .args(["--edition", "2024"]) + .arg(out) + .status() + .context("cannot run rustfmt; is it installed and on PATH?")?; + + if !status.success() { + bail!("rustfmt failed with {status}"); + } + + Ok(()) +} + +/// Compiles the entry protos into a `FileDescriptorSet` using the installed `protoc`. +fn compile(proto_root: &Path, entry_proto: &Path) -> Result> { + // Generate a unique-enough name; this can be run concurrently in tests. + let out = std::env::temp_dir().join(format!( + "proto-descriptor-set-{}-{}.pb", + entry_proto + .file_stem() + .unwrap_or_default() + .to_string_lossy(), + std::process::id() + )); + + let status = Command::new("protoc") + .arg(format!("--descriptor_set_out={}", out.display())) + .arg("--include_imports") + .arg("-I") + .arg(proto_root) + .arg(entry_proto) + .status() + .context("cannot run protoc; is it installed and on PATH?")?; + + if !status.success() { + bail!("protoc failed with {status}"); + } + + fs::read(&out).with_context(|| format!("cannot read {}", out.display())) +} + +// Roots are the top-level messages in the specified file, but we'll still transitively pull +// in other messages that are nested. +fn extract_roots_and_messages<'a>( + root_file: &Path, + descriptor_set: &'a FileDescriptorSet, +) -> Result<(Vec, BTreeMap)> { + let mut messages = BTreeMap::new(); + let mut root_types = vec![]; + for file in &descriptor_set.file { + let prefix = match file.package() { + "" => String::new(), + package => format!(".{package}"), + }; + + for message in &file.message_type { + collect(&prefix, message, &mut messages)?; + + if file.name() == root_file { + root_types.push(message.name().to_owned()); + } + } + } + + Ok((root_types, messages)) +} + +// Adds `message` and everything transitively nested inside it to `messages`. +fn collect<'a>( + prefix: &str, + message: &'a DescriptorProto, + messages: &mut BTreeMap, +) -> Result<()> { + let full_name = format!("{prefix}.{}", message.name()); + + if messages.insert(full_name.clone(), message).is_some() { + bail!("{full_name} is defined twice in the descriptor set"); + } + + for nested in &message.nested_type { + collect(&full_name, nested, messages)?; + } + + Ok(()) +} + +struct ProcessedMessage<'a> { + // The fully qualified proto name + full_name: String, + // The short name of this message + ident: String, + // `(tag, fully qualified name)` for every field holding a nested message, ordered by tag. + nested: Vec<(u32, &'a str)>, +} + +fn walk<'a>(messages: BTreeMap) -> Result>> { + let mut processed = Vec::new(); + let mut queue: Vec = messages.keys().map(|k| k.to_owned()).collect(); + + while let Some(full_name) = queue.pop() { + let (full_name, message) = messages + .get_key_value(&full_name) + .map(|(name, message)| (name.as_str(), *message)) + .with_context(|| format!("{full_name} is not in the descriptor set"))?; + + let mut nested = Vec::new(); + for field in &message.field { + // Bail loudly on this for now; not supported in proto3. + if field.r#type() == Type::Group { + bail!( + "{full_name}.{} is a group, which this generator does not describe", + field.name() + ); + } + + if field.r#type() != Type::Message { + continue; + } + + let type_name = field.type_name(); + + let target = messages.get(type_name).with_context(|| { + format!( + "{full_name}.{} refers to {type_name}, which is not in the descriptor set - \ + was it built without --include_imports?", + field.name() + ) + })?; + + // Maps not supported at the moment. + if target.options.as_ref().is_some_and(|o| o.map_entry()) { + bail!( + "{full_name}.{} is a map, which this generator does not describe", + field.name() + ); + } + + let tag = u32::try_from(field.number()) + .with_context(|| format!("{full_name}.{} has a negative tag", field.name()))?; + + nested.push((tag, type_name)); + } + + nested.sort_unstable(); + + processed.push(ProcessedMessage { + full_name: full_name.trim_start_matches('.').to_owned(), + ident: ident(full_name), + nested, + }); + } + + // Sorting by name keeps regeneration diffs empty when nothing has actually changed. + processed.sort_unstable_by(|a, b| a.full_name.cmp(&b.full_name)); + + let mut idents = BTreeMap::new(); + for message in &processed { + if let Some(other) = idents.insert(message.ident.clone(), message.full_name.clone()) { + bail!( + "{} and {} both map to the static {}", + other, + message.full_name, + message.ident + ); + } + } + + Ok(processed) +} + +// Derives the name of a static from a fully qualified proto name. For example, +// `.opentelemetry.proto.trace.v1.Span.Event` becomes `SPAN_EVENT`. +fn ident(full_name: &str) -> String { + // Message names start with an uppercase letter and package segments do not, so the first + // uppercase segment is where the message path begins. + full_name + .split('.') + .skip_while(|segment| !segment.starts_with(|c: char| c.is_ascii_uppercase())) + .map(|s| s.to_case(Case::UpperSnake)) + .collect::>() + .join("_") +} + +fn render_to_rust( + proto_root: &Path, + out_path: &Path, + use_stmts: &Option, + file: &Path, + reachable: &[ProcessedMessage<'_>], + root_types: &Vec, +) -> Result { + let mut out = String::new(); + + writeln!( + out, + "// @generated by tools/proto-descriptors - DO NOT EDIT." + )?; + writeln!(out, "//")?; + writeln!(out, "// To regenerate, invoke:")?; + writeln!(out, "// cargo run -p proto-descriptors -- \\")?; + writeln!(out, "// --proto-root {} \\", proto_root.to_string_lossy())?; + + if let Some(use_stmts) = use_stmts { + writeln!(out, "// --use-stmts \"{}\" \\", use_stmts)?; + } + + writeln!(out, "// --out {} \\", out_path.to_string_lossy())?; + writeln!(out, "// {}", file.to_string_lossy())?; + writeln!(out)?; + writeln!(out, "use relay_serialization::prost::Error;")?; + writeln!(out, "use relay_serialization::prost::MessageDesc;")?; + writeln!(out, "use relay_serialization::prost::decode;")?; + + if let Some(stmts) = use_stmts { + writeln!(out, "{}", stmts)?; + } + + writeln!( + out, + "pub trait Decodable {{ fn decode_bounded(buf: &[u8], max_ops: usize) -> Result where + Self: Sized; }}" + )?; + + for message in reachable { + let nested = if !message.nested.is_empty() { + let mut nested = "".to_owned(); + for (tag, type_name) in &message.nested { + nested += &format!(" ({tag}, &{}),", ident(type_name)); + } + nested + } else { + "".to_owned() + }; + + writeln!(out)?; + writeln!(out, "/// `{}`", message.full_name)?; + writeln!( + out, + "pub static {}: MessageDesc = MessageDesc {{", + message.ident + )?; + writeln!(out, " name: \"{}\",", message.full_name)?; + writeln!(out, " nested: &[{}],", nested)?; + writeln!(out, "}};")?; + } + + for typ in root_types { + writeln!( + out, + " + impl Decodable for {} {{\ + fn decode_bounded(buf: &[u8], max_ops: usize) -> Result {{ decode(buf, &{}, max_ops) }}\ + }}", + typ, + typ.to_case(Case::UpperSnake) + )?; + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROTO_ROOT: &str = "tests/fixtures/protos"; + const TREE_PROTO: &str = "relay/test/v1/tree.proto"; + + fn compile_fixture(file: &str, proto_root: &str) -> (FileDescriptorSet, PathBuf) { + let file = PathBuf::from(file); + let bytes = compile(Path::new(proto_root), &file).expect("cannot compile fixture"); + let set = FileDescriptorSet::decode(bytes.as_slice()).expect("malformed descriptor set"); + + (set, file) + } + + fn descriptors(file: &str, proto_root: &str) -> Result<(Vec, Vec)> { + let (set, file) = compile_fixture(file, proto_root); + let (root_messages, messages) = extract_roots_and_messages(&file, &set)?; + let reachable = walk(messages)?; + + Ok(( + root_messages, + reachable.iter().map(|m| m.full_name.clone()).collect(), + )) + } + + #[test] + fn test_render_matches_fixture() { + let cli = Cli { + proto_root: PathBuf::from(PROTO_ROOT), + out: PathBuf::from("tests/fixtures/descriptors.rs"), + use_stmts: Some("use super::proto::relay::test::v1::*;".to_owned()), + file: PathBuf::from(TREE_PROTO), + }; + + let rendered = run(&cli.proto_root, &cli.out, &cli.use_stmts, &cli.file).unwrap(); + let out_path = + std::env::temp_dir().join(format!("proto-descriptors-{}.rs", std::process::id())); + fs::write(&out_path, rendered).expect("cannot write rendered output"); + format(&out_path).unwrap(); + let rendered = std::fs::read_to_string(&out_path).unwrap(); + + let fixture = fs::read_to_string(&cli.out).unwrap(); + + assert_eq!( + rendered, + fixture, + "regenerate with the command in the header of {}", + cli.out.display() + ); + } + + #[test] + fn test_walk_rejects_maps() { + let error = descriptors("relay/test/v1/maps.proto", PROTO_ROOT).unwrap_err(); + + assert_eq!( + error.to_string(), + ".relay.test.v1.WithMap.labels is a map, which this generator does not describe" + ); + } + + #[test] + fn test_walk_rejects_groups() { + let error = descriptors("relay/test/v1/groups.proto", PROTO_ROOT).unwrap_err(); + + assert_eq!( + error.to_string(), + ".relay.test.v1.WithGroup.inner is a group, which this generator does not describe" + ); + } + + #[test] + fn test_walk_rejects_colliding_idents() { + // `FooBar` and `Foo.Bar` both flatten to `FOO_BAR`, which would emit the static twice. + let error = descriptors("relay/test/v1/collision.proto", PROTO_ROOT).unwrap_err(); + + assert_eq!( + error.to_string(), + "relay.test.v1.Foo.Bar and relay.test.v1.FooBar both map to the static FOO_BAR" + ); + } + + #[test] + fn test_ident_drops_package_and_snake_cases() { + assert_eq!(ident(".opentelemetry.proto.logs.v1.LogsData"), "LOGS_DATA"); + assert_eq!( + ident(".opentelemetry.proto.common.v1.AnyValue"), + "ANY_VALUE" + ); + assert_eq!( + ident(".opentelemetry.proto.common.v1.KeyValueList"), + "KEY_VALUE_LIST" + ); + assert_eq!( + ident(".opentelemetry.proto.trace.v1.Span.Event"), + "SPAN_EVENT" + ); + assert_eq!( + ident(".opentelemetry.proto.resource.v1.Resource"), + "RESOURCE" + ); + } +} diff --git a/tools/proto-descriptors/tests/fixtures/descriptors.rs b/tools/proto-descriptors/tests/fixtures/descriptors.rs new file mode 100644 index 00000000000..4ef04c45fb1 --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/descriptors.rs @@ -0,0 +1,60 @@ +// @generated by tools/proto-descriptors - DO NOT EDIT. +// +// To regenerate, invoke: +// cargo run -p proto-descriptors -- \ +// --proto-root tests/fixtures/protos \ +// --use-stmts "use super::proto::relay::test::v1::*;" \ +// --out tests/fixtures/descriptors.rs \ +// relay/test/v1/tree.proto + +use super::proto::relay::test::v1::*; +use relay_serialization::prost::Error; +use relay_serialization::prost::MessageDesc; +use relay_serialization::prost::decode; +pub trait Decodable { + fn decode_bounded(buf: &[u8], max_ops: usize) -> Result + where + Self: Sized; +} + +/// `relay.test.common.v1.Metadata` +pub static METADATA: MessageDesc = MessageDesc { + name: "relay.test.common.v1.Metadata", + nested: &[(2, &TAG)], +}; + +/// `relay.test.common.v1.Tag` +pub static TAG: MessageDesc = MessageDesc { + name: "relay.test.common.v1.Tag", + nested: &[], +}; + +/// `relay.test.v1.Leaf` +pub static LEAF: MessageDesc = MessageDesc { + name: "relay.test.v1.Leaf", + nested: &[(9, &LEAF)], +}; + +/// `relay.test.v1.Tree` +pub static TREE: MessageDesc = MessageDesc { + name: "relay.test.v1.Tree", + nested: &[(1, &TREE_NODE), (2, &TREE_NODE), (3, &METADATA)], +}; + +/// `relay.test.v1.Tree.Node` +pub static TREE_NODE: MessageDesc = MessageDesc { + name: "relay.test.v1.Tree.Node", + nested: &[(2, &LEAF), (3, &TREE_NODE)], +}; + +impl Decodable for Tree { + fn decode_bounded(buf: &[u8], max_ops: usize) -> Result { + decode(buf, &TREE, max_ops) + } +} + +impl Decodable for Leaf { + fn decode_bounded(buf: &[u8], max_ops: usize) -> Result { + decode(buf, &LEAF, max_ops) + } +} diff --git a/tools/proto-descriptors/tests/fixtures/proto/mod.rs b/tools/proto-descriptors/tests/fixtures/proto/mod.rs new file mode 100644 index 00000000000..cc1e5f02d22 --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/proto/mod.rs @@ -0,0 +1,16 @@ +//Prost types for `tests/fixtures/protos/relay/test/v1/tree.proto`. + +#![allow(missing_docs)] +pub mod relay { + pub mod test { + pub mod common { + pub mod v1 { + include!("relay.test.common.v1.rs"); + } + } + + pub mod v1 { + include!("relay.test.v1.rs"); + } + } +} diff --git a/tools/proto-descriptors/tests/fixtures/proto/relay.test.common.v1.rs b/tools/proto-descriptors/tests/fixtures/proto/relay.test.common.v1.rs new file mode 100644 index 00000000000..97951bfc5a2 --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/proto/relay.test.common.v1.rs @@ -0,0 +1,15 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Metadata { + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "2")] + pub tags: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Tag { + #[prost(string, tag = "1")] + pub key: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub value: ::prost::alloc::string::String, +} diff --git a/tools/proto-descriptors/tests/fixtures/proto/relay.test.v1.rs b/tools/proto-descriptors/tests/fixtures/proto/relay.test.v1.rs new file mode 100644 index 00000000000..5057797c4fa --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/proto/relay.test.v1.rs @@ -0,0 +1,81 @@ +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Tree { + #[prost(message, optional, tag = "1")] + pub root: ::core::option::Option, + #[prost(message, repeated, tag = "2")] + pub forest: ::prost::alloc::vec::Vec, + /// Lives in an imported file, so it is only resolvable with `--include_imports`. + #[prost(message, optional, tag = "3")] + pub metadata: ::core::option::Option, +} +/// Nested message and enum types in `Tree`. +pub mod tree { + /// A type nested inside another message, so the generator has to flatten the path. + #[derive(Clone, PartialEq, ::prost::Message)] + pub struct Node { + #[prost(string, tag = "1")] + pub label: ::prost::alloc::string::String, + #[prost(message, repeated, tag = "2")] + pub leaves: ::prost::alloc::vec::Vec, + /// Recursion through a nested type. + #[prost(message, repeated, tag = "3")] + pub children: ::prost::alloc::vec::Vec, + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Leaf { + #[prost(string, tag = "1")] + pub text: ::prost::alloc::string::String, + #[prost(uint64, tag = "2")] + pub number: u64, + #[prost(double, tag = "3")] + pub real: f64, + #[prost(fixed32, tag = "4")] + pub fixed: u32, + #[prost(bytes = "vec", tag = "5")] + pub blob: ::prost::alloc::vec::Vec, + #[prost(uint64, repeated, tag = "6")] + pub numbers: ::prost::alloc::vec::Vec, + #[prost(enumeration = "Kind", tag = "7")] + pub kind: i32, + #[prost(oneof = "leaf::Value", tags = "8, 9")] + pub value: ::core::option::Option, +} +/// Nested message and enum types in `Leaf`. +pub mod leaf { + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Value { + #[prost(string, tag = "8")] + Name(::prost::alloc::string::String), + /// Recursion through a `oneof`. + #[prost(message, tag = "9")] + Leaf(::prost::alloc::boxed::Box), + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum Kind { + Unspecified = 0, + One = 1, +} +impl Kind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "KIND_UNSPECIFIED", + Self::One => "KIND_ONE", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "KIND_UNSPECIFIED" => Some(Self::Unspecified), + "KIND_ONE" => Some(Self::One), + _ => None, + } + } +} diff --git a/tools/proto-descriptors/tests/fixtures/protos/relay/test/common/v1/metadata.proto b/tools/proto-descriptors/tests/fixtures/protos/relay/test/common/v1/metadata.proto new file mode 100644 index 00000000000..b57f94fc4dd --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/protos/relay/test/common/v1/metadata.proto @@ -0,0 +1,15 @@ +// Imported by `tree.proto`; nothing here is reachable unless the descriptor set was built with +// `--include_imports`. +syntax = "proto3"; + +package relay.test.common.v1; + +message Metadata { + string name = 1; + repeated Tag tags = 2; +} + +message Tag { + string key = 1; + string value = 2; +} diff --git a/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/collision.proto b/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/collision.proto new file mode 100644 index 00000000000..6914ee5149f --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/collision.proto @@ -0,0 +1,17 @@ +// `FooBar` and `Foo.Bar` both flatten to the static `FOO_BAR`. Only used to assert that the +// generator reports the collision instead of emitting the same static twice. +syntax = "proto3"; + +package relay.test.v1; + +message FooBar { + string value = 1; +} + +message Foo { + message Bar { + string value = 1; + } + + Bar bar = 1; +} diff --git a/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/groups.proto b/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/groups.proto new file mode 100644 index 00000000000..f69cea278e4 --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/groups.proto @@ -0,0 +1,11 @@ +// Groups carry their own framing, which the generator refuses to describe. Only used to assert +// that refusal, hence proto2. +syntax = "proto2"; + +package relay.test.v1; + +message WithGroup { + optional group Inner = 1 { + optional string label = 1; + } +} diff --git a/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/maps.proto b/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/maps.proto new file mode 100644 index 00000000000..3fe2d74da7f --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/maps.proto @@ -0,0 +1,9 @@ +// A map field is sugar for a repeated synthesized entry message, which the generator refuses to +// describe rather than under-count. Only used to assert that refusal. +syntax = "proto3"; + +package relay.test.v1; + +message WithMap { + map labels = 1; +} diff --git a/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/tree.proto b/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/tree.proto new file mode 100644 index 00000000000..efded00f5d6 --- /dev/null +++ b/tools/proto-descriptors/tests/fixtures/protos/relay/test/v1/tree.proto @@ -0,0 +1,44 @@ +// A schema exercising every shape the descriptor generator has to describe: messages nested +// inside messages, repeated message fields, recursion (both direct and through a `oneof`), +// packed scalars, opaque `bytes`, enums, and a message pulled in from another file. +syntax = "proto3"; + +package relay.test.v1; + +import "relay/test/common/v1/metadata.proto"; + +message Tree { + // A type nested inside another message, so the generator has to flatten the path. + message Node { + string label = 1; + repeated Leaf leaves = 2; + // Recursion through a nested type. + repeated Node children = 3; + } + + Node root = 1; + repeated Node forest = 2; + // Lives in an imported file, so it is only resolvable with `--include_imports`. + relay.test.common.v1.Metadata metadata = 3; +} + +message Leaf { + string text = 1; + uint64 number = 2; + double real = 3; + fixed32 fixed = 4; + bytes blob = 5; + repeated uint64 numbers = 6; + Kind kind = 7; + + oneof value { + string name = 8; + // Recursion through a `oneof`. + Leaf leaf = 9; + } +} + +enum Kind { + KIND_UNSPECIFIED = 0; + KIND_ONE = 1; +}