Skip to main content

sentinel_core/ingest/
json.rs

1//! JSON ingestion with auto-format detection.
2//!
3//! Detects the input format (native, OTLP/JSON, Jaeger, Zipkin) and dispatches
4//! to the appropriate parser. Format detection peeks at the JSON structure,
5//! in this order:
6//! - Has `"data"` key with trace objects containing `"spans"` -> Jaeger
7//! - Has `"resourceSpans"` (or `"resource_spans"`) key -> OTLP/JSON
8//! - Is array where items have `"traceId"` + `"localEndpoint"` -> Zipkin
9//! - Otherwise -> native perf-sentinel format
10
11use crate::event::SpanEvent;
12use crate::ingest::IngestSource;
13
14/// Defense-in-depth nesting cap for the native ingest path. The native
15/// span-event format is flat (top-level array of objects, each with at
16/// most a `source` and a few scalar fields), so depth 32 is well above
17/// what valid input ever needs. We pre-scan the bytes BEFORE handing
18/// them to `serde_json::from_slice` because `serde_json` has a built-in
19/// recursion limit of 128 (its compile-time default, no public setter
20/// to tighten it). The pre-scan is O(N) in payload bytes, negligible
21/// next to the JSON parse cost.
22pub const MAX_JSON_DEPTH: usize = 32;
23
24/// Reject the payload when its bracket nesting exceeds [`MAX_JSON_DEPTH`].
25///
26/// This is a byte-level pre-scan, not a full JSON parse: it counts `[`
27/// and `{` opens against `]` and `}` closes, ignoring any character that
28/// appears inside a `"..."` string (with `\"` escape support). False
29/// positives (rejecting valid input) are impossible because we never
30/// inflate the depth on string contents. False negatives (accepting an
31/// over-deep payload) are impossible because every structural open
32/// increments depth.
33///
34/// `pub` so CLI subcommands that accept user-supplied JSON through paths
35/// that bypass `JsonIngest` (e.g. `report --input` in Report mode,
36/// `report --before`) can enforce the same defense-in-depth cap.
37#[must_use]
38pub fn exceeds_max_depth(raw: &[u8]) -> bool {
39    let mut depth: usize = 0;
40    let mut in_string = false;
41    let mut escape = false;
42    for &b in raw {
43        if in_string {
44            advance_string_state(b, &mut in_string, &mut escape);
45            continue;
46        }
47        if bump_depth(b, &mut depth, &mut in_string) {
48            return true;
49        }
50    }
51    false
52}
53
54/// Advance the string-scanning state machine by one byte while inside a
55/// `"..."` literal. Handles `\"` escapes and the closing `"`. Pulled out
56/// of [`exceeds_max_depth`] to keep its cognitive complexity under the
57/// S3776 threshold.
58#[inline]
59fn advance_string_state(b: u8, in_string: &mut bool, escape: &mut bool) {
60    if *escape {
61        *escape = false;
62    } else if b == b'\\' {
63        *escape = true;
64    } else if b == b'"' {
65        *in_string = false;
66    }
67}
68
69/// Apply a structural byte to the bracket-depth counter. Returns `true`
70/// iff the depth rose above [`MAX_JSON_DEPTH`] (the caller short-circuits
71/// and rejects the payload).
72#[inline]
73fn bump_depth(b: u8, depth: &mut usize, in_string: &mut bool) -> bool {
74    match b {
75        b'"' => *in_string = true,
76        b'[' | b'{' => {
77            *depth += 1;
78            if *depth > MAX_JSON_DEPTH {
79                return true;
80            }
81        }
82        b']' | b'}' => *depth = depth.saturating_sub(1),
83        _ => {}
84    }
85    false
86}
87
88/// The detected input format.
89///
90/// `#[non_exhaustive]` for SemVer-minor variant additions (0.9.5 added
91/// `Otlp`; external matchers must carry a wildcard arm).
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub enum InputFormat {
95    /// Native perf-sentinel JSON array of `SpanEvent`.
96    Native,
97    /// OTLP/JSON (`ExportTraceServiceRequest`), single object or NDJSON.
98    Otlp,
99    /// Jaeger JSON export format.
100    Jaeger,
101    /// Zipkin JSON v2 format.
102    Zipkin,
103}
104
105/// Ingests span events from JSON input with auto-format detection.
106pub struct JsonIngest {
107    max_size: usize,
108}
109
110impl JsonIngest {
111    #[must_use]
112    pub const fn new(max_size: usize) -> Self {
113        Self { max_size }
114    }
115}
116
117impl IngestSource for JsonIngest {
118    type Error = JsonIngestError;
119
120    fn ingest(&self, raw: &[u8]) -> Result<Vec<SpanEvent>, Self::Error> {
121        if raw.len() > self.max_size {
122            return Err(JsonIngestError::PayloadTooLarge {
123                size: raw.len(),
124                max: self.max_size,
125            });
126        }
127
128        // Apply the project-wide nesting cap before dispatching to a
129        // format-specific parser. Pre-0.5.15 only the Native arm enforced
130        // it, leaving Jaeger and Zipkin paths on serde_json's looser
131        // 128-frame default.
132        if exceeds_max_depth(raw) {
133            return Err(JsonIngestError::PayloadTooDeep {
134                max_depth: MAX_JSON_DEPTH,
135            });
136        }
137
138        match detect_format(raw) {
139            InputFormat::Otlp => {
140                // Deserialize each document (a single pretty-printed request or
141                // the Collector file exporter's NDJSON, one request per line)
142                // straight into the typed ExportTraceServiceRequest: that keeps
143                // strict duplicate-key rejection, positioned parse errors, and
144                // streaming memory. Only a document that trips protojson's
145                // omitted-`values` case (empty arrayValue/kvlistValue) is
146                // re-parsed through a normalized Value, so the common case pays
147                // nothing. convert_otlp_request sanitizes each event, same code
148                // path as the daemon listeners.
149                type OtlpRequest =
150                    opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest;
151                let mut events = Vec::new();
152                let mut parsed_any = false;
153                let mut offset = 0;
154                while offset < raw.len() {
155                    let mut stream = serde_json::Deserializer::from_slice(&raw[offset..])
156                        .into_iter::<OtlpRequest>();
157                    match stream.next() {
158                        None => break,
159                        Some(Ok(request)) => {
160                            parsed_any = true;
161                            events.extend(crate::ingest::otlp::convert_otlp_request(&request));
162                            offset += stream.byte_offset();
163                        }
164                        // Canonical protojson omits empty repeated fields, so an
165                        // empty-list attribute serializes as `{"arrayValue":{}}`
166                        // and fails with `missing field values`. Backfill the
167                        // empty list via normalize_otlp_json and retry just this
168                        // one document.
169                        Some(Err(e)) if is_missing_values(&e) => {
170                            let mut retry = serde_json::Deserializer::from_slice(&raw[offset..])
171                                .into_iter::<serde_json::Value>();
172                            let Some(Ok(mut value)) = retry.next() else {
173                                return Err(JsonIngestError::Parse(e));
174                            };
175                            normalize_otlp_json(&mut value);
176                            let request: OtlpRequest =
177                                serde_json::from_value(value).map_err(JsonIngestError::Parse)?;
178                            parsed_any = true;
179                            events.extend(crate::ingest::otlp::convert_otlp_request(&request));
180                            offset += retry.byte_offset();
181                        }
182                        // A truncated trailing document is routine on a live or
183                        // rotated Collector file-exporter dump (exporter still
184                        // writing, file rotated mid-line). Tolerate it once at
185                        // least one request parsed; mid-stream garbage (non-EOF
186                        // errors) and a truncated-only payload still fail.
187                        Some(Err(e)) if e.is_eof() && parsed_any => {
188                            tracing::warn!(
189                                "ignoring truncated trailing OTLP JSON document \
190                                 (live or rotated file-exporter dump?)"
191                            );
192                            break;
193                        }
194                        Some(Err(e)) => return Err(JsonIngestError::Parse(e)),
195                    }
196                }
197                Ok(events)
198            }
199            InputFormat::Jaeger => {
200                let ingest = crate::ingest::jaeger::JaegerIngest::new(self.max_size);
201                ingest
202                    .ingest(raw)
203                    .map_err(|e| JsonIngestError::Format(e.to_string()))
204            }
205            InputFormat::Zipkin => {
206                let ingest = crate::ingest::zipkin::ZipkinIngest::new(self.max_size);
207                ingest
208                    .ingest(raw)
209                    .map_err(|e| JsonIngestError::Format(e.to_string()))
210            }
211            InputFormat::Native => {
212                let mut events: Vec<SpanEvent> =
213                    serde_json::from_slice(raw).map_err(JsonIngestError::Parse)?;
214                // Sanitize cloud.region at the JSON ingest boundary, symmetric
215                // with the OTLP path. Invalid values (empty, > 64 bytes, non-ASCII
216                // alphanumeric plus `-`/`_`) are replaced with None to prevent
217                // log-forging through downstream tracing::debug! format strings.
218                for event in &mut events {
219                    if let Some(region) = event.cloud_region.as_deref()
220                        && !crate::score::carbon::is_valid_region_id(region)
221                    {
222                        event.cloud_region = None;
223                    }
224                    crate::event::sanitize_span_event(event);
225                }
226                Ok(events)
227            }
228        }
229    }
230}
231
232/// True for the serde error opentelemetry-proto raises on an empty-list
233/// attribute serialized the protojson way, `{"arrayValue":{}}` or
234/// `{"kvlistValue":{}}`: the derived Deserialize marks `values` required. The
235/// only proto fields named `values` are ArrayValue/KeyValueList, so this match
236/// is unambiguous and never fires on a well-formed request.
237fn is_missing_values(e: &serde_json::Error) -> bool {
238    e.classify() == serde_json::error::Category::Data
239        && e.to_string().contains("missing field `values`")
240}
241
242/// Backfill the `values` field on empty `arrayValue`/`kvlistValue` attribute
243/// values. Canonical protojson omits empty repeated fields, so `{"arrayValue":{}}`
244/// is a valid empty list, but opentelemetry-proto's derived Deserialize marks
245/// `values` required. Walks the parsed document and inserts an empty array where
246/// missing. Recursion depth is bounded by the pre-dispatch `MAX_JSON_DEPTH` cap.
247fn normalize_otlp_json(value: &mut serde_json::Value) {
248    match value {
249        serde_json::Value::Object(map) => {
250            for key in ["arrayValue", "kvlistValue"] {
251                if let Some(serde_json::Value::Object(inner)) = map.get_mut(key)
252                    && !inner.contains_key("values")
253                {
254                    inner.insert("values".to_string(), serde_json::Value::Array(Vec::new()));
255                }
256            }
257            for v in map.values_mut() {
258                normalize_otlp_json(v);
259            }
260        }
261        serde_json::Value::Array(items) => items.iter_mut().for_each(normalize_otlp_json),
262        _ => {}
263    }
264}
265
266/// Detect the format of the JSON input using lightweight byte-level heuristics.
267///
268/// Peeks at the first few kilobytes to identify the format without parsing the full
269/// payload into a `serde_json::Value`, avoiding a 2x parse cost.
270#[must_use]
271pub fn detect_format(raw: &[u8]) -> InputFormat {
272    let peek = std::str::from_utf8(&raw[..raw.len().min(1024)]).unwrap_or("");
273
274    // `{`-rooted formats are told apart STRUCTURALLY, on top-level keys
275    // only, never on whole-buffer substrings: a Jaeger export can carry
276    // the literal "resourceSpans" inside a span name or tag value (a
277    // trace OF an OTel Collector), and an OTLP request can carry "data"
278    // as an attribute key or value while always containing a nested
279    // "spans" key inside scopeSpans, so substring sniffs misroute in
280    // BOTH directions.
281    if peek.trim_start().starts_with('{') {
282        let mut saw_data_key = false;
283        for key in TopLevelKeys::new(peek) {
284            match key {
285                // OTLP/JSON: { "resourceSpans": [...] } (camelCase per the
286                // protobuf JSON mapping; the snake_case spelling routes here
287                // too so it fails with a clear serde error instead of
288                // Native's confusing "expected array").
289                "resourceSpans" | "resource_spans" => return InputFormat::Otlp,
290                // Jaeger: { "data": [{ ..., "spans": [...] }] }; the nested
291                // "spans" key is confirmed on a deeper window below.
292                "data" => saw_data_key = true,
293                _ => {}
294            }
295        }
296        if saw_data_key {
297            let deeper = std::str::from_utf8(&raw[..raw.len().min(4096)]).unwrap_or("");
298            if deeper.contains("\"spans\"") {
299                return InputFormat::Jaeger;
300            }
301        }
302    }
303
304    // Zipkin: [{ "traceId": "...", "localEndpoint": {...} }]
305    if peek.trim_start().starts_with('[')
306        && peek.contains("\"traceId\"")
307        && peek.contains("\"localEndpoint\"")
308    {
309        return InputFormat::Zipkin;
310    }
311
312    InputFormat::Native
313}
314
315/// Iterator over the keys of the ROOT JSON object inside a (possibly
316/// truncated) prefix: depth-1 strings whose next non-whitespace byte is
317/// `:`. Strings at any other depth (nested keys, attribute names) and
318/// string VALUES never qualify, which is what makes the format sniff
319/// immune to payload content. Escape-aware, stops silently when the
320/// prefix ends mid-string.
321struct TopLevelKeys<'a> {
322    bytes: &'a [u8],
323    pos: usize,
324    depth: usize,
325}
326
327impl<'a> TopLevelKeys<'a> {
328    fn new(peek: &'a str) -> Self {
329        Self {
330            bytes: peek.as_bytes(),
331            pos: 0,
332            depth: 0,
333        }
334    }
335
336    /// Scan a quoted string whose opening quote is at `self.pos`, advancing
337    /// `self.pos` past the closing quote. Returns the byte range of the
338    /// content, or `None` if the string is truncated at the end of the peek
339    /// window (escape-aware).
340    fn scan_string(&mut self) -> Option<(usize, usize)> {
341        let start = self.pos + 1;
342        let mut i = start;
343        let mut escape = false;
344        while i < self.bytes.len() {
345            let c = self.bytes[i];
346            if escape {
347                escape = false;
348            } else if c == b'\\' {
349                escape = true;
350            } else if c == b'"' {
351                break;
352            }
353            i += 1;
354        }
355        if i >= self.bytes.len() {
356            return None; // truncated mid-string at the end of the peek window
357        }
358        self.pos = i + 1;
359        Some((start, i))
360    }
361
362    /// True if the next non-whitespace byte at or after `self.pos` is `:`,
363    /// i.e. the string just scanned is an object key rather than a value.
364    fn colon_follows(&self) -> bool {
365        let mut j = self.pos;
366        while j < self.bytes.len() && self.bytes[j].is_ascii_whitespace() {
367            j += 1;
368        }
369        j < self.bytes.len() && self.bytes[j] == b':'
370    }
371}
372
373impl<'a> Iterator for TopLevelKeys<'a> {
374    type Item = &'a str;
375
376    fn next(&mut self) -> Option<&'a str> {
377        while self.pos < self.bytes.len() {
378            match self.bytes[self.pos] {
379                b'{' | b'[' => {
380                    self.depth += 1;
381                    self.pos += 1;
382                }
383                b'}' | b']' => {
384                    self.depth = self.depth.saturating_sub(1);
385                    self.pos += 1;
386                }
387                b'"' => {
388                    let (start, end) = self.scan_string()?;
389                    if self.depth == 1
390                        && self.colon_follows()
391                        && let Ok(key) = std::str::from_utf8(&self.bytes[start..end])
392                    {
393                        return Some(key);
394                    }
395                }
396                _ => self.pos += 1,
397            }
398        }
399        None
400    }
401}
402
403/// Errors that can occur during JSON ingestion.
404///
405/// `#[non_exhaustive]` for SemVer-minor variant additions.
406#[derive(Debug, thiserror::Error)]
407#[non_exhaustive]
408pub enum JsonIngestError {
409    #[error("payload too large: {size} bytes exceeds maximum of {max} bytes")]
410    PayloadTooLarge { size: usize, max: usize },
411    #[error(
412        "payload nesting exceeds maximum depth of {max_depth} (defense against deeply-nested attacker payloads)"
413    )]
414    PayloadTooDeep { max_depth: usize },
415    #[error("JSON parse error: {0}")]
416    Parse(#[from] serde_json::Error),
417    #[error("format detection error: {0}")]
418    Format(String),
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use core::assert_matches;
425
426    #[test]
427    fn rejects_oversized_payload() {
428        let ingest = JsonIngest::new(10);
429        let result = ingest.ingest(&[0u8; 100]);
430        assert!(result.is_err());
431    }
432
433    #[test]
434    fn parses_empty_array() {
435        let ingest = JsonIngest::new(1_048_576);
436        let events = ingest.ingest(b"[]").unwrap();
437        assert!(events.is_empty());
438    }
439
440    #[test]
441    fn detect_native_format() {
442        let json = r#"[{"type": "sql", "target": "SELECT 1"}]"#;
443        assert_eq!(detect_format(json.as_bytes()), InputFormat::Native);
444    }
445
446    #[test]
447    fn detect_jaeger_format() {
448        let json = r#"{"data": [{"traceID": "abc", "spans": [], "processes": {}}]}"#;
449        assert_eq!(detect_format(json.as_bytes()), InputFormat::Jaeger);
450    }
451
452    #[test]
453    fn detect_zipkin_format() {
454        let json = r#"[{"traceId": "abc", "id": "s1", "localEndpoint": {"serviceName": "svc"}}]"#;
455        assert_eq!(detect_format(json.as_bytes()), InputFormat::Zipkin);
456    }
457
458    #[test]
459    fn detect_empty_array_is_native() {
460        assert_eq!(detect_format(b"[]"), InputFormat::Native);
461    }
462
463    #[test]
464    fn detect_invalid_json_falls_to_native() {
465        assert_eq!(detect_format(b"not json"), InputFormat::Native);
466    }
467
468    #[test]
469    fn auto_ingest_jaeger() {
470        let json = r#"{
471            "data": [{
472                "traceID": "t1",
473                "spans": [{
474                    "spanID": "s1",
475                    "operationName": "op",
476                    "references": [],
477                    "startTime": 1720621921123000,
478                    "duration": 500,
479                    "processID": "p1",
480                    "tags": [
481                        {"key": "db.statement", "value": "SELECT 1"},
482                        {"key": "db.system", "value": "pg"}
483                    ]
484                }],
485                "processes": {"p1": {"serviceName": "svc"}}
486            }]
487        }"#;
488        let ingest = JsonIngest::new(1_048_576);
489        let events = ingest.ingest(json.as_bytes()).unwrap();
490        assert_eq!(events.len(), 1);
491        assert_eq!(events[0].target, "SELECT 1");
492    }
493
494    #[test]
495    fn auto_ingest_zipkin() {
496        let json = r#"[{
497            "traceId": "t1",
498            "id": "s1",
499            "name": "query",
500            "timestamp": 1720621921123000,
501            "duration": 500,
502            "localEndpoint": {"serviceName": "svc"},
503            "tags": {"db.statement": "SELECT 1", "db.system": "pg"}
504        }]"#;
505        let ingest = JsonIngest::new(1_048_576);
506        let events = ingest.ingest(json.as_bytes()).unwrap();
507        assert_eq!(events.len(), 1);
508        assert_eq!(events[0].target, "SELECT 1");
509    }
510
511    // ----- OTLP/JSON -----
512
513    /// Compact single-request OTLP/JSON body with one SQL CLIENT span.
514    fn otlp_request_json(trace_id: &str, statement: &str) -> String {
515        otlp_request_json_with_attrs(trace_id, statement, "")
516    }
517
518    /// Same span as `otlp_request_json`, with `extra_attrs` spliced into the
519    /// attributes array after db.statement/db.system. Each element must carry a
520    /// leading comma (e.g. `,{"key":..,"value":..}`); pass "" for none.
521    fn otlp_request_json_with_attrs(trace_id: &str, statement: &str, extra_attrs: &str) -> String {
522        format!(
523            r#"{{"resourceSpans":[{{"resource":{{"attributes":[{{"key":"service.name","value":{{"stringValue":"svc"}}}}]}},"scopeSpans":[{{"spans":[{{"traceId":"{trace_id}","spanId":"eee19b7ec3c1b174","name":"db-query","kind":3,"startTimeUnixNano":"1720621921000000000","endTimeUnixNano":"1720621921000500000","attributes":[{{"key":"db.statement","value":{{"stringValue":"{statement}"}}}},{{"key":"db.system","value":{{"stringValue":"postgresql"}}}}{extra_attrs}]}}]}}]}}]}}"#
524        )
525    }
526
527    #[test]
528    fn detect_otlp_format() {
529        let json = r#"{"resourceSpans": [{"scopeSpans": []}]}"#;
530        assert_eq!(detect_format(json.as_bytes()), InputFormat::Otlp);
531    }
532
533    #[test]
534    fn detect_jaeger_wins_over_stray_resource_spans_literal() {
535        // Regression: a Jaeger export can mention "resourceSpans" inside a
536        // span name or tag (e.g. a trace OF an OTel Collector). The Jaeger
537        // rule must keep winning, as it did before the OTLP sniff existed.
538        let json = r#"{
539            "data": [{
540                "traceID": "t1",
541                "spans": [{
542                    "spanID": "s1",
543                    "operationName": "export resourceSpans",
544                    "references": [],
545                    "startTime": 1720621921123000,
546                    "duration": 500,
547                    "processID": "p1",
548                    "tags": [{"key": "note", "value": "handles \"resourceSpans\" batches"}]
549                }],
550                "processes": {"p1": {"serviceName": "collector"}}
551            }]
552        }"#;
553        assert_eq!(detect_format(json.as_bytes()), InputFormat::Jaeger);
554    }
555
556    #[test]
557    fn detect_otlp_wins_over_stray_data_literal() {
558        // Regression (reverse direction): an OTLP dump whose first spans
559        // carry a "data" attribute key or value must NOT be misrouted to
560        // Jaeger. OTLP always contains a nested "spans" key (scopeSpans),
561        // so a substring rule on "data" would have flipped it.
562        let json = r#"{"resourceSpans":[{"resource":{"attributes":[{"key":"data","value":{"stringValue":"data"}}]},"scopeSpans":[{"spans":[]}]}]}"#;
563        assert_eq!(detect_format(json.as_bytes()), InputFormat::Otlp);
564    }
565
566    #[test]
567    fn top_level_keys_ignores_nested_keys_and_string_values() {
568        let json = r#"{"a": {"nested": 1}, "b": ["data", {"c": 2}], "d": "resourceSpans"}"#;
569        let keys: Vec<&str> = TopLevelKeys::new(json).collect();
570        assert_eq!(keys, ["a", "b", "d"]);
571    }
572
573    #[test]
574    fn detect_otlp_snake_case_routes_to_otlp() {
575        // snake_case is not a valid OTLP/JSON spelling (with-serde is
576        // camelCase-only), but routing it to the OTLP arm yields a clear
577        // serde error instead of Native's "expected array".
578        let json = r#"{"resource_spans": []}"#;
579        assert_eq!(detect_format(json.as_bytes()), InputFormat::Otlp);
580        let ingest = JsonIngest::new(1_048_576);
581        assert_matches!(
582            ingest.ingest(json.as_bytes()),
583            Err(JsonIngestError::Parse(_))
584        );
585    }
586
587    #[test]
588    fn auto_ingest_otlp() {
589        let json = otlp_request_json("5b8efff798038103d269b633813fc60c", "SELECT 1");
590        let ingest = JsonIngest::new(1_048_576);
591        let events = ingest.ingest(json.as_bytes()).unwrap();
592        assert_eq!(events.len(), 1);
593        assert_eq!(events[0].target, "SELECT 1");
594        assert_eq!(events[0].service.as_ref(), "svc");
595        assert_eq!(events[0].trace_id, "5b8efff798038103d269b633813fc60c");
596    }
597
598    #[test]
599    fn otlp_empty_array_value_ingests() {
600        // Canonical protojson omits empty repeated fields, so `{"arrayValue":{}}`
601        // is a valid empty-list attribute. It must not poison the batch (#81).
602        let json = otlp_request_json_with_attrs(
603            "5b8efff798038103d269b633813fc60c",
604            "SELECT 1",
605            r#",{"key":"tags","value":{"arrayValue":{}}}"#,
606        );
607        let ingest = JsonIngest::new(1_048_576);
608        let events = ingest.ingest(json.as_bytes()).unwrap();
609        assert_eq!(events.len(), 1);
610        assert_eq!(events[0].target, "SELECT 1");
611    }
612
613    #[test]
614    fn otlp_empty_kvlist_value_ingests() {
615        // `{"kvlistValue":{}}` omits `values` identically and must also parse.
616        let json = otlp_request_json_with_attrs(
617            "5b8efff798038103d269b633813fc60c",
618            "SELECT 1",
619            r#",{"key":"meta","value":{"kvlistValue":{}}}"#,
620        );
621        let ingest = JsonIngest::new(1_048_576);
622        let events = ingest.ingest(json.as_bytes()).unwrap();
623        assert_eq!(events.len(), 1);
624        assert_eq!(events[0].target, "SELECT 1");
625    }
626
627    #[test]
628    fn normalize_fills_missing_array_values() {
629        // The issue's "attribute reads as an empty list" assertion. It is not
630        // observable on SpanEvent (list attributes are not lifted), so assert it
631        // at the value level: after normalization, `{"arrayValue":{}}` becomes a
632        // valid AnyValue holding an empty ArrayValue.
633        use opentelemetry_proto::tonic::common::v1::{AnyValue, any_value};
634        let mut value: serde_json::Value = serde_json::from_str(r#"{"arrayValue":{}}"#).unwrap();
635        normalize_otlp_json(&mut value);
636        let any: AnyValue = serde_json::from_value(value).unwrap();
637        let Some(any_value::Value::ArrayValue(av)) = any.value else {
638            panic!("expected ArrayValue variant");
639        };
640        assert!(av.values.is_empty());
641    }
642
643    #[test]
644    fn otlp_issue_81_repro_line_ingests() {
645        // The exact repro from #81: a lone SERVER span whose only attribute is
646        // an empty arrayValue. The span is filtered for lacking http.url, so no
647        // events are produced, but ingest must not error on the empty arrayValue.
648        let json = r#"{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"svc-a"}}]},"scopeSpans":[{"scope":{"name":"repro"},"spans":[{"traceId":"5b8efff798038103d269b633813fc60c","spanId":"eee19b7ec3c1b174","name":"GET /x","kind":2,"startTimeUnixNano":"1783678644000000000","endTimeUnixNano":"1783678644100000000","attributes":[{"key":"empty.list","value":{"arrayValue":{}}}]}]}]}]}"#;
649        let ingest = JsonIngest::new(1_048_576);
650        assert!(ingest.ingest(json.as_bytes()).is_ok());
651    }
652
653    #[test]
654    fn otlp_empty_array_value_mid_ndjson_continues() {
655        // The lenient retry must advance past the patched document and keep
656        // parsing the rest of the stream, not stop at the first empty list.
657        let line1 = otlp_request_json_with_attrs(
658            "0af7651916cd43dd8448eb211c80319c",
659            "SELECT 1",
660            r#",{"key":"tags","value":{"arrayValue":{}}}"#,
661        );
662        let line2 = otlp_request_json("1bf7651916cd43dd8448eb211c80319d", "SELECT 2");
663        let json = format!("{line1}\n{line2}\n");
664        let ingest = JsonIngest::new(1_048_576);
665        let events = ingest.ingest(json.as_bytes()).unwrap();
666        assert_eq!(events.len(), 2);
667        assert_eq!(events[0].target, "SELECT 1");
668        assert_eq!(events[1].target, "SELECT 2");
669    }
670
671    #[test]
672    fn otlp_type_wrong_truncated_tail_still_fails() {
673        // A trailing document that is both truncated and type-wrong is not the
674        // benign truncated-tail case: the strict typed parser rejects the wrong
675        // type before EOF, so the batch must fail rather than silently drop it.
676        let full = otlp_request_json("0af7651916cd43dd8448eb211c80319c", "SELECT 1");
677        let json = format!("{full}\n{{\"resourceSpans\":123");
678        let ingest = JsonIngest::new(1_048_576);
679        assert_matches!(
680            ingest.ingest(json.as_bytes()),
681            Err(JsonIngestError::Parse(_))
682        );
683    }
684
685    #[test]
686    fn auto_ingest_otlp_ndjson() {
687        // Collector file-exporter shape: one request per line.
688        let json = format!(
689            "{}\n{}\n",
690            otlp_request_json("0af7651916cd43dd8448eb211c80319c", "SELECT 1"),
691            otlp_request_json("1bf7651916cd43dd8448eb211c80319d", "SELECT 2"),
692        );
693        let ingest = JsonIngest::new(1_048_576);
694        let events = ingest.ingest(json.as_bytes()).unwrap();
695        assert_eq!(events.len(), 2);
696        assert_eq!(events[0].target, "SELECT 1");
697        assert_eq!(events[1].target, "SELECT 2");
698    }
699
700    #[test]
701    fn auto_ingest_otlp_ndjson_tolerates_truncated_tail() {
702        // A live or rotated Collector file-exporter dump routinely ends on
703        // a partially-written line: keep the parsed requests, warn, and do
704        // not fail the whole batch.
705        let full = otlp_request_json("0af7651916cd43dd8448eb211c80319c", "SELECT 1");
706        let truncated = &full[..full.len() / 2];
707        let json = format!("{full}\n{truncated}");
708        let ingest = JsonIngest::new(1_048_576);
709        let events = ingest.ingest(json.as_bytes()).unwrap();
710        assert_eq!(events.len(), 1);
711        assert_eq!(events[0].target, "SELECT 1");
712    }
713
714    #[test]
715    fn auto_ingest_otlp_truncated_only_payload_still_fails() {
716        // With zero complete requests there is nothing to salvage: the
717        // parse error must surface, not an empty success.
718        let full = otlp_request_json("0af7651916cd43dd8448eb211c80319c", "SELECT 1");
719        let truncated = &full[..full.len() / 2];
720        let ingest = JsonIngest::new(1_048_576);
721        assert_matches!(
722            ingest.ingest(truncated.as_bytes()),
723            Err(JsonIngestError::Parse(_))
724        );
725    }
726
727    #[test]
728    fn auto_ingest_otlp_mid_stream_garbage_still_fails() {
729        // Non-EOF errors (malformed document between valid ones) are not
730        // the truncated-tail case and must abort the ingest.
731        let full = otlp_request_json("0af7651916cd43dd8448eb211c80319c", "SELECT 1");
732        let json = format!("{full}\n{{\"resourceSpans\": 42}}\n{full}");
733        let ingest = JsonIngest::new(1_048_576);
734        assert_matches!(
735            ingest.ingest(json.as_bytes()),
736            Err(JsonIngestError::Parse(_))
737        );
738    }
739
740    #[test]
741    fn auto_ingest_otlp_pretty_printed_single_object() {
742        // A pretty-printed request spans many lines; the stream
743        // deserializer must not treat it as broken NDJSON.
744        let compact = otlp_request_json("5b8efff798038103d269b633813fc60c", "SELECT 1");
745        let value: serde_json::Value = serde_json::from_str(&compact).unwrap();
746        let pretty = serde_json::to_string_pretty(&value).unwrap();
747        let ingest = JsonIngest::new(1_048_576);
748        let events = ingest.ingest(pretty.as_bytes()).unwrap();
749        assert_eq!(events.len(), 1);
750        assert_eq!(events[0].target, "SELECT 1");
751    }
752
753    #[test]
754    fn deeply_nested_otlp_payload_is_rejected() {
755        // Same guard as Jaeger/Zipkin: nesting via attribute arrayValue
756        // must trip the pre-dispatch depth cap, not serde's 128 default.
757        let depth = MAX_JSON_DEPTH + 4;
758        let mut payload = String::from(
759            r#"{"resourceSpans":[{"scopeSpans":[{"spans":[{"attributes":[{"key":"a","value":{"arrayValue":{"values":["#,
760        );
761        for _ in 0..depth {
762            payload.push('[');
763        }
764        for _ in 0..depth {
765            payload.push(']');
766        }
767        payload.push_str("]}}}]}]}]}]}");
768        let ingest = JsonIngest::new(1_048_576);
769        let result = ingest.ingest(payload.as_bytes());
770        assert!(
771            matches!(result, Err(JsonIngestError::PayloadTooDeep { .. })),
772            "deeply-nested OTLP input must be rejected: {result:?}"
773        );
774    }
775
776    // ----- Sanitize cloud_region on native JSON path -----
777
778    fn native_event_with_cloud_region(cloud_region: &str) -> String {
779        format!(
780            r#"[{{
781                "timestamp": "2025-07-10T14:32:01.123Z",
782                "trace_id": "trace-1",
783                "span_id": "span-1",
784                "service": "order-svc",
785                "cloud_region": {cr},
786                "type": "sql",
787                "operation": "SELECT",
788                "target": "SELECT 1",
789                "duration_us": 1000,
790                "source": {{
791                    "endpoint": "POST /api/orders/42/submit",
792                    "method": "OrderService::create_order"
793                }}
794            }}]"#,
795            cr = serde_json::to_string(cloud_region).unwrap()
796        )
797    }
798
799    #[test]
800    fn native_json_valid_cloud_region_preserved() {
801        // Valid region names round-trip intact.
802        let json = native_event_with_cloud_region("eu-west-3");
803        let ingest = JsonIngest::new(1_048_576);
804        let events = ingest.ingest(json.as_bytes()).unwrap();
805        assert_eq!(events.len(), 1);
806        assert_eq!(events[0].cloud_region.as_deref(), Some("eu-west-3"));
807    }
808
809    #[test]
810    fn native_json_invalid_cloud_region_is_sanitized_to_none() {
811        // A malicious client on the JSON socket trying to log-forge via
812        // a newline in cloud_region must have the value replaced with None,
813        // symmetric with the OTLP boundary sanitization.
814        let json = native_event_with_cloud_region("eu-west-3\n2026 WARN fake alert");
815        let ingest = JsonIngest::new(1_048_576);
816        let events = ingest.ingest(json.as_bytes()).unwrap();
817        assert_eq!(events.len(), 1);
818        assert!(
819            events[0].cloud_region.is_none(),
820            "cloud_region with control char must be sanitized"
821        );
822    }
823
824    #[test]
825    fn native_json_oversized_cloud_region_sanitized() {
826        // 65 chars exceeds the 64-byte cap.
827        let long_region = "a".repeat(65);
828        let json = native_event_with_cloud_region(&long_region);
829        let ingest = JsonIngest::new(1_048_576);
830        let events = ingest.ingest(json.as_bytes()).unwrap();
831        assert!(events[0].cloud_region.is_none());
832    }
833
834    #[test]
835    fn native_json_cloud_region_with_space_sanitized() {
836        let json = native_event_with_cloud_region("eu west 3");
837        let ingest = JsonIngest::new(1_048_576);
838        let events = ingest.ingest(json.as_bytes()).unwrap();
839        assert!(events[0].cloud_region.is_none());
840    }
841
842    #[test]
843    fn native_json_cloud_region_with_dot_sanitized() {
844        // Dot is not in the allowlist (prevents path-traversal-style tricks).
845        let json = native_event_with_cloud_region("eu.west.3");
846        let ingest = JsonIngest::new(1_048_576);
847        let events = ingest.ingest(json.as_bytes()).unwrap();
848        assert!(events[0].cloud_region.is_none());
849    }
850
851    #[test]
852    fn deeply_nested_native_payload_is_rejected_below_stack_overflow() {
853        // Build `[[[[...]]]]` with depth above `MAX_JSON_DEPTH`. The
854        // pre-scan guard must reject before serde_json walks the tree.
855        let depth = MAX_JSON_DEPTH + 4;
856        let mut payload = String::with_capacity(depth * 2);
857        for _ in 0..depth {
858            payload.push('[');
859        }
860        for _ in 0..depth {
861            payload.push(']');
862        }
863        let ingest = JsonIngest::new(1_048_576);
864        let result = ingest.ingest(payload.as_bytes());
865        assert_matches!(result, Err(JsonIngestError::PayloadTooDeep { .. }));
866    }
867
868    #[test]
869    fn deeply_nested_jaeger_payload_is_rejected() {
870        // Pre-0.5.15 only the Native arm enforced MAX_JSON_DEPTH. A Jaeger
871        // payload with 33+ frames of nesting would slip through to
872        // JaegerIngest and rely on serde_json's looser 128-frame default.
873        let depth = MAX_JSON_DEPTH + 4;
874        let mut payload = String::from(r#"{"data":[{"spans":[{"tags":["#);
875        for _ in 0..depth {
876            payload.push('[');
877        }
878        for _ in 0..depth {
879            payload.push(']');
880        }
881        payload.push_str("]}]}]}");
882        let ingest = JsonIngest::new(1_048_576);
883        let result = ingest.ingest(payload.as_bytes());
884        assert!(
885            matches!(result, Err(JsonIngestError::PayloadTooDeep { .. })),
886            "deeply-nested Jaeger input must be rejected: {result:?}"
887        );
888    }
889
890    #[test]
891    fn deeply_nested_zipkin_payload_is_rejected() {
892        // Symmetric guard for the Zipkin v2 path.
893        let depth = MAX_JSON_DEPTH + 4;
894        let mut payload = String::from(
895            r#"[{"traceId":"abc","localEndpoint":{"serviceName":"s"},"annotations":["#,
896        );
897        for _ in 0..depth {
898            payload.push('[');
899        }
900        for _ in 0..depth {
901            payload.push(']');
902        }
903        payload.push_str("]}]");
904        let ingest = JsonIngest::new(1_048_576);
905        let result = ingest.ingest(payload.as_bytes());
906        assert!(
907            matches!(result, Err(JsonIngestError::PayloadTooDeep { .. })),
908            "deeply-nested Zipkin input must be rejected: {result:?}"
909        );
910    }
911
912    // Boundary tests for the 32-frame depth cap. The cap rejects when
913    // peak nesting strictly exceeds 32 (`*depth > MAX_JSON_DEPTH`), so
914    // peak = 32 is OK and peak = 33 fails. The depth-31 / depth-33 pair
915    // skips the ambiguous boundary at peak = 32 to keep the assertions
916    // robust if the cap is ever adjusted by one frame.
917
918    #[test]
919    fn native_ingest_accepts_input_at_depth_31() {
920        // Native: array-of-arrays, peak depth = number of `[` brackets.
921        let mut payload = String::with_capacity(64);
922        for _ in 0..31 {
923            payload.push('[');
924        }
925        for _ in 0..31 {
926            payload.push(']');
927        }
928        let ingest = JsonIngest::new(1_048_576);
929        let result = ingest.ingest(payload.as_bytes());
930        assert!(
931            !matches!(result, Err(JsonIngestError::PayloadTooDeep { .. })),
932            "depth 31 must not be rejected by the depth guard, got: {result:?}"
933        );
934    }
935
936    #[test]
937    fn native_ingest_rejects_input_at_depth_33() {
938        let mut payload = String::with_capacity(68);
939        for _ in 0..33 {
940            payload.push('[');
941        }
942        for _ in 0..33 {
943            payload.push(']');
944        }
945        let ingest = JsonIngest::new(1_048_576);
946        assert_matches!(
947            ingest.ingest(payload.as_bytes()),
948            Err(JsonIngestError::PayloadTooDeep { .. })
949        );
950    }
951
952    #[test]
953    fn jaeger_ingest_accepts_input_at_depth_31() {
954        // Jaeger wrapper `{"data":[{"spans":[{"tags":[ ... ]}]}]}` reaches
955        // peak 6 before the inner brackets. Inner depth 25 yields peak 31.
956        let inner = 25;
957        let mut payload = String::from(r#"{"data":[{"spans":[{"tags":["#);
958        for _ in 0..inner {
959            payload.push('[');
960        }
961        for _ in 0..inner {
962            payload.push(']');
963        }
964        payload.push_str("]}]}]}");
965        let ingest = JsonIngest::new(1_048_576);
966        let result = ingest.ingest(payload.as_bytes());
967        assert!(
968            !matches!(result, Err(JsonIngestError::PayloadTooDeep { .. })),
969            "Jaeger depth 31 must not be rejected by the depth guard, got: {result:?}"
970        );
971    }
972
973    #[test]
974    fn jaeger_ingest_rejects_input_at_depth_33() {
975        // Inner depth 27 yields peak 33 (6 wrapper + 27 inner).
976        let inner = 27;
977        let mut payload = String::from(r#"{"data":[{"spans":[{"tags":["#);
978        for _ in 0..inner {
979            payload.push('[');
980        }
981        for _ in 0..inner {
982            payload.push(']');
983        }
984        payload.push_str("]}]}]}");
985        let ingest = JsonIngest::new(1_048_576);
986        assert_matches!(
987            ingest.ingest(payload.as_bytes()),
988            Err(JsonIngestError::PayloadTooDeep { .. })
989        );
990    }
991
992    #[test]
993    fn zipkin_ingest_accepts_input_at_depth_31() {
994        // Zipkin wrapper `[{"traceId":...,"localEndpoint":{...},"annotations":[...]}]`
995        // reaches peak 3 before the inner brackets. Inner depth 28 yields peak 31.
996        let inner = 28;
997        let mut payload = String::from(
998            r#"[{"traceId":"abc","localEndpoint":{"serviceName":"s"},"annotations":["#,
999        );
1000        for _ in 0..inner {
1001            payload.push('[');
1002        }
1003        for _ in 0..inner {
1004            payload.push(']');
1005        }
1006        payload.push_str("]}]");
1007        let ingest = JsonIngest::new(1_048_576);
1008        let result = ingest.ingest(payload.as_bytes());
1009        assert!(
1010            !matches!(result, Err(JsonIngestError::PayloadTooDeep { .. })),
1011            "Zipkin depth 31 must not be rejected by the depth guard, got: {result:?}"
1012        );
1013    }
1014
1015    #[test]
1016    fn zipkin_ingest_rejects_input_at_depth_33() {
1017        // Inner depth 30 yields peak 33 (3 wrapper + 30 inner).
1018        let inner = 30;
1019        let mut payload = String::from(
1020            r#"[{"traceId":"abc","localEndpoint":{"serviceName":"s"},"annotations":["#,
1021        );
1022        for _ in 0..inner {
1023            payload.push('[');
1024        }
1025        for _ in 0..inner {
1026            payload.push(']');
1027        }
1028        payload.push_str("]}]");
1029        let ingest = JsonIngest::new(1_048_576);
1030        assert_matches!(
1031            ingest.ingest(payload.as_bytes()),
1032            Err(JsonIngestError::PayloadTooDeep { .. })
1033        );
1034    }
1035
1036    #[test]
1037    fn depth_scan_ignores_brackets_inside_strings() {
1038        // A valid native event whose `target` field contains `[[[...`.
1039        // The pre-scan must not count those brackets, otherwise it
1040        // would falsely reject SQL queries like `WHERE id IN (...)` or
1041        // template strings.
1042        let json = native_event_with_cloud_region("eu-west-3").replace(
1043            "\"SELECT 1\"",
1044            "\"SELECT * FROM t WHERE col = '[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]'\"",
1045        );
1046        let ingest = JsonIngest::new(1_048_576);
1047        let events = ingest
1048            .ingest(json.as_bytes())
1049            .expect("string-internal brackets must not trigger the depth guard");
1050        assert_eq!(events.len(), 1);
1051    }
1052}