Skip to main content

tycho_client/
client_metadata.rs

1//! Generic client-metadata carried to the Tycho server in a dedicated header.
2//!
3//! This is the single source of truth for the header name and serialization so the RPC and
4//! WebSocket paths can never drift. The map is deliberately untyped: `tycho-client` never learns
5//! what the keys mean — consumers supply their own vocabulary.
6
7use std::collections::HashMap;
8
9use thiserror::Error;
10
11/// Header name carrying serialized client metadata. Lowercase so it can be used with
12/// `HeaderName::from_static`.
13pub const CLIENT_METADATA_HEADER: &str = "x-tycho-client-metadata";
14
15/// Maximum number of entries a client may send.
16pub const MAX_ENTRIES: usize = 16;
17/// Maximum key length in bytes.
18pub const MAX_KEY_BYTES: usize = 64;
19/// Maximum value length in bytes.
20pub const MAX_VALUE_BYTES: usize = 128;
21/// Maximum serialized header length in bytes.
22pub const MAX_HEADER_BYTES: usize = 1024;
23
24#[derive(Debug, Error, PartialEq, Eq)]
25pub(crate) enum ClientMetadataError {
26    #[error("invalid client metadata key: {0:?}")]
27    InvalidKey(String),
28    #[error("invalid client metadata value: {0:?}")]
29    InvalidValue(String),
30    #[error("too many client metadata entries: {0} (max {MAX_ENTRIES})")]
31    TooManyEntries(usize),
32    #[error("client metadata key too long: {0:?} (max {MAX_KEY_BYTES} bytes)")]
33    KeyTooLong(String),
34    #[error("client metadata value too long: {0:?} (max {MAX_VALUE_BYTES} bytes)")]
35    ValueTooLong(String),
36    #[error("serialized client metadata too long: {0} bytes (max {MAX_HEADER_BYTES})")]
37    HeaderTooLong(usize),
38}
39
40/// Serializes client metadata into the `X-Tycho-Client-Metadata` header value.
41///
42/// Entries are emitted in key order as `key=value;key=value`. Returns `Ok(None)` for an empty
43/// map, meaning no header should be sent (back-compatible default). Keys must be non-empty and
44/// match `[A-Za-z0-9_.-]`; values must be non-empty visible ASCII excluding `;` and `=`. These
45/// rules are stricter than `HeaderValue::from_str`, so any accepted output is always a valid
46/// header value and the RPC path can never fail on serialized input.
47pub(crate) fn serialize_client_metadata(
48    meta: &HashMap<String, String>,
49) -> Result<Option<String>, ClientMetadataError> {
50    if meta.is_empty() {
51        return Ok(None);
52    }
53    if meta.len() > MAX_ENTRIES {
54        return Err(ClientMetadataError::TooManyEntries(meta.len()));
55    }
56    // Sort by key so the serialized header is deterministic regardless of map iteration order.
57    let mut entries: Vec<_> = meta.iter().collect();
58    entries.sort_by_key(|(k, _)| *k);
59    let mut parts = Vec::with_capacity(entries.len());
60    for (key, value) in entries {
61        if !is_valid_key(key) {
62            return Err(ClientMetadataError::InvalidKey(key.clone()));
63        }
64        if key.len() > MAX_KEY_BYTES {
65            return Err(ClientMetadataError::KeyTooLong(key.clone()));
66        }
67        if !is_valid_value(value) {
68            return Err(ClientMetadataError::InvalidValue(value.clone()));
69        }
70        if value.len() > MAX_VALUE_BYTES {
71            return Err(ClientMetadataError::ValueTooLong(value.clone()));
72        }
73        parts.push(format!("{key}={value}"));
74    }
75    let serialized = parts.join(";");
76    if serialized.len() > MAX_HEADER_BYTES {
77        return Err(ClientMetadataError::HeaderTooLong(serialized.len()));
78    }
79    Ok(Some(serialized))
80}
81
82fn is_valid_key(key: &str) -> bool {
83    !key.is_empty() &&
84        key.bytes()
85            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
86}
87
88fn is_valid_value(value: &str) -> bool {
89    !value.is_empty() &&
90        value
91            .bytes()
92            .all(|b| b.is_ascii_graphic() && b != b';' && b != b'=')
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn map(pairs: &[(&str, &str)]) -> HashMap<String, String> {
100        pairs
101            .iter()
102            .map(|(k, v)| (k.to_string(), v.to_string()))
103            .collect()
104    }
105
106    #[test]
107    fn empty_map_yields_no_header() {
108        assert_eq!(serialize_client_metadata(&HashMap::new()), Ok(None));
109    }
110
111    #[test]
112    fn serializes_in_deterministic_key_order() {
113        let meta = map(&[("preset", "best"), ("fynd_version", "0.57.0")]);
114        assert_eq!(
115            serialize_client_metadata(&meta),
116            Ok(Some("fynd_version=0.57.0;preset=best".to_string()))
117        );
118    }
119
120    #[test]
121    fn rejects_invalid_keys() {
122        for bad in ["", "has space", "semi;colon", "eq=uals", "unicod\u{00e9}"] {
123            let meta = map(&[(bad, "v")]);
124            assert!(
125                matches!(serialize_client_metadata(&meta), Err(ClientMetadataError::InvalidKey(_))),
126                "expected InvalidKey for {bad:?}"
127            );
128        }
129    }
130
131    #[test]
132    fn rejects_invalid_values() {
133        for bad in ["", "has space", "semi;colon", "eq=uals", "ctrl\u{0007}", "unicod\u{00e9}"] {
134            let meta = map(&[("k", bad)]);
135            assert!(
136                matches!(
137                    serialize_client_metadata(&meta),
138                    Err(ClientMetadataError::InvalidValue(_))
139                ),
140                "expected InvalidValue for {bad:?}"
141            );
142        }
143    }
144
145    #[test]
146    fn rejects_too_many_entries() {
147        let meta: HashMap<String, String> = (0..MAX_ENTRIES + 1)
148            .map(|i| (format!("k{i}"), "v".to_string()))
149            .collect();
150        assert_eq!(
151            serialize_client_metadata(&meta),
152            Err(ClientMetadataError::TooManyEntries(MAX_ENTRIES + 1))
153        );
154    }
155
156    #[test]
157    fn rejects_overlong_key() {
158        let meta = map(&[("v", "ok")]);
159        let long_key = "a".repeat(MAX_KEY_BYTES + 1);
160        let mut meta2 = meta;
161        meta2.insert(long_key.clone(), "v".to_string());
162        assert_eq!(
163            serialize_client_metadata(&meta2),
164            Err(ClientMetadataError::KeyTooLong(long_key))
165        );
166    }
167
168    #[test]
169    fn rejects_overlong_value() {
170        let long_value = "a".repeat(MAX_VALUE_BYTES + 1);
171        let meta = map(&[("k", long_value.as_str())]);
172        assert_eq!(
173            serialize_client_metadata(&meta),
174            Err(ClientMetadataError::ValueTooLong(long_value))
175        );
176    }
177
178    #[test]
179    fn rejects_overlong_header() {
180        // Nine entries, each value at the per-value cap, exceed the 1 KiB header cap.
181        let value = "a".repeat(MAX_VALUE_BYTES);
182        let meta: HashMap<String, String> = (0..9)
183            .map(|i| (format!("key{i}"), value.clone()))
184            .collect();
185        assert!(matches!(
186            serialize_client_metadata(&meta),
187            Err(ClientMetadataError::HeaderTooLong(_))
188        ));
189    }
190
191    #[test]
192    fn accepts_entries_at_the_caps() {
193        let meta = map(&[
194            ("k", "a".repeat(MAX_VALUE_BYTES).as_str()),
195            ("a".repeat(MAX_KEY_BYTES).as_str(), "v"),
196        ]);
197        assert!(serialize_client_metadata(&meta).is_ok());
198    }
199
200    #[test]
201    fn accepted_output_is_a_valid_header_value() {
202        let meta = map(&[("fynd_version", "0.57.0"), ("preset", "best")]);
203        let serialized = serialize_client_metadata(&meta)
204            .unwrap()
205            .unwrap();
206        assert!(reqwest::header::HeaderValue::from_str(&serialized).is_ok());
207    }
208}