Skip to main content

zerodds_grpc_bridge/
metadata.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Custom-Metadata Encoding nach gRPC-Spec.
5//!
6//! Spec: `protocol-http2.md` + `protocol-web.md` —
7//! "Custom-Metadata is an arbitrary set of key-value pairs defined
8//! by the application layer. [...] Header names that end with `-bin`
9//! are interpreted as binary and base64-encoded values, otherwise
10//! header values are ASCII."
11//!
12//! Plus gRPC-Web-Spezifika fuer `application/grpc-web-text`:
13//! der gesamte LPM-Body wird base64-encoded.
14
15use alloc::string::String;
16use alloc::vec::Vec;
17
18/// Suffix fuer Binary-Headers nach gRPC-Spec.
19pub const BIN_SUFFIX: &str = "-bin";
20
21/// Standard Content-Type Header-Werte.
22pub mod content_types {
23    /// Mandatory fuer gRPC-over-HTTP/2.
24    pub const GRPC: &str = "application/grpc";
25    /// gRPC-over-HTTP/2 mit Sub-Format.
26    pub const GRPC_PROTO: &str = "application/grpc+proto";
27    /// gRPC-Web Binary-Format (Browser-kompatibel).
28    pub const GRPC_WEB: &str = "application/grpc-web";
29    /// gRPC-Web Base64-Text-Format (Browser-kompatibel ohne
30    /// HTTP-Trailers).
31    pub const GRPC_WEB_TEXT: &str = "application/grpc-web-text";
32}
33
34/// Standard Required Request-Headers (HTTP/2-Pseudoheaders + gRPC).
35pub mod request_headers {
36    /// HTTP/2-Pseudoheader `:method` — gRPC verlangt POST.
37    pub const METHOD: &str = ":method";
38    /// HTTP/2-Pseudoheader `:scheme` — http oder https.
39    pub const SCHEME: &str = ":scheme";
40    /// HTTP/2-Pseudoheader `:path` — `/<service>/<method>`.
41    pub const PATH: &str = ":path";
42    /// HTTP/2-Pseudoheader `:authority` — Server-Hostname.
43    pub const AUTHORITY: &str = ":authority";
44    /// `te: trailers` — gRPC verlangt Trailer-Support-Signal.
45    pub const TE: &str = "te";
46    /// `content-type` — siehe [`super::content_types`].
47    pub const CONTENT_TYPE: &str = "content-type";
48    /// `grpc-encoding` — Compression (e.g. "identity", "gzip").
49    pub const GRPC_ENCODING: &str = "grpc-encoding";
50    /// `grpc-accept-encoding` — Liste der akzeptierten Compressions.
51    pub const GRPC_ACCEPT_ENCODING: &str = "grpc-accept-encoding";
52    /// `user-agent` — empfohlen.
53    pub const USER_AGENT: &str = "user-agent";
54    /// `grpc-timeout` — siehe `timeout` Modul.
55    pub const GRPC_TIMEOUT: &str = "grpc-timeout";
56    /// `grpc-message-type` — fully-qualified-name des Request-Type.
57    pub const GRPC_MESSAGE_TYPE: &str = "grpc-message-type";
58}
59
60/// Standard Required Response-Headers + Trailers.
61pub mod response_headers {
62    /// HTTP/2-Pseudoheader `:status` — HTTP-Status.
63    pub const STATUS: &str = ":status";
64    /// `content-type` (siehe Request).
65    pub const CONTENT_TYPE: &str = "content-type";
66    /// `grpc-encoding`.
67    pub const GRPC_ENCODING: &str = "grpc-encoding";
68    /// Trailer: `grpc-status` (numerisch, 0..=16).
69    pub const GRPC_STATUS: &str = "grpc-status";
70    /// Trailer: `grpc-message` (Percent-Encoded).
71    pub const GRPC_MESSAGE: &str = "grpc-message";
72}
73
74// ---------------------------------------------------------------------------
75// -bin Header-Klassifikation
76// ---------------------------------------------------------------------------
77
78/// `true` wenn der Header-Name den `-bin`-Suffix traegt.
79#[must_use]
80pub fn is_binary_header(name: &str) -> bool {
81    name.len() > BIN_SUFFIX.len() && name.ends_with(BIN_SUFFIX)
82}
83
84// ---------------------------------------------------------------------------
85// Base64 fuer Binary-Header + grpc-web-text
86// ---------------------------------------------------------------------------
87
88const B64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
89
90/// Encoding eines Binary-Header-Wertes nach RFC 4648 §4 (mit Padding).
91#[must_use]
92pub fn encode_base64(input: &[u8]) -> String {
93    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
94    let mut i = 0;
95    while i + 3 <= input.len() {
96        let b0 = input[i];
97        let b1 = input[i + 1];
98        let b2 = input[i + 2];
99        out.push(B64_TABLE[(b0 >> 2) as usize] as char);
100        out.push(B64_TABLE[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
101        out.push(B64_TABLE[(((b1 & 0b1111) << 2) | (b2 >> 6)) as usize] as char);
102        out.push(B64_TABLE[(b2 & 0b111111) as usize] as char);
103        i += 3;
104    }
105    let rest = input.len() - i;
106    if rest == 1 {
107        let b0 = input[i];
108        out.push(B64_TABLE[(b0 >> 2) as usize] as char);
109        out.push(B64_TABLE[((b0 & 0b11) << 4) as usize] as char);
110        out.push('=');
111        out.push('=');
112    } else if rest == 2 {
113        let b0 = input[i];
114        let b1 = input[i + 1];
115        out.push(B64_TABLE[(b0 >> 2) as usize] as char);
116        out.push(B64_TABLE[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char);
117        out.push(B64_TABLE[((b1 & 0b1111) << 2) as usize] as char);
118        out.push('=');
119    }
120    out
121}
122
123/// Decoding eines Base64-encoded Binary-Header-Wertes.
124///
125/// # Errors
126/// `MetadataError::InvalidBase64` bei nicht-Base64-Zeichen oder
127/// fehlerhaftem Padding.
128pub fn decode_base64(input: &str) -> Result<Vec<u8>, MetadataError> {
129    let bytes = input.as_bytes();
130    if bytes.len() % 4 != 0 {
131        return Err(MetadataError::InvalidBase64);
132    }
133    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
134    let mut buf = [0u8; 4];
135    let mut buf_len;
136    let mut i = 0;
137    while i < bytes.len() {
138        buf_len = 0;
139        let mut padding = 0;
140        for j in 0..4 {
141            let c = bytes[i + j];
142            if c == b'=' {
143                padding += 1;
144                buf[j] = 0;
145            } else {
146                buf[j] = decode_b64_char(c).ok_or(MetadataError::InvalidBase64)?;
147                buf_len += 1;
148            }
149        }
150        if padding > 0 && i + 4 != bytes.len() {
151            return Err(MetadataError::InvalidBase64);
152        }
153        out.push((buf[0] << 2) | (buf[1] >> 4));
154        if buf_len > 2 {
155            out.push((buf[1] << 4) | (buf[2] >> 2));
156        }
157        if buf_len > 3 {
158            out.push((buf[2] << 6) | buf[3]);
159        }
160        i += 4;
161    }
162    Ok(out)
163}
164
165fn decode_b64_char(c: u8) -> Option<u8> {
166    match c {
167        b'A'..=b'Z' => Some(c - b'A'),
168        b'a'..=b'z' => Some(c - b'a' + 26),
169        b'0'..=b'9' => Some(c - b'0' + 52),
170        b'+' => Some(62),
171        b'/' => Some(63),
172        _ => None,
173    }
174}
175
176// ---------------------------------------------------------------------------
177// Errors
178// ---------------------------------------------------------------------------
179
180/// Metadata-Encoding-Fehler.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum MetadataError {
183    /// Base64-Eingabe ist invalid (Padding, Zeichen, Length).
184    InvalidBase64,
185    /// Header-Name traegt kein `-bin`-Suffix, Wert ist aber non-ASCII.
186    NonAsciiInTextHeader,
187}
188
189impl core::fmt::Display for MetadataError {
190    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
191        match self {
192            Self::InvalidBase64 => write!(f, "InvalidBase64"),
193            Self::NonAsciiInTextHeader => write!(f, "NonAsciiInTextHeader"),
194        }
195    }
196}
197
198#[cfg(feature = "std")]
199impl std::error::Error for MetadataError {}
200
201// ---------------------------------------------------------------------------
202// High-Level Helpers
203// ---------------------------------------------------------------------------
204
205/// Encodet einen Header-Wert nach dem Header-Namen:
206/// `-bin`-Suffix → base64; sonst ASCII (Plain).
207///
208/// # Errors
209/// `MetadataError::NonAsciiInTextHeader` wenn der Header *kein*
210/// `-bin`-Suffix hat aber `value` non-ASCII-Bytes enthaelt.
211pub fn encode_header_value(name: &str, value: &[u8]) -> Result<String, MetadataError> {
212    if is_binary_header(name) {
213        Ok(encode_base64(value))
214    } else if value.iter().all(|b| b.is_ascii() && *b != 0) {
215        Ok(String::from_utf8(value.to_vec()).map_err(|_| MetadataError::NonAsciiInTextHeader)?)
216    } else {
217        Err(MetadataError::NonAsciiInTextHeader)
218    }
219}
220
221/// Decodet einen Header-Wert in raw bytes nach dem Header-Namen.
222///
223/// # Errors
224/// `MetadataError::InvalidBase64` bei base64-Fehlern.
225pub fn decode_header_value(name: &str, encoded: &str) -> Result<Vec<u8>, MetadataError> {
226    if is_binary_header(name) {
227        decode_base64(encoded)
228    } else {
229        Ok(encoded.as_bytes().to_vec())
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Tests
235// ---------------------------------------------------------------------------
236
237#[cfg(test)]
238#[allow(clippy::expect_used)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn is_binary_header_recognizes_bin_suffix() {
244        assert!(is_binary_header("trace-bin"));
245        assert!(is_binary_header("custom-meta-bin"));
246    }
247
248    #[test]
249    fn is_binary_header_rejects_text_headers() {
250        assert!(!is_binary_header("custom-key"));
251        assert!(!is_binary_header(""));
252        assert!(!is_binary_header("-bin"));
253    }
254
255    #[test]
256    fn encode_base64_empty() {
257        assert_eq!(encode_base64(b""), "");
258    }
259
260    #[test]
261    fn encode_base64_one_byte_pads_two() {
262        assert_eq!(encode_base64(b"f"), "Zg==");
263    }
264
265    #[test]
266    fn encode_base64_two_bytes_pads_one() {
267        assert_eq!(encode_base64(b"fo"), "Zm8=");
268    }
269
270    #[test]
271    fn encode_base64_three_bytes_no_padding() {
272        assert_eq!(encode_base64(b"foo"), "Zm9v");
273    }
274
275    #[test]
276    fn encode_base64_known_vector() {
277        // RFC 4648 Test-Vector
278        assert_eq!(encode_base64(b"foobar"), "Zm9vYmFy");
279    }
280
281    #[test]
282    fn decode_base64_round_trip() {
283        for input in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar"] {
284            let encoded = encode_base64(input);
285            let decoded = decode_base64(&encoded).expect("decode");
286            assert_eq!(decoded, input);
287        }
288    }
289
290    #[test]
291    fn decode_base64_rejects_invalid_chars() {
292        assert!(decode_base64("Zm**").is_err());
293    }
294
295    #[test]
296    fn decode_base64_rejects_bad_padding_length() {
297        assert!(decode_base64("Zm9").is_err());
298    }
299
300    #[test]
301    fn encode_header_value_text_passes_ascii() {
302        let v = encode_header_value("custom-key", b"hello").expect("ok");
303        assert_eq!(v, "hello");
304    }
305
306    #[test]
307    fn encode_header_value_text_rejects_non_ascii() {
308        // 0xff is invalid ASCII
309        let r = encode_header_value("custom-key", &[0xff]);
310        assert!(r.is_err());
311    }
312
313    #[test]
314    fn encode_header_value_bin_uses_base64() {
315        let v = encode_header_value("trace-bin", &[0xde, 0xad, 0xbe, 0xef]).expect("ok");
316        // 0xdeadbeef → 3q2+7w==
317        assert_eq!(v, "3q2+7w==");
318    }
319
320    #[test]
321    fn decode_header_value_round_trip_bin() {
322        let original = vec![0x01, 0x02, 0x03, 0x04, 0xff];
323        let encoded = encode_header_value("trace-bin", &original).expect("encode");
324        let decoded = decode_header_value("trace-bin", &encoded).expect("decode");
325        assert_eq!(decoded, original);
326    }
327
328    #[test]
329    fn decode_header_value_text_passes_through() {
330        let decoded = decode_header_value("user-agent", "grpc-rust/1.0").expect("ok");
331        assert_eq!(decoded, b"grpc-rust/1.0");
332    }
333
334    #[test]
335    fn content_types_match_spec_strings() {
336        assert_eq!(content_types::GRPC, "application/grpc");
337        assert_eq!(content_types::GRPC_PROTO, "application/grpc+proto");
338        assert_eq!(content_types::GRPC_WEB, "application/grpc-web");
339        assert_eq!(content_types::GRPC_WEB_TEXT, "application/grpc-web-text");
340    }
341
342    #[test]
343    fn request_headers_constants_match_spec() {
344        assert_eq!(request_headers::METHOD, ":method");
345        assert_eq!(request_headers::PATH, ":path");
346        assert_eq!(request_headers::AUTHORITY, ":authority");
347        assert_eq!(request_headers::TE, "te");
348        assert_eq!(request_headers::GRPC_TIMEOUT, "grpc-timeout");
349    }
350
351    #[test]
352    fn response_headers_constants_match_spec() {
353        assert_eq!(response_headers::STATUS, ":status");
354        assert_eq!(response_headers::GRPC_STATUS, "grpc-status");
355        assert_eq!(response_headers::GRPC_MESSAGE, "grpc-message");
356    }
357}