Skip to main content

icechunk_storage/
attribution.rs

1//! Per-request attribution rendered into the `User-Agent` header.
2
3use std::borrow::Cow;
4
5use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Longest accepted value for any [`Attribution`] label, in bytes.
10pub const MAX_LABEL_BYTES: usize = 128;
11/// Longest rendered `array=` value, in bytes, after encoding.
12pub const MAX_ARRAY_BYTES: usize = 256;
13/// Longest rendered `chunk=` value, in bytes.
14pub 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/// Caller-supplied labels attached to every request of a repository.
29///
30/// They are rendered into the `User-Agent` header so the bucket owner can
31/// break traffic down in access logs by three questions:
32///
33/// - `client`: which software is talking to the bucket. The product token of
34///   the application or library that embeds icechunk, e.g. `weatherlib/0.9`.
35/// - `workload`: what it is doing. The job, pipeline or deployment that runs
36///   the requests, e.g. `nightly-ingest` or `dashboard-v2`.
37/// - `principal`: on whose behalf. A user id, service account or tenant,
38///   set by services that act for many users.
39///
40/// The array path and chunk coordinates are added per request by icechunk
41/// itself and need no configuration.
42#[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    /// Which software is talking to the bucket: the product token of the
56    /// application or library that embeds icechunk, `name` or `name/version`
57    /// in HTTP token characters, e.g. `weatherlib/0.9`.
58    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    /// What the requests are for: the job, pipeline or deployment running
69    /// them, e.g. `nightly-ingest`. Printable ASCII, at most 128 bytes, none
70    /// of `(`, `)`, `\`, `;`, `=`, `"`.
71    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    /// On whose behalf the requests are made: a user id, service account or
82    /// tenant, for services that act for many users. Same rules as
83    /// [`Attribution::with_workload`].
84    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/// The per-repository part of the header, rendered once.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct AttributionLabels {
141    /// Product tokens, e.g. `weatherlib/0.9 icechunk/2.3.0`.
142    products: Cow<'static, str>,
143    /// Comment fields, e.g. `workload=x; principal=y`. Empty when none is set.
144    fields: Cow<'static, str>,
145}
146
147/// Labels of a caller that set no attribution: the icechunk product token only.
148pub 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/// What one storage request is attributed to.
177#[derive(Debug, Clone, Copy)]
178pub struct RequestAttribution<'a> {
179    pub labels: &'a AttributionLabels,
180    /// Node path without its leading `/`.
181    pub array: Option<&'a str>,
182    /// Chunk coordinates.
183    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    /// The icechunk part of the `User-Agent` header: product tokens, then a
192    /// parenthesised comment when there is anything to say.
193    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
215/// Bytes a header comment cannot carry (controls and non-ASCII, via
216/// [`CONTROLS`]), plus our own separators. `/` and space pass through.
217const 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
224/// Keep the first and last `(cap - 3) / 2` bytes joined by `...`, moving the
225/// cut points outward off any `%XX` escape they would split. Input is ASCII
226/// (the output of [`encode`], or digits and slashes), so byte slicing is safe.
227fn 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    // the head may not end with `%` or `%X`
235    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    // the tail may not start inside an escape: `%` one or two bytes before
240    // the cut means the cut splits `%XX`; step forward past it
241    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        // "é" encodes to "%C3%A9": 100 of them is 600 bytes
362        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        // keep = (MAX_ARRAY_BYTES - 3) / 2 = 126: the head cut at 126 lands
378        // inside "%41" (bytes 125..128), and the tail cut at len - 126 lands
379        // one byte into "%42".
380        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        // Regression for a tail cut that landed one byte into "%41": the old
391        // code only looked forward from the cut point, so it kept the
392        // trailing "1" of the escape instead of stepping past it.
393        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}