Skip to main content

provide_telemetry/
propagation.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use std::collections::BTreeMap;
7
8use serde_json::Value;
9
10use crate::context::{bind_context, ContextGuard};
11use crate::tracer::set_trace_context;
12
13const MAX_HEADER_LENGTH: usize = 512;
14const MAX_TRACESTATE_PAIRS: usize = 32;
15const MAX_BAGGAGE_LENGTH: usize = 8192;
16
17#[derive(Clone, Debug, Default, PartialEq, Eq)]
18pub struct PropagationContext {
19    pub traceparent: Option<String>,
20    pub tracestate: Option<String>,
21    pub baggage: Option<String>,
22    pub trace_id: Option<String>,
23    pub span_id: Option<String>,
24}
25
26pub struct PropagationGuard {
27    trace_guard: Option<ContextGuard>,
28    context_guard: Option<ContextGuard>,
29}
30
31impl Drop for PropagationGuard {
32    #[cfg_attr(test, mutants::skip)] // Equivalent mutant: fields still drop after an empty body.
33    fn drop(&mut self) {
34        drop(self.trace_guard.take());
35        drop(self.context_guard.take());
36    }
37}
38
39/// Parse a W3C baggage header into key-value pairs.
40/// Properties after `;` are stripped. Empty keys are skipped.
41/// True when `key` is an RFC 7230 token, which the W3C Baggage spec requires.
42fn is_baggage_token(key: &str) -> bool {
43    !key.is_empty()
44        && key
45            .bytes()
46            .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
47}
48
49/// Strip C0 control characters and DEL from a baggage value, keeping TAB.
50///
51/// The set is exactly `[\x00-\x08\x0a-\x1f\x7f]` — the same one the Python,
52/// TypeScript and Go siblings compile. `char::is_control()` is deliberately not
53/// used: it is the Unicode Cc category, which also covers C1 (U+0080–U+009F).
54/// Stripping those here would make one baggage header parse to two different
55/// values depending on which language handled the hop, so the same request
56/// would carry different `baggage.*` log attributes and different
57/// cardinality-guard keys on either side.
58fn strip_control_chars(value: &str) -> String {
59    value
60        .chars()
61        .filter(|c| !matches!(*c, '\x00'..='\x08' | '\x0a'..='\x1f' | '\x7f'))
62        .collect()
63}
64
65/// Parse a W3C baggage header into key-value pairs.
66///
67/// Keys must be RFC 7230 tokens and control characters are stripped from values.
68/// This is a security boundary: a baggage key becomes a log-attribute key, and the
69/// console renderer emits keys bare, so a newline in a key from an untrusted
70/// inbound header would forge an entire additional log record.
71pub fn parse_baggage(raw: &str) -> BTreeMap<String, String> {
72    let mut result = BTreeMap::new();
73    for member in raw.split(',') {
74        let kv = member.split(';').next().unwrap_or("");
75        if let Some(eq_idx) = kv.find('=') {
76            let key = kv[..eq_idx].trim();
77            if is_baggage_token(key) {
78                let value = kv[eq_idx + 1..].trim();
79                result.insert(key.to_string(), strip_control_chars(value));
80            }
81        }
82    }
83    result
84}
85
86fn parse_traceparent(value: Option<&str>) -> (Option<String>, Option<String>, Option<String>) {
87    let Some(raw) = value else {
88        return (None, None, None);
89    };
90    let parts = raw.split('-').collect::<Vec<_>>();
91    if parts.len() != 4 {
92        return (None, None, None);
93    }
94    let version = parts[0];
95    let trace_id = parts[1];
96    let span_id = parts[2];
97    let flags = parts[3];
98    let valid = version.len() == 2
99        && trace_id.len() == 32
100        && span_id.len() == 16
101        && flags.len() == 2
102        && !version.eq_ignore_ascii_case("ff")
103        && trace_id != "00000000000000000000000000000000"
104        && span_id != "0000000000000000"
105        && [version, trace_id, span_id, flags]
106            .iter()
107            .all(|part| part.chars().all(|ch| ch.is_ascii_hexdigit()));
108
109    if !valid {
110        return (None, None, None);
111    }
112
113    (
114        Some(raw.to_string()),
115        Some(trace_id.to_ascii_lowercase()),
116        Some(span_id.to_ascii_lowercase()),
117    )
118}
119
120/// True when every tracestate list member fits the W3C grammar: OWS, a key
121/// starting with lcalpha/digit followed by up to 255 of the spec's key
122/// characters (multi-tenant `@` included), `=`, a value of printable ASCII
123/// minus comma and equals, OWS. One bad member discards the whole header.
124///
125/// A security boundary, not pedantry: a kept tracestate is forwarded verbatim
126/// into outbound headers by runtimes that inject it, so a surviving control
127/// character (CR/LF especially) is header injection at the next hop. Mirrors
128/// Python's `_is_forwardable_tracestate` (parity category:
129/// `propagation_tracestate_grammar`).
130fn is_forwardable_tracestate(value: &str) -> bool {
131    value.split(',').all(is_tracestate_member)
132}
133
134fn is_tracestate_member(member: &str) -> bool {
135    let trimmed = member.trim_matches([' ', '\t']);
136    let Some((key, val)) = trimmed.split_once('=') else {
137        return false;
138    };
139    if key.is_empty() || key.len() > 256 {
140        return false;
141    }
142    let mut chars = key.chars();
143    let first = chars.next().expect("key checked non-empty");
144    if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
145        return false;
146    }
147    if !chars.all(|c| {
148        c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '*' | '/' | '@')
149    }) {
150        return false;
151    }
152    val.chars()
153        .all(|c| matches!(c, '\x20'..='\x2b' | '\x2d'..='\x3c' | '\x3e'..='\x7e'))
154}
155
156pub fn extract_w3c_context(
157    traceparent: Option<&str>,
158    tracestate: Option<&str>,
159    baggage: Option<&str>,
160) -> PropagationContext {
161    let traceparent = traceparent.filter(|value| value.len() <= MAX_HEADER_LENGTH);
162    let tracestate = tracestate.and_then(|value| {
163        if value.len() > MAX_HEADER_LENGTH
164            || value.split(',').count() > MAX_TRACESTATE_PAIRS
165            || !is_forwardable_tracestate(value)
166        {
167            None
168        } else {
169            Some(value.to_string())
170        }
171    });
172    let baggage = baggage.and_then(|value| {
173        if value.len() > MAX_BAGGAGE_LENGTH {
174            None
175        } else {
176            Some(value.to_string())
177        }
178    });
179    let (traceparent, trace_id, span_id) = parse_traceparent(traceparent);
180
181    PropagationContext {
182        traceparent,
183        tracestate,
184        baggage,
185        trace_id,
186        span_id,
187    }
188}
189
190pub fn bind_propagation_context(context: PropagationContext) -> PropagationGuard {
191    let mut fields = Vec::new();
192    if let Some(traceparent) = context.traceparent.clone() {
193        fields.push(("traceparent".to_string(), Value::String(traceparent)));
194    }
195    if let Some(tracestate) = context.tracestate.clone() {
196        fields.push(("tracestate".to_string(), Value::String(tracestate)));
197    }
198    if let Some(ref baggage) = context.baggage {
199        fields.push(("baggage".to_string(), Value::String(baggage.clone())));
200        for (k, v) in parse_baggage(baggage) {
201            fields.push((format!("baggage.{k}"), Value::String(v)));
202        }
203    }
204
205    let context_guard = if fields.is_empty() {
206        None
207    } else {
208        Some(bind_context(fields))
209    };
210    let trace_guard = if context.trace_id.is_some() || context.span_id.is_some() {
211        Some(set_trace_context(context.trace_id, context.span_id))
212    } else {
213        None
214    };
215
216    PropagationGuard {
217        trace_guard,
218        context_guard,
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    use serde_json::json;
227
228    use crate::context::get_context;
229    use crate::testing::acquire_test_state_lock;
230    use crate::tracer::get_trace_context;
231
232    #[test]
233    fn propagation_test_a_parse_baggage_keeps_pairs_and_strips_parameters() {
234        let baggage = parse_baggage("user=alice;prop=x,env=prod;ttl=100,invalid,=skip");
235
236        assert_eq!(baggage.get("user").map(String::as_str), Some("alice"));
237        assert_eq!(baggage.get("env").map(String::as_str), Some("prod"));
238        assert_eq!(baggage.len(), 2);
239    }
240
241    #[test]
242    fn propagation_test_a_bind_propagation_context_roundtrip_restores_state() {
243        let _guard = acquire_test_state_lock();
244        let context = PropagationContext {
245            traceparent: Some(
246                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
247            ),
248            tracestate: Some("k=v".to_string()),
249            baggage: Some("user=alice,env=prod".to_string()),
250            trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
251            span_id: Some("00f067aa0ba902b7".to_string()),
252        };
253
254        {
255            let _propagation = bind_propagation_context(context);
256            let trace = get_trace_context();
257            let fields = get_context();
258            assert_eq!(
259                trace.get("trace_id").and_then(std::clone::Clone::clone),
260                Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string())
261            );
262            assert_eq!(
263                trace.get("span_id").and_then(std::clone::Clone::clone),
264                Some("00f067aa0ba902b7".to_string())
265            );
266            assert_eq!(
267                fields.get("traceparent"),
268                Some(&json!(
269                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
270                ))
271            );
272            assert_eq!(fields.get("tracestate"), Some(&json!("k=v")));
273            assert_eq!(fields.get("baggage"), Some(&json!("user=alice,env=prod")));
274            assert_eq!(fields.get("baggage.user"), Some(&json!("alice")));
275            assert_eq!(fields.get("baggage.env"), Some(&json!("prod")));
276        }
277
278        assert!(get_context().is_empty());
279        let trace = get_trace_context();
280        assert_eq!(trace.get("trace_id"), Some(&None));
281        assert_eq!(trace.get("span_id"), Some(&None));
282    }
283}