1use std::borrow::Cow;
4
5use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9pub const MAX_LABEL_BYTES: usize = 128;
11pub const MAX_ARRAY_BYTES: usize = 256;
13pub const MAX_CHUNK_BYTES: usize = 64;
15
16#[derive(Debug, Error, Clone, PartialEq, Eq)]
17pub enum AttributionError {
18 #[error("attribution `{field}` is longer than {MAX_LABEL_BYTES} bytes")]
19 TooLong { field: &'static str },
20 #[error("attribution `{field}` contains the forbidden character {ch:?}")]
21 ForbiddenChar { field: &'static str, ch: char },
22 #[error(
23 "attribution `client` must be `name` or `name/version` in token characters, got {value:?}"
24 )]
25 InvalidClient { value: String },
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub struct Attribution {
45 client: Option<String>,
46 workload: Option<String>,
47 principal: Option<String>,
48}
49
50impl Attribution {
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 pub fn with_client(
59 mut self,
60 client: impl Into<String>,
61 ) -> Result<Self, AttributionError> {
62 let client = client.into();
63 validate_client(&client)?;
64 self.client = Some(client);
65 Ok(self)
66 }
67
68 pub fn with_workload(
72 mut self,
73 workload: impl Into<String>,
74 ) -> Result<Self, AttributionError> {
75 let workload = workload.into();
76 validate_label("workload", &workload)?;
77 self.workload = Some(workload);
78 Ok(self)
79 }
80
81 pub fn with_principal(
85 mut self,
86 principal: impl Into<String>,
87 ) -> Result<Self, AttributionError> {
88 let principal = principal.into();
89 validate_label("principal", &principal)?;
90 self.principal = Some(principal);
91 Ok(self)
92 }
93
94 pub fn client(&self) -> Option<&str> {
95 self.client.as_deref()
96 }
97
98 pub fn workload(&self) -> Option<&str> {
99 self.workload.as_deref()
100 }
101
102 pub fn principal(&self) -> Option<&str> {
103 self.principal.as_deref()
104 }
105}
106
107fn validate_label(field: &'static str, value: &str) -> Result<(), AttributionError> {
108 if value.len() > MAX_LABEL_BYTES {
109 return Err(AttributionError::TooLong { field });
110 }
111 let forbidden = |c: &char| !(' '..='~').contains(c) || "()\\;=\"".contains(*c);
112 match value.chars().find(forbidden) {
113 Some(ch) => Err(AttributionError::ForbiddenChar { field, ch }),
114 None => Ok(()),
115 }
116}
117
118fn is_tchar(c: char) -> bool {
119 c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c)
120}
121
122fn validate_client(value: &str) -> Result<(), AttributionError> {
123 if value.len() > MAX_LABEL_BYTES {
124 return Err(AttributionError::TooLong { field: "client" });
125 }
126 let invalid = || AttributionError::InvalidClient { value: value.to_string() };
127 let (name, version) = match value.split_once('/') {
128 Some((name, version)) => (name, Some(version)),
129 None => (value, None),
130 };
131 let token = |s: &str| !s.is_empty() && s.chars().all(is_tchar);
132 if !token(name) || version.is_some_and(|v| !token(v)) {
133 return Err(invalid());
134 }
135 Ok(())
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct AttributionLabels {
141 products: Cow<'static, str>,
143 fields: Cow<'static, str>,
145}
146
147pub static UNATTRIBUTED_LABELS: AttributionLabels = AttributionLabels {
149 products: Cow::Borrowed(icechunk_types::user_agent_product()),
150 fields: Cow::Borrowed(""),
151};
152
153impl Default for AttributionLabels {
154 fn default() -> Self {
155 UNATTRIBUTED_LABELS.clone()
156 }
157}
158
159impl From<&Attribution> for AttributionLabels {
160 fn from(a: &Attribution) -> Self {
161 let products = match a.client() {
162 Some(client) => {
163 Cow::Owned(format!("{client} {}", icechunk_types::user_agent_product()))
164 }
165 None => Cow::Borrowed(icechunk_types::user_agent_product()),
166 };
167 let fields = [("workload", a.workload()), ("principal", a.principal())]
168 .into_iter()
169 .filter_map(|(k, v)| v.map(|v| format!("{k}={v}")))
170 .collect::<Vec<_>>()
171 .join("; ");
172 Self { products, fields: Cow::Owned(fields) }
173 }
174}
175
176#[derive(Debug, Clone, Copy)]
178pub struct RequestAttribution<'a> {
179 pub labels: &'a AttributionLabels,
180 pub array: Option<&'a str>,
182 pub chunk: Option<&'a [u32]>,
184}
185
186impl<'a> RequestAttribution<'a> {
187 pub fn without_node(labels: &'a AttributionLabels) -> Self {
188 Self { labels, array: None, chunk: None }
189 }
190
191 pub fn user_agent_fragment(&self) -> String {
194 let mut parts: Vec<Cow<'_, str>> = Vec::with_capacity(3);
195 if !self.labels.fields.is_empty() {
196 parts.push(Cow::Borrowed(&self.labels.fields));
197 }
198 if let Some(array) = self.array {
199 let value = truncate_middle(encode(array), MAX_ARRAY_BYTES);
200 parts.push(Cow::Owned(format!("array={value}")));
201 }
202 if let Some(chunk) = self.chunk {
203 let rendered = chunk.iter().map(u32::to_string).collect::<Vec<_>>().join("/");
204 let value = truncate_middle(rendered, MAX_CHUNK_BYTES);
205 parts.push(Cow::Owned(format!("chunk={value}")));
206 }
207 if parts.is_empty() {
208 self.labels.products.to_string()
209 } else {
210 format!("{} ({})", self.labels.products, parts.join("; "))
211 }
212 }
213}
214
215const COMMENT_ESCAPES: &AsciiSet =
218 &CONTROLS.add(b'(').add(b')').add(b'\\').add(b';').add(b'=').add(b'%');
219
220fn encode(value: &str) -> String {
221 utf8_percent_encode(value, COMMENT_ESCAPES).to_string()
222}
223
224fn truncate_middle(value: String, cap: usize) -> String {
228 if value.len() <= cap {
229 return value;
230 }
231 let bytes = value.as_bytes();
232 let keep = (cap - 3) / 2;
233
234 let mut head_end = keep;
236 while head_end > 0 && bytes[head_end.saturating_sub(2)..head_end].contains(&b'%') {
237 head_end -= 1;
238 }
239 let mut tail_start = value.len() - keep;
242 while tail_start < value.len()
243 && bytes[tail_start.saturating_sub(2)..tail_start].contains(&b'%')
244 {
245 tail_start += 1;
246 }
247 format!("{}...{}", &value[..head_end], &value[tail_start..])
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn labels(a: &Attribution) -> AttributionLabels {
255 AttributionLabels::from(a)
256 }
257
258 fn value_of(fragment: &str, field: &str) -> String {
259 fragment
260 .split(&format!("{field}="))
261 .nth(1)
262 .unwrap()
263 .split([';', ')'])
264 .next()
265 .unwrap()
266 .to_string()
267 }
268
269 #[test]
270 fn empty_attribution_renders_only_the_product_token() {
271 let l = labels(&Attribution::new());
272 let r = RequestAttribution::without_node(&l);
273 assert_eq!(r.user_agent_fragment(), icechunk_types::user_agent_product());
274 assert_eq!(UNATTRIBUTED_LABELS, l);
275 assert_eq!(AttributionLabels::default(), l);
276 }
277
278 #[test]
279 fn labels_render_in_order_and_client_goes_first() {
280 let a = Attribution::new()
281 .with_client("weatherlib/0.9")
282 .unwrap()
283 .with_workload("nightly-ingest")
284 .unwrap()
285 .with_principal("u_123")
286 .unwrap();
287 let l = labels(&a);
288 let r = RequestAttribution::without_node(&l);
289 assert_eq!(
290 r.user_agent_fragment(),
291 format!(
292 "weatherlib/0.9 {} (workload=nightly-ingest; principal=u_123)",
293 icechunk_types::user_agent_product()
294 )
295 );
296 }
297
298 #[test]
299 fn array_and_chunk_are_appended_after_labels() {
300 let a = Attribution::new().with_workload("w").unwrap();
301 let l = labels(&a);
302 let r = RequestAttribution {
303 labels: &l,
304 array: Some("g/temperature"),
305 chunk: Some(&[0, 1, 2]),
306 };
307 assert_eq!(
308 r.user_agent_fragment(),
309 format!(
310 "{} (workload=w; array=g/temperature; chunk=0/1/2)",
311 icechunk_types::user_agent_product()
312 )
313 );
314 }
315
316 #[test]
317 fn array_only_without_labels() {
318 let r = RequestAttribution {
319 labels: &UNATTRIBUTED_LABELS,
320 array: Some("a"),
321 chunk: None,
322 };
323 assert_eq!(
324 r.user_agent_fragment(),
325 format!("{} (array=a)", icechunk_types::user_agent_product())
326 );
327 }
328
329 #[test]
330 fn reserved_characters_are_percent_encoded() {
331 let r = RequestAttribution {
332 labels: &UNATTRIBUTED_LABELS,
333 array: Some("a(b)c\\d;e=f%g\u{e9} h"),
334 chunk: None,
335 };
336 assert_eq!(
337 r.user_agent_fragment(),
338 format!(
339 "{} (array=a%28b%29c%5Cd%3Be%3Df%25g%C3%A9 h)",
340 icechunk_types::user_agent_product()
341 )
342 );
343 }
344
345 #[test]
346 fn long_array_keeps_head_and_tail() {
347 let long = format!("{}MIDDLE{}", "h".repeat(150), "t".repeat(150));
348 let r = RequestAttribution {
349 labels: &UNATTRIBUTED_LABELS,
350 array: Some(&long),
351 chunk: None,
352 };
353 let value = value_of(&r.user_agent_fragment(), "array");
354 let keep = (MAX_ARRAY_BYTES - 3) / 2;
355 assert_eq!(value, format!("{}...{}", "h".repeat(keep), "t".repeat(keep)));
356 assert!(value.len() <= MAX_ARRAY_BYTES);
357 }
358
359 #[test]
360 fn truncation_never_splits_a_percent_escape() {
361 let long = "\u{e9}".repeat(100);
363 let r = RequestAttribution {
364 labels: &UNATTRIBUTED_LABELS,
365 array: Some(&long),
366 chunk: None,
367 };
368 let value = value_of(&r.user_agent_fragment(), "array");
369 let (head, tail) = value.split_once("...").unwrap();
370 assert_eq!(head.len() % 3, 0, "head ends mid-escape: {head}");
371 assert_eq!(tail.len() % 3, 0, "tail starts mid-escape: {tail}");
372 assert!(tail.starts_with('%'));
373 }
374
375 #[test]
376 fn truncate_middle_isolated_escapes_at_both_cut_points() {
377 let value =
381 format!("{}%41{}%42{}", "a".repeat(125), "b".repeat(200), "c".repeat(125));
382 let result = truncate_middle(value, MAX_ARRAY_BYTES);
383 let (head, tail) = result.split_once("...").unwrap();
384 assert_eq!(head, "a".repeat(125), "head kept part of the %41 escape");
385 assert_eq!(tail, "c".repeat(125), "tail kept part of the %42 escape");
386 }
387
388 #[test]
389 fn truncate_middle_never_leaves_an_orphaned_hex_digit_in_the_tail() {
390 let value = format!("{}%41{}", "A".repeat(50), "B".repeat(10));
394 let result = truncate_middle(value, 25);
395 assert_eq!(result, format!("{}...{}", "A".repeat(11), "B".repeat(10)));
396 }
397
398 #[test]
399 fn long_chunk_is_truncated_at_its_own_cap() {
400 let coords: Vec<u32> = (0..40).map(|i| 1_000_000 + i).collect();
401 let r = RequestAttribution {
402 labels: &UNATTRIBUTED_LABELS,
403 array: None,
404 chunk: Some(&coords),
405 };
406 let value = value_of(&r.user_agent_fragment(), "chunk");
407 let keep = (MAX_CHUNK_BYTES - 3) / 2;
408 assert_eq!(value.len(), 2 * keep + 3);
409 assert!(value.starts_with("1000000/1000001/"));
410 assert!(value.ends_with("/1000038/1000039"));
411 }
412
413 #[test]
414 fn labels_reject_forbidden_characters_length_and_bad_clients() {
415 for bad in ["a(b", "a)b", "a\\b", "a;b", "a=b", "a\"b", "a\u{e9}", "a\nb"] {
416 assert!(Attribution::new().with_workload(bad).is_err(), "{bad:?}");
417 assert!(Attribution::new().with_principal(bad).is_err(), "{bad:?}");
418 }
419 assert!(Attribution::new().with_workload("x".repeat(MAX_LABEL_BYTES)).is_ok());
420 assert!(
421 Attribution::new().with_workload("x".repeat(MAX_LABEL_BYTES + 1)).is_err()
422 );
423 assert!(Attribution::new().with_workload("spaces are fine").is_ok());
424 for bad in ["", "a b", "a/", "/1", "a/b/c", "a(b)"] {
425 assert!(Attribution::new().with_client(bad).is_err(), "{bad:?}");
426 }
427 for ok in ["weatherlib", "weatherlib/0.9.1", "my-lib_2/1.0-rc1"] {
428 assert!(Attribution::new().with_client(ok).is_ok(), "{ok:?}");
429 }
430 }
431
432 #[test]
433 fn getters_return_what_was_set() {
434 let a =
435 Attribution::new().with_client("c/1").unwrap().with_principal("p").unwrap();
436 assert_eq!(a.client(), Some("c/1"));
437 assert_eq!(a.workload(), None);
438 assert_eq!(a.principal(), Some("p"));
439 }
440}