Skip to main content

grpc_webnext/
metadata.rs

1//! HTTP header <-> gRPC metadata conversion, protobuf metadata <-> gRPC
2//! metadata conversion, and grpc-timeout parsing. Shared by the proxy and the
3//! native server library.
4
5use std::time::Duration;
6
7use crate::pb::{metadatum, Metadatum};
8use http::{HeaderMap, HeaderName, HeaderValue};
9use tonic::metadata::{
10    AsciiMetadataKey, BinaryMetadataKey, KeyAndValueRef, MetadataMap, MetadataValue,
11};
12
13/// Headers that must not be forwarded as gRPC metadata (hop-by-hop, framing, or
14/// set by the gRPC stack itself).
15const DENY: &[&str] = &[
16    "host",
17    "connection",
18    "content-length",
19    "content-type",
20    "keep-alive",
21    "proxy-connection",
22    "transfer-encoding",
23    "te",
24    "upgrade",
25    "grpc-timeout",
26    "grpc-status",
27    "grpc-message",
28    "grpc-status-details-bin",
29    "grpc-encoding",
30    "grpc-accept-encoding",
31];
32
33pub fn is_denied(name: &HeaderName) -> bool {
34    DENY.contains(&name.as_str())
35}
36
37/// Copy request headers into a gRPC metadata map, minus the denylist.
38pub fn request_headers_to_metadata(headers: &HeaderMap) -> MetadataMap {
39    let mut filtered = HeaderMap::new();
40    for (name, value) in headers.iter() {
41        if !is_denied(name) {
42            filtered.append(name.clone(), value.clone());
43        }
44    }
45    MetadataMap::from_headers(filtered)
46}
47
48/// Merge gRPC metadata into HTTP headers, skipping the denylist.
49pub fn merge_metadata_into_headers(meta: &MetadataMap, headers: &mut HeaderMap) {
50    for (name, value) in meta.clone().into_headers().iter() {
51        if is_denied(name) {
52            continue;
53        }
54        headers.append(name.clone(), value.clone());
55    }
56}
57
58/// Convert a protobuf metadata list (from a frame) into a gRPC metadata map.
59///
60/// A `-bin` entry carries **raw bytes** in the frame but is **base64** on the HTTP wire, so
61/// it must go through tonic's binary metadata API rather than straight into a `HeaderValue`
62/// — raw bytes are not a legal header value at all (a control byte would simply be dropped).
63pub fn metadata_vec_to_metadata(items: &[Metadatum]) -> MetadataMap {
64    let mut md = MetadataMap::new();
65    for m in items {
66        match HeaderName::from_bytes(m.key.as_bytes()) {
67            Ok(n) if !is_denied(&n) => {}
68            _ => continue,
69        }
70        match &m.value {
71            Some(metadatum::Value::AsciiValue(s)) => {
72                // An ASCII value under a `-bin` key has no valid encoding; the key parse
73                // rejects it, which is the behavior we want.
74                if let (Ok(k), Ok(v)) = (
75                    m.key.parse::<AsciiMetadataKey>(),
76                    MetadataValue::try_from(s.as_str()),
77                ) {
78                    md.append(k, v);
79                }
80            }
81            Some(metadatum::Value::BinValue(b)) => {
82                if let Ok(k) = m.key.parse::<BinaryMetadataKey>() {
83                    md.append_bin(k, MetadataValue::from_bytes(b));
84                }
85            }
86            None => {}
87        }
88    }
89    md
90}
91
92/// Convert a gRPC metadata map into a protobuf metadata list for a frame — the inverse of
93/// [`metadata_vec_to_metadata`], so a `-bin` value is base64-**decoded** back to raw bytes.
94pub fn metadata_to_vec(meta: &MetadataMap) -> Vec<Metadatum> {
95    let mut out = Vec::new();
96    for kv in meta.iter() {
97        match kv {
98            KeyAndValueRef::Ascii(name, value) => {
99                let key = name.as_str().to_string();
100                if is_denied_str(&key) {
101                    continue;
102                }
103                if let Ok(s) = value.to_str() {
104                    out.push(Metadatum {
105                        key,
106                        value: Some(metadatum::Value::AsciiValue(s.to_string())),
107                    });
108                }
109            }
110            KeyAndValueRef::Binary(name, value) => {
111                let key = name.as_str().to_string();
112                if is_denied_str(&key) {
113                    continue;
114                }
115                if let Ok(bytes) = value.to_bytes() {
116                    out.push(Metadatum {
117                        key,
118                        value: Some(metadatum::Value::BinValue(bytes)),
119                    });
120                }
121            }
122        }
123    }
124    out
125}
126
127fn is_denied_str(key: &str) -> bool {
128    DENY.contains(&key)
129}
130
131/// Parse a gRPC `grpc-timeout` header (e.g. `100m`, `5S`) into a Duration.
132pub fn parse_grpc_timeout(headers: &HeaderMap) -> Option<Duration> {
133    let raw = headers.get("grpc-timeout")?.to_str().ok()?;
134    let (value, unit) = raw.split_at(raw.len().checked_sub(1)?);
135    let n: u64 = value.parse().ok()?;
136    let d = match unit {
137        "H" => Duration::from_secs(n.checked_mul(3600)?),
138        "M" => Duration::from_secs(n.checked_mul(60)?),
139        "S" => Duration::from_secs(n),
140        "m" => Duration::from_millis(n),
141        "u" => Duration::from_micros(n),
142        "n" => Duration::from_nanos(n),
143        _ => return None,
144    };
145    Some(d)
146}
147
148/// Build a `grpc-timeout` header value from a Duration (millisecond unit).
149pub fn format_grpc_timeout(d: Duration) -> HeaderValue {
150    let millis = d.as_millis().min(u128::from(u64::MAX));
151    HeaderValue::from_str(&format!("{millis}m")).expect("valid header value")
152}
153
154/// Convert a `Subscribe.timeout_millis` field (0 = no deadline) to a Duration.
155pub fn grpc_timeout_from_millis(timeout_millis: u32) -> Option<Duration> {
156    match timeout_millis {
157        0 => None,
158        m => Some(Duration::from_millis(u64::from(m))),
159    }
160}
161
162/// Read the gRPC status `(code, message)` from a response's trailers, falling back to
163/// its headers (a "trailers-only" response carries the status in the headers). The
164/// `grpc-message` value is percent-decoded.
165pub fn read_status(trailers: &HeaderMap, headers: &HeaderMap) -> (u32, String) {
166    let get = |name: &str| trailers.get(name).or_else(|| headers.get(name));
167    let code = get("grpc-status")
168        .and_then(|v| v.to_str().ok())
169        .and_then(|s| s.parse::<u32>().ok())
170        .unwrap_or(0);
171    let message = get("grpc-message")
172        .and_then(|v| v.to_str().ok())
173        .map(percent_decode)
174        .unwrap_or_default();
175    (code, message)
176}
177
178/// Minimal percent-encoding for a `grpc-message` header value.
179pub fn percent_encode(s: &str) -> String {
180    let mut out = String::with_capacity(s.len());
181    for b in s.bytes() {
182        if b.is_ascii_alphanumeric() || matches!(b, b' ' | b'-' | b'_' | b'.' | b'/' | b':') {
183            out.push(b as char);
184        } else {
185            out.push_str(&format!("%{b:02X}"));
186        }
187    }
188    out
189}
190
191/// Minimal gRPC `grpc-message` percent-decoding (`%XX`).
192pub fn percent_decode(s: &str) -> String {
193    let bytes = s.as_bytes();
194    let mut out = Vec::with_capacity(bytes.len());
195    let mut i = 0;
196    while i < bytes.len() {
197        if bytes[i] == b'%' && i + 2 < bytes.len() {
198            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
199                out.push(byte);
200                i += 3;
201                continue;
202            }
203        }
204        out.push(bytes[i]);
205        i += 1;
206    }
207    String::from_utf8_lossy(&out).into_owned()
208}