Skip to main content

trillium_grpc/
metadata.rs

1//! Custom request, response, and trailing metadata.
2//!
3//! gRPC distinguishes "ASCII metadata" (printable-ASCII values) from "binary
4//! metadata" (key ends in `-bin`, value base64-encoded on the wire). Reserved
5//! keys (`grpc-*`, `te`, `content-type`, `user-agent`, HTTP/2 pseudo-headers)
6//! are owned by the framework and rejected on insert.
7//!
8//! [`Metadata`] is an ordered map that round-trips through
9//! `trillium::Headers`. It backs [`Status::metadata`](crate::Status::metadata),
10//! the trailing metadata sent alongside an error status.
11
12// gRPC `-bin` metadata values are base64 *without* padding; padded values are
13// rejected by spec-conformant peers (e.g. the connectrpc conformance runner).
14use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD as BASE64};
15use trillium::Headers;
16
17/// An ordered, multi-valued map of custom gRPC metadata. Keys may repeat;
18/// insertion order is preserved through the round-trip to and from
19/// `trillium::Headers`.
20#[derive(Debug, Clone, Default)]
21pub struct Metadata {
22    entries: Vec<(String, MetadataValue)>,
23}
24
25/// A single metadata value: either printable ASCII text or, for `-bin` keys,
26/// raw bytes (base64-encoded on the wire).
27#[derive(Debug, Clone)]
28pub enum MetadataValue {
29    /// A printable-ASCII text value.
30    Ascii(String),
31    /// A raw byte value, carried base64-encoded under a `-bin` key.
32    Binary(Vec<u8>),
33}
34
35/// Why an insert into [`Metadata`] was rejected.
36#[derive(Debug, thiserror::Error)]
37pub enum MetadataError {
38    /// The key contained characters outside `[0-9a-z_\-.]`.
39    #[error("metadata key {0:?} contains invalid characters (must match [0-9a-z_\\-.]+)")]
40    InvalidKey(String),
41    /// The key is one the gRPC framework or HTTP transport owns.
42    #[error("metadata key {0:?} is reserved by the gRPC framework")]
43    ReservedKey(String),
44    /// An ASCII insert was given a `-bin` key, which is reserved for binary
45    /// values.
46    #[error("ASCII metadata key {0:?} must not end in -bin")]
47    AsciiKeyHasBinSuffix(String),
48    /// A binary insert was given a key that doesn't end in `-bin`.
49    #[error("binary metadata key {0:?} must end in -bin")]
50    BinaryKeyMissingBinSuffix(String),
51    /// An ASCII value contained bytes outside the printable range 0x20–0x7E.
52    #[error("ASCII metadata value contains non-printable bytes")]
53    InvalidAsciiValue,
54}
55
56impl MetadataValue {
57    /// The text, if this is an [`Ascii`](Self::Ascii) value; `None` for binary.
58    pub fn as_ascii(&self) -> Option<&str> {
59        match self {
60            Self::Ascii(s) => Some(s),
61            Self::Binary(_) => None,
62        }
63    }
64
65    /// The bytes, if this is a [`Binary`](Self::Binary) value; `None` for ASCII.
66    pub fn as_binary(&self) -> Option<&[u8]> {
67        match self {
68            Self::Binary(b) => Some(b),
69            Self::Ascii(_) => None,
70        }
71    }
72}
73
74impl Metadata {
75    /// An empty metadata map.
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Whether there are zero entries.
81    pub fn is_empty(&self) -> bool {
82        self.entries.is_empty()
83    }
84
85    /// The number of entries, counting repeated keys separately.
86    pub fn len(&self) -> usize {
87        self.entries.len()
88    }
89
90    /// Insert an ASCII metadata entry. Key must be lowercase
91    /// `[0-9a-z_\-.]+`, must not be reserved, and must not end in `-bin`.
92    /// Value must be printable ASCII (0x20–0x7E).
93    pub fn insert_ascii(
94        &mut self,
95        key: &str,
96        value: impl Into<String>,
97    ) -> Result<(), MetadataError> {
98        validate_key(key)?;
99        if is_reserved(key) {
100            return Err(MetadataError::ReservedKey(key.to_owned()));
101        }
102        if key.ends_with("-bin") {
103            return Err(MetadataError::AsciiKeyHasBinSuffix(key.to_owned()));
104        }
105        let value = value.into();
106        if !is_valid_ascii_value(&value) {
107            return Err(MetadataError::InvalidAsciiValue);
108        }
109        self.entries
110            .push((key.to_owned(), MetadataValue::Ascii(value)));
111        Ok(())
112    }
113
114    /// Insert a binary metadata entry. Key must end in `-bin` and otherwise
115    /// follow the same rules as ASCII keys. Value bytes are base64-encoded
116    /// at write time.
117    pub fn insert_binary(
118        &mut self,
119        key: &str,
120        value: impl Into<Vec<u8>>,
121    ) -> Result<(), MetadataError> {
122        validate_key(key)?;
123        if is_reserved(key) {
124            return Err(MetadataError::ReservedKey(key.to_owned()));
125        }
126        if !key.ends_with("-bin") {
127            return Err(MetadataError::BinaryKeyMissingBinSuffix(key.to_owned()));
128        }
129        self.entries
130            .push((key.to_owned(), MetadataValue::Binary(value.into())));
131        Ok(())
132    }
133
134    /// Return the first ASCII value for `key`, if any.
135    pub fn get_ascii(&self, key: &str) -> Option<&str> {
136        self.entries.iter().find_map(|(k, v)| match v {
137            MetadataValue::Ascii(s) if k == key => Some(s.as_str()),
138            _ => None,
139        })
140    }
141
142    /// Return the first binary value for `key`, if any.
143    pub fn get_binary(&self, key: &str) -> Option<&[u8]> {
144        self.entries.iter().find_map(|(k, v)| match v {
145            MetadataValue::Binary(b) if k == key => Some(b.as_slice()),
146            _ => None,
147        })
148    }
149
150    /// Iterate over `(key, value)` pairs in insertion order.
151    pub fn iter(&self) -> impl Iterator<Item = (&str, &MetadataValue)> {
152        self.entries.iter().map(|(k, v)| (k.as_str(), v))
153    }
154
155    /// Pluck non-reserved entries out of `headers`. Binary `-bin` entries
156    /// are base64-decoded; entries that fail to decode or whose ASCII values
157    /// are not valid UTF-8 are skipped silently (the spec is lenient here —
158    /// we'd rather drop unparseable user metadata than fail the whole RPC).
159    ///
160    /// Header names are normalized to lowercase. trillium's `KnownHeaderName`
161    /// table presents canonical-case names (`Content-Type`, `Retry-After`,
162    /// …) even though the HTTP/2 wire is lowercase-only, so we lowercase
163    /// before matching against the reserved set and storing.
164    pub fn from_headers(headers: &Headers) -> Self {
165        let mut out = Self::new();
166        for (name, values) in headers.iter() {
167            let key = name.as_ref().to_ascii_lowercase();
168            if is_reserved(&key) || !is_valid_key(&key) {
169                continue;
170            }
171            let is_bin = key.ends_with("-bin");
172            for value in values.iter() {
173                if is_bin {
174                    if let Ok(decoded) = BASE64.decode(value.as_ref()) {
175                        out.entries
176                            .push((key.clone(), MetadataValue::Binary(decoded)));
177                    }
178                } else if let Some(s) = value.as_str() {
179                    out.entries
180                        .push((key.clone(), MetadataValue::Ascii(s.to_owned())));
181                }
182            }
183        }
184        out
185    }
186
187    /// Append every entry to `headers`. Binary values are base64-encoded.
188    /// Multiple values for the same key produce multiple appended entries,
189    /// preserving wire order.
190    pub fn write_into(&self, headers: &mut Headers) {
191        for (key, value) in &self.entries {
192            match value {
193                MetadataValue::Ascii(v) => {
194                    headers.append(key.clone(), v.clone());
195                }
196                MetadataValue::Binary(b) => {
197                    headers.append(key.clone(), BASE64.encode(b));
198                }
199            }
200        }
201    }
202}
203
204fn validate_key(key: &str) -> Result<(), MetadataError> {
205    if is_valid_key(key) {
206        Ok(())
207    } else {
208        Err(MetadataError::InvalidKey(key.to_owned()))
209    }
210}
211
212fn is_valid_key(key: &str) -> bool {
213    !key.is_empty()
214        && key
215            .bytes()
216            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'_' | b'-' | b'.'))
217}
218
219fn is_valid_ascii_value(value: &str) -> bool {
220    value.bytes().all(|b| (0x20..=0x7E).contains(&b))
221}
222
223/// Keys owned by the gRPC framework or HTTP transport. The `grpc-` prefix
224/// catches the documented family (`grpc-status`, `grpc-message`,
225/// `grpc-status-details-bin`, `grpc-timeout`, `grpc-encoding`,
226/// `grpc-accept-encoding`) plus any future additions.
227fn is_reserved(key: &str) -> bool {
228    key.starts_with("grpc-")
229        || matches!(
230            key,
231            "te" | "content-type" | "user-agent" | "host" | "connection"
232        )
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn insert_and_get_ascii() {
241        let mut m = Metadata::new();
242        m.insert_ascii("trace-id", "abc123").unwrap();
243        assert_eq!(m.get_ascii("trace-id"), Some("abc123"));
244        assert_eq!(m.get_binary("trace-id"), None);
245    }
246
247    #[test]
248    fn insert_and_get_binary() {
249        let mut m = Metadata::new();
250        m.insert_binary("token-bin", vec![1, 2, 3, 0xFF]).unwrap();
251        assert_eq!(m.get_binary("token-bin"), Some(&[1, 2, 3, 0xFF][..]));
252        assert_eq!(m.get_ascii("token-bin"), None);
253    }
254
255    #[test]
256    fn rejects_uppercase_key() {
257        let mut m = Metadata::new();
258        let err = m.insert_ascii("Trace-Id", "x").unwrap_err();
259        assert!(matches!(err, MetadataError::InvalidKey(_)));
260    }
261
262    #[test]
263    fn rejects_invalid_key_chars() {
264        let mut m = Metadata::new();
265        assert!(matches!(
266            m.insert_ascii("trace id", "x"),
267            Err(MetadataError::InvalidKey(_))
268        ));
269        assert!(matches!(
270            m.insert_ascii("trace/id", "x"),
271            Err(MetadataError::InvalidKey(_))
272        ));
273        assert!(matches!(
274            m.insert_ascii("", "x"),
275            Err(MetadataError::InvalidKey(_))
276        ));
277    }
278
279    #[test]
280    fn rejects_reserved_keys() {
281        let mut m = Metadata::new();
282        assert!(matches!(
283            m.insert_ascii("grpc-status", "0"),
284            Err(MetadataError::ReservedKey(_))
285        ));
286        assert!(matches!(
287            m.insert_ascii("content-type", "x"),
288            Err(MetadataError::ReservedKey(_))
289        ));
290        assert!(matches!(
291            m.insert_binary("grpc-status-details-bin", vec![0]),
292            Err(MetadataError::ReservedKey(_))
293        ));
294    }
295
296    #[test]
297    fn ascii_key_cannot_end_in_bin() {
298        let mut m = Metadata::new();
299        let err = m.insert_ascii("token-bin", "x").unwrap_err();
300        assert!(matches!(err, MetadataError::AsciiKeyHasBinSuffix(_)));
301    }
302
303    #[test]
304    fn binary_key_must_end_in_bin() {
305        let mut m = Metadata::new();
306        let err = m.insert_binary("token", vec![1, 2, 3]).unwrap_err();
307        assert!(matches!(err, MetadataError::BinaryKeyMissingBinSuffix(_)));
308    }
309
310    #[test]
311    fn rejects_non_printable_ascii_value() {
312        let mut m = Metadata::new();
313        assert!(matches!(
314            m.insert_ascii("trace-id", "line1\nline2"),
315            Err(MetadataError::InvalidAsciiValue)
316        ));
317        assert!(matches!(
318            m.insert_ascii("trace-id", "café"),
319            Err(MetadataError::InvalidAsciiValue)
320        ));
321    }
322
323    #[test]
324    fn round_trip_through_headers() {
325        let mut m = Metadata::new();
326        m.insert_ascii("trace-id", "abc").unwrap();
327        m.insert_ascii("trace-id", "def").unwrap();
328        m.insert_binary("token-bin", vec![0, 1, 2, 0xFF]).unwrap();
329
330        let mut headers = Headers::new();
331        m.write_into(&mut headers);
332
333        let parsed = Metadata::from_headers(&headers);
334        let entries: Vec<_> = parsed.iter().map(|(k, v)| (k, v.clone())).collect();
335
336        let trace_ids: Vec<_> = entries
337            .iter()
338            .filter(|(k, _)| *k == "trace-id")
339            .filter_map(|(_, v)| v.as_ascii())
340            .collect();
341        assert_eq!(trace_ids, vec!["abc", "def"]);
342
343        let token = entries
344            .iter()
345            .find(|(k, _)| *k == "token-bin")
346            .and_then(|(_, v)| v.as_binary())
347            .unwrap();
348        assert_eq!(token, &[0, 1, 2, 0xFF]);
349    }
350
351    #[test]
352    fn from_headers_skips_reserved() {
353        let mut headers = Headers::new();
354        headers.append("grpc-status", "0");
355        headers.append("content-type", "application/grpc");
356        headers.append("trace-id", "abc");
357        let m = Metadata::from_headers(&headers);
358        assert_eq!(m.len(), 1);
359        assert_eq!(m.get_ascii("trace-id"), Some("abc"));
360    }
361
362    #[test]
363    fn from_headers_skips_undecodable_bin() {
364        let mut headers = Headers::new();
365        headers.append("token-bin", "not!valid!base64!");
366        let m = Metadata::from_headers(&headers);
367        assert!(m.is_empty());
368    }
369}