Skip to main content

grpc_webnext_client/
metadata.rs

1//! Call metadata: ASCII values, and `-bin` keys carrying raw bytes.
2//!
3//! On this path metadata is HTTP/2 headers and the gRPC spec applies verbatim — so
4//! a `-bin` key is **base64** on the wire, unlike the custom `Frame` protocol where
5//! `bin_value` holds the raw bytes. That seam has produced a real bug in this repo
6//! before (see `/doc/GO_SERVER.md`), so the encode/decode lives here and nowhere else.
7
8use std::collections::HashMap;
9
10use base64::Engine as _;
11
12/// Metadata for one call. Keys are lowercase, as HTTP/2 requires.
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct Metadata {
15    entries: Vec<(String, MetadataValue)>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum MetadataValue {
20    Ascii(String),
21    Binary(Vec<u8>),
22}
23
24impl Metadata {
25    pub fn new() -> Metadata {
26        Metadata::default()
27    }
28
29    pub fn is_empty(&self) -> bool {
30        self.entries.is_empty()
31    }
32
33    pub fn len(&self) -> usize {
34        self.entries.len()
35    }
36
37    /// Set an ASCII value, replacing any existing entries for the key.
38    pub fn insert(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
39        self.remove(key);
40        self.entries.push((key.to_ascii_lowercase(), MetadataValue::Ascii(value.into())));
41        self
42    }
43
44    /// Set a binary value. The key must end in `-bin`, which is the gRPC signal
45    /// that the value is base64 on the wire.
46    pub fn insert_bin(&mut self, key: &str, value: impl Into<Vec<u8>>) -> &mut Self {
47        debug_assert!(key.ends_with("-bin"), "binary metadata keys must end in -bin");
48        self.remove(key);
49        self.entries.push((key.to_ascii_lowercase(), MetadataValue::Binary(value.into())));
50        self
51    }
52
53    /// Add a value without removing existing ones — gRPC metadata is multi-valued.
54    pub fn append(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
55        self.entries.push((key.to_ascii_lowercase(), MetadataValue::Ascii(value.into())));
56        self
57    }
58
59    pub fn remove(&mut self, key: &str) {
60        let key = key.to_ascii_lowercase();
61        self.entries.retain(|(k, _)| *k != key);
62    }
63
64    /// The first ASCII value for `key`.
65    pub fn get(&self, key: &str) -> Option<&str> {
66        let key = key.to_ascii_lowercase();
67        self.entries.iter().find_map(|(k, v)| match v {
68            MetadataValue::Ascii(s) if *k == key => Some(s.as_str()),
69            _ => None,
70        })
71    }
72
73    /// The first binary value for `key`.
74    pub fn get_bin(&self, key: &str) -> Option<&[u8]> {
75        let key = key.to_ascii_lowercase();
76        self.entries.iter().find_map(|(k, v)| match v {
77            MetadataValue::Binary(b) if *k == key => Some(b.as_slice()),
78            _ => None,
79        })
80    }
81
82    pub fn iter(&self) -> impl Iterator<Item = (&str, &MetadataValue)> {
83        self.entries.iter().map(|(k, v)| (k.as_str(), v))
84    }
85
86    /// Render to wire headers, base64-encoding every `-bin` value.
87    pub fn to_headers(&self) -> Vec<(String, String)> {
88        self.entries
89            .iter()
90            .map(|(k, v)| {
91                let value = match v {
92                    MetadataValue::Ascii(s) => s.clone(),
93                    MetadataValue::Binary(b) => base64::engine::general_purpose::STANDARD.encode(b),
94                };
95                (k.clone(), value)
96            })
97            .collect()
98    }
99
100    /// Read wire headers back, base64-decoding `-bin` values. Framing headers are
101    /// dropped: they describe the transport, not the call.
102    pub fn from_headers(headers: &HashMap<String, String>) -> Metadata {
103        let mut md = Metadata::new();
104        for (key, value) in headers {
105            let key = key.to_ascii_lowercase();
106            if key.starts_with(':') || is_framing_header(&key) {
107                continue;
108            }
109            if key.ends_with("-bin") {
110                // gRPC permits unpadded base64; a value we cannot decode is kept as
111                // ASCII rather than dropped, so nothing silently disappears.
112                match base64::engine::general_purpose::STANDARD
113                    .decode(value)
114                    .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(value))
115                {
116                    Ok(bytes) => md.entries.push((key, MetadataValue::Binary(bytes))),
117                    Err(_) => md.entries.push((key, MetadataValue::Ascii(value.clone()))),
118                }
119            } else {
120                md.entries.push((key, MetadataValue::Ascii(value.clone())));
121            }
122        }
123        md.entries.sort_by(|a, b| a.0.cmp(&b.0)); // HashMap order is not stable
124        md
125    }
126}
127
128/// Headers that belong to the transport rather than the call. Mirrors the server's
129/// own denylist so what a client sees matches what a server echoes.
130fn is_framing_header(key: &str) -> bool {
131    matches!(
132        key,
133        "content-type"
134            | "content-length"
135            | "te"
136            | "grpc-encoding"
137            | "grpc-accept-encoding"
138            | "grpc-timeout"
139            | "user-agent"
140            | "date"
141            | "trailer"
142    )
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn bin_values_are_base64_on_the_wire() {
151        let mut md = Metadata::new();
152        md.insert_bin("x-trace-bin", vec![0, 1, 2, 255]);
153        assert_eq!(md.to_headers(), vec![("x-trace-bin".to_string(), "AAEC/w==".to_string())]);
154    }
155
156    #[test]
157    fn bin_values_round_trip() {
158        let mut md = Metadata::new();
159        md.insert_bin("x-trace-bin", vec![0, 1, 2, 255]);
160        md.insert("x-ascii", "plain");
161        let wire: HashMap<String, String> = md.to_headers().into_iter().collect();
162        let back = Metadata::from_headers(&wire);
163        assert_eq!(back.get_bin("x-trace-bin"), Some(&[0u8, 1, 2, 255][..]));
164        assert_eq!(back.get("x-ascii"), Some("plain"));
165    }
166
167    #[test]
168    fn unpadded_base64_decodes_too() {
169        // The gRPC spec explicitly allows senders to omit padding.
170        let wire: HashMap<String, String> =
171            [("x-t-bin".to_string(), "AAEC".to_string())].into_iter().collect();
172        assert_eq!(Metadata::from_headers(&wire).get_bin("x-t-bin"), Some(&[0u8, 1, 2][..]));
173    }
174
175    #[test]
176    fn undecodable_bin_is_kept_as_ascii_not_dropped() {
177        let wire: HashMap<String, String> =
178            [("x-t-bin".to_string(), "!!!not base64!!!".to_string())].into_iter().collect();
179        let md = Metadata::from_headers(&wire);
180        assert_eq!(md.get("x-t-bin"), Some("!!!not base64!!!"));
181    }
182
183    #[test]
184    fn framing_and_pseudo_headers_are_not_call_metadata() {
185        let wire: HashMap<String, String> = [
186            (":status".to_string(), "200".to_string()),
187            ("content-type".to_string(), "application/grpc".to_string()),
188            ("x-real".to_string(), "yes".to_string()),
189        ]
190        .into_iter()
191        .collect();
192        let md = Metadata::from_headers(&wire);
193        assert_eq!(md.len(), 1);
194        assert_eq!(md.get("x-real"), Some("yes"));
195    }
196
197    #[test]
198    fn keys_are_case_insensitive() {
199        let mut md = Metadata::new();
200        md.insert("X-Mixed-Case", "v");
201        assert_eq!(md.get("x-mixed-case"), Some("v"));
202        md.remove("X-MIXED-CASE");
203        assert!(md.is_empty());
204    }
205}