tycho_client/
client_metadata.rs1use std::collections::HashMap;
8
9use thiserror::Error;
10
11pub const CLIENT_METADATA_HEADER: &str = "x-tycho-client-metadata";
14
15pub const MAX_ENTRIES: usize = 16;
17pub const MAX_KEY_BYTES: usize = 64;
19pub const MAX_VALUE_BYTES: usize = 128;
21pub 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
40pub(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 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 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}