Skip to main content

cortex_ingest_core/
metadata.rs

1//! Bounded and redacted JSON metadata helpers for untrusted ingest input.
2
3use serde_json::{Map, Value};
4
5/// Maximum encoded metadata size before the payload is replaced by a truncation marker.
6pub const MAX_METADATA_JSON_BYTES: usize = 64 * 1024;
7/// Maximum number of Unicode scalar values retained from a metadata string.
8pub const MAX_METADATA_STRING_CHARS: usize = 2048;
9/// Maximum number of Unicode scalar values retained from a metadata key.
10pub const MAX_METADATA_KEY_CHARS: usize = 128;
11/// Maximum number of elements retained from a metadata object or array.
12pub const MAX_METADATA_OBJECT_FIELDS: usize = 128;
13
14const REDACTED: &str = "[REDACTED]";
15
16/// Sanitize and encode metadata, replacing oversized results with a valid JSON truncation marker.
17pub fn bounded_metadata_json(value: Value) -> String {
18    let value = sanitize_value(value, None);
19    let encoded = value.to_string();
20    if encoded.len() <= MAX_METADATA_JSON_BYTES {
21        return encoded;
22    }
23
24    let source_type = value
25        .get("source_type")
26        .and_then(Value::as_str)
27        .map(str::to_string);
28    serde_json::json!({
29        "source_type": source_type,
30        "metadata_truncated": true,
31        "metadata_original_bytes": encoded.len(),
32    })
33    .to_string()
34}
35
36/// Like [`bounded_metadata_json`], but refuses to truncate: returns `None`
37/// when the sanitized encoding exceeds [`MAX_METADATA_JSON_BYTES`], so
38/// callers whose metadata carries load-bearing keys (e.g. the extracted
39/// `agent_docker` identity) can back out instead of losing them.
40pub fn try_bounded_metadata_json(value: Value) -> Option<String> {
41    let encoded = sanitize_value(value, None).to_string();
42    (encoded.len() <= MAX_METADATA_JSON_BYTES).then_some(encoded)
43}
44
45/// Build a sanitized JSON object from key/value attributes while enforcing field limits.
46pub fn attrs_to_metadata_object<'a, I>(attrs: I) -> Value
47where
48    I: IntoIterator<Item = (&'a str, Value)>,
49{
50    let mut object = Map::new();
51    let mut omitted = 0usize;
52    for (key, value) in attrs {
53        if object.len() >= MAX_METADATA_OBJECT_FIELDS {
54            omitted += 1;
55            continue;
56        }
57        let stored_key = truncate_chars(key, MAX_METADATA_KEY_CHARS);
58        object.insert(stored_key, sanitize_value(value, Some(key)));
59    }
60    if omitted > 0 {
61        object.insert("_omitted_fields".to_string(), Value::Number(omitted.into()));
62    }
63    Value::Object(object)
64}
65
66fn sanitize_value(value: Value, key: Option<&str>) -> Value {
67    if key.is_some_and(is_sensitive_key) {
68        return Value::String(REDACTED.to_string());
69    }
70
71    match value {
72        Value::String(value) => Value::String(truncate_chars(&value, MAX_METADATA_STRING_CHARS)),
73        Value::Array(values) => Value::Array(
74            values
75                .into_iter()
76                .take(MAX_METADATA_OBJECT_FIELDS)
77                .map(|value| sanitize_value(value, key))
78                .collect(),
79        ),
80        Value::Object(values) => sanitize_object(values),
81        value => value,
82    }
83}
84
85fn sanitize_object(values: Map<String, Value>) -> Value {
86    let mut object = Map::new();
87    let mut omitted = 0usize;
88    for (key, value) in values {
89        if object.len() >= MAX_METADATA_OBJECT_FIELDS {
90            omitted += 1;
91            continue;
92        }
93        let stored_key = truncate_chars(&key, MAX_METADATA_KEY_CHARS);
94        object.insert(stored_key, sanitize_value(value, Some(&key)));
95    }
96    if omitted > 0 {
97        object.insert("_omitted_fields".to_string(), Value::Number(omitted.into()));
98    }
99    Value::Object(object)
100}
101
102fn is_sensitive_key(key: &str) -> bool {
103    let normalized = key.to_ascii_lowercase();
104    [
105        "authorization",
106        "bearer",
107        "cookie",
108        "credential",
109        "password",
110        "private_key",
111        "secret",
112        "set-cookie",
113        "token",
114    ]
115    .iter()
116    .any(|needle| normalized.contains(needle))
117        || normalized.contains("api_key")
118        || normalized.contains("apikey")
119        || normalized.contains("access_key")
120}
121
122fn truncate_chars(value: &str, max: usize) -> String {
123    let mut out = String::with_capacity(value.len().min(max));
124    for (idx, ch) in value.chars().enumerate() {
125        if idx >= max {
126            out.push_str("...[truncated]");
127            return out;
128        }
129        out.push(ch);
130    }
131    out
132}
133
134#[cfg(test)]
135#[path = "metadata_tests.rs"]
136mod tests;