Skip to main content

faucet_core/
util.rs

1//! Shared utilities used across faucet source and sink crates.
2
3use std::collections::HashMap;
4
5use crate::FaucetError;
6use jsonpath_rust::JsonPath;
7use serde_json::Value;
8
9// ── SQL Utilities ───────────────────────────────────────────────────────────
10
11/// Quote a SQL identifier to prevent SQL injection.
12///
13/// Wraps the name in double quotes and doubles any embedded double-quotes
14/// per the SQL standard (ANSI SQL).
15///
16/// ```
17/// use faucet_core::util::quote_ident;
18/// assert_eq!(quote_ident("my_table"), "\"my_table\"");
19/// assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
20/// ```
21pub fn quote_ident(name: &str) -> String {
22    format!("\"{}\"", name.replace('"', "\"\""))
23}
24
25// ── JSONPath Extraction ─────────────────────────────────────────────────────
26
27/// Extract records from a JSON value using an optional JSONPath expression.
28///
29/// - If `path` is `Some`, queries the body with the JSONPath and returns
30///   all matched values.
31/// - If `path` is `None`, returns the body as-is: arrays are unpacked into
32///   individual records, objects/scalars are returned as a single-element vec.
33pub fn extract_records(body: &Value, path: Option<&str>) -> Result<Vec<Value>, FaucetError> {
34    match path {
35        Some(p) => {
36            let results = body
37                .query(p)
38                .map_err(|e| FaucetError::JsonPath(format!("invalid JSONPath '{p}': {e}")))?;
39            Ok(results.into_iter().cloned().collect())
40        }
41        None => match body {
42            Value::Array(arr) => Ok(arr.clone()),
43            other => Ok(vec![other.clone()]),
44        },
45    }
46}
47
48// ── HTTP Response Handling ──────────────────────────────────────────────────
49
50/// Check an HTTP response status and return a [`FaucetError::HttpStatus`] on
51/// non-success responses.
52///
53/// Reads the response body for error context, truncating to `max_body_len`
54/// bytes (default: 2048) to avoid large error messages.
55pub async fn check_http_response(
56    resp: reqwest::Response,
57    max_body_len: usize,
58) -> Result<reqwest::Response, FaucetError> {
59    if resp.status().is_success() {
60        return Ok(resp);
61    }
62
63    let status = resp.status().as_u16();
64    let url = resp.url().to_string();
65    let body_text = resp.text().await.unwrap_or_default();
66
67    let body = if body_text.len() > max_body_len {
68        let end = body_text.floor_char_boundary(max_body_len);
69        format!("{}...(truncated)", &body_text[..end])
70    } else {
71        body_text
72    };
73
74    Err(FaucetError::HttpStatus { status, url, body })
75}
76
77/// Default maximum body length for error responses.
78pub const DEFAULT_ERROR_BODY_MAX_LEN: usize = 2048;
79
80// ── Context Utilities ──────────────────────────────────────────────────────
81
82/// Substitute `{key}` placeholders in a template string with values from context.
83///
84/// Value conversion rules:
85/// - `String` -> raw string (no quotes)
86/// - `Number` -> number as string
87/// - `Bool` -> `"true"` / `"false"`
88/// - `Null` -> `"null"`
89/// - `Array` / `Object` -> JSON-serialized string
90///
91/// Unmatched placeholders are left as-is.
92///
93/// **Warning:** Do NOT use this for SQL queries (SQL injection risk) or for
94/// substitution into serialized JSON (corruption risk with special characters).
95/// Use [`substitute_context_bind_params`] for SQL and [`substitute_context_json`]
96/// for serialized JSON.
97pub fn substitute_context(template: &str, context: &HashMap<String, Value>) -> String {
98    substitute_single_pass(template, context, |value| match value {
99        Value::String(s) => s.clone(),
100        Value::Number(n) => n.to_string(),
101        Value::Bool(b) => b.to_string(),
102        Value::Null => "null".to_string(),
103        other => other.to_string(),
104    })
105}
106
107/// Single left-to-right scan that replaces each recognised `{key}` placeholder
108/// with `render(value)`. Unmatched placeholders are left verbatim; replacement
109/// text is never re-scanned. Shared by [`substitute_context`] and
110/// [`substitute_context_json`] so neither is O(template × context) (#78/#36).
111fn substitute_single_pass(
112    template: &str,
113    context: &HashMap<String, Value>,
114    render: impl Fn(&Value) -> String,
115) -> String {
116    if context.is_empty() {
117        return template.to_string();
118    }
119    let mut result = String::with_capacity(template.len());
120    let mut last_copied = 0;
121    let mut search_from = 0;
122
123    while search_from < template.len() {
124        let Some(open_offset) = template[search_from..].find('{') else {
125            break;
126        };
127        let open = search_from + open_offset;
128        let Some(close_offset) = template[open + 1..].find('}') else {
129            break;
130        };
131        let close = open + 1 + close_offset;
132        let key = &template[open + 1..close];
133
134        if let Some(value) = context.get(key) {
135            result.push_str(&template[last_copied..open]);
136            result.push_str(&render(value));
137            last_copied = close + 1;
138            search_from = close + 1;
139        } else {
140            search_from = open + 1;
141        }
142    }
143
144    result.push_str(&template[last_copied..]);
145    result
146}
147
148/// Replace `{key}` placeholders with SQL bind-parameter markers, returning
149/// the rewritten query and an ordered list of values to bind.
150///
151/// Scans the template left-to-right; each recognised placeholder is replaced
152/// with the marker produced by `marker_fn(index)`, and the corresponding
153/// value is appended to the returned vector.  The same key appearing multiple
154/// times produces one bind value per occurrence.
155///
156/// `start_index` is the 1-based index for the first parameter.
157///
158/// # Marker functions
159///
160/// - PostgreSQL: `|i| format!("${i}")`
161/// - MySQL / SQLite: `|_| "?".to_string()`
162///
163/// Placeholders whose key is not present in `context` are left unchanged.
164pub fn substitute_context_bind_params(
165    template: &str,
166    context: &HashMap<String, Value>,
167    start_index: usize,
168    marker_fn: impl Fn(usize) -> String,
169) -> (String, Vec<Value>) {
170    if context.is_empty() {
171        return (template.to_string(), Vec::new());
172    }
173
174    let mut result = String::with_capacity(template.len());
175    let mut values = Vec::new();
176    let mut param_idx = start_index;
177    let mut last_copied = 0;
178    let mut search_from = 0;
179
180    while search_from < template.len() {
181        let Some(open_offset) = template[search_from..].find('{') else {
182            break;
183        };
184        let open = search_from + open_offset;
185
186        let Some(close_offset) = template[open + 1..].find('}') else {
187            break;
188        };
189        let close = open + 1 + close_offset;
190        let key = &template[open + 1..close];
191
192        if let Some(value) = context.get(key) {
193            result.push_str(&template[last_copied..open]);
194            result.push_str(&marker_fn(param_idx));
195            values.push(value.clone());
196            param_idx += 1;
197            last_copied = close + 1;
198            search_from = close + 1;
199        } else {
200            search_from = open + 1;
201        }
202    }
203
204    result.push_str(&template[last_copied..]);
205    (result, values)
206}
207
208/// Substitute `{key}` placeholders within a serialized JSON string, escaping
209/// string values so that the result remains valid JSON.
210///
211/// Use this instead of [`substitute_context`] when the template is a
212/// `serde_json`-serialized value that will be deserialized back after
213/// substitution.  String values are JSON-escaped (double-quotes, backslashes,
214/// and control characters).  Numbers, bools, and null are substituted as-is.
215pub fn substitute_context_json(template: &str, context: &HashMap<String, Value>) -> String {
216    substitute_single_pass(template, context, |value| match value {
217        Value::String(s) => json_escape_string(s),
218        Value::Number(n) => n.to_string(),
219        Value::Bool(b) => b.to_string(),
220        Value::Null => "null".to_string(),
221        other => other.to_string(),
222    })
223}
224
225/// Escape a string for safe embedding inside a JSON string value.
226///
227/// Handles double-quotes, backslashes, and control characters per RFC 8259.
228fn json_escape_string(s: &str) -> String {
229    let mut escaped = String::with_capacity(s.len());
230    for c in s.chars() {
231        match c {
232            '"' => escaped.push_str("\\\""),
233            '\\' => escaped.push_str("\\\\"),
234            '\n' => escaped.push_str("\\n"),
235            '\r' => escaped.push_str("\\r"),
236            '\t' => escaped.push_str("\\t"),
237            c if c.is_control() => {
238                escaped.push_str(&format!("\\u{:04x}", c as u32));
239            }
240            c => escaped.push(c),
241        }
242    }
243    escaped
244}
245
246/// Strip credentials from a connection string so it can be used as a lineage
247/// dataset URI without leaking secrets. Handles two shapes, best-effort:
248///
249/// - **URL userinfo** — `scheme://user:pass@host/...` → `scheme://host/...`
250///   (the `user[:pass]@` between `://` and the authority terminator is removed).
251/// - **Key/value (ADO.NET) connection strings** — any `Password=...` / `Pwd=...`
252///   segment (case-insensitive key) has its value replaced with `***`.
253///
254/// Input with neither shape is returned unchanged.
255pub fn redact_uri_credentials(uri: &str) -> String {
256    let mut out = uri.to_string();
257    // 1) URL userinfo: `scheme://user:pass@host/...` → `scheme://host/...`.
258    //
259    // A naive "first '/' or '?' terminates the authority, first '@' delimits
260    // userinfo" scan LEAKS passwords that contain '/', '?' or '@' (very common
261    // in unencoded DB connection strings): the early terminator truncates the
262    // authority before the real '@', and the first '@' splits inside the
263    // password. Since a host/port never contains '@', the userinfo→host
264    // delimiter is the LAST '@' whose following host segment (up to the next
265    // '/', '?' or '#') is a non-empty, host-shaped token. Picking that '@'
266    // tolerates arbitrary '/', '?' and '@' inside the password.
267    if let Some(scheme_end) = out.find("://") {
268        let after = scheme_end + 3;
269        let tail = &out[after..];
270        let delim = tail
271            .char_indices()
272            .rev()
273            .find(|&(at, c)| {
274                c == '@' && {
275                    let host = &tail[at + 1..];
276                    let host_end = host.find(['/', '?', '#']).unwrap_or(host.len());
277                    let host = &host[..host_end];
278                    !host.is_empty() && !host.contains('@') && is_host_shaped(host)
279                }
280            })
281            .map(|(at, _)| at);
282        if let Some(at) = delim {
283            // Remove "user:pass@" inclusive of the '@'.
284            out.replace_range(after..after + at + 1, "");
285        }
286    }
287    // 2) ADO.NET-style and query-string secret tokens. Splitting on both
288    //    ';' (ADO.NET) and '&'/'?' (URL query) catches a secret carried as a
289    //    query parameter (`...?token=secret`) as well as keyword form. The key
290    //    denylist covers the common secret parameter names, not just
291    //    password/pwd (audit #321 M11): a leaked `?api_key=…` or SAS `?sig=…`
292    //    was previously passed through verbatim into lineage / catalog output.
293    if out.contains('=') {
294        out = out
295            .split_inclusive([';', '&', '?'])
296            .map(|seg| {
297                // Preserve any trailing delimiter the split kept on the segment.
298                let (body, delim) = match seg.char_indices().next_back() {
299                    Some((i, ';' | '&' | '?')) => (&seg[..i], &seg[i..]),
300                    _ => (seg, ""),
301                };
302                match body.find('=') {
303                    Some(eq) if is_secret_kv_key(&body[..eq]) => {
304                        format!("{}=***{delim}", &body[..eq])
305                    }
306                    _ => seg.to_string(),
307                }
308            })
309            .collect::<String>();
310    }
311    out
312}
313
314/// Whether a connection-string / query-string key names a credential whose
315/// value must be redacted from a lineage / catalog URI.
316///
317/// Matches a denylist case-insensitively after stripping `-`/`_`/space, so
318/// `api_key`, `api-key`, and `apikey` all match one entry. Covers the common
319/// secret parameter names beyond `password`/`pwd` (audit #321 M11).
320fn is_secret_kv_key(key: &str) -> bool {
321    let norm: String = key
322        .trim()
323        .chars()
324        .filter(|c| !matches!(c, '-' | '_' | ' '))
325        .flat_map(char::to_lowercase)
326        .collect();
327    matches!(
328        norm.as_str(),
329        "password"
330            | "pwd"
331            | "token"
332            | "apikey"
333            | "accesstoken"
334            | "refreshtoken"
335            | "sessiontoken"
336            | "clientsecret"
337            | "secret"
338            | "secretkey"
339            | "accesskey"
340            | "accesskeyid"
341            | "secretaccesskey"
342            | "sig"
343            | "sas"
344            | "accountkey"
345            | "sharedaccesskey"
346            | "authorization"
347            | "auth"
348    )
349}
350
351/// Best-effort check that a string looks like a `host[:port]` authority — used
352/// to identify the userinfo→host `@` delimiter when redacting credentials.
353/// Accepts letters, digits, `.`, `-`, `:`, `_`, and bracketed IPv6 forms.
354fn is_host_shaped(s: &str) -> bool {
355    s.bytes().all(|b| {
356        b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'_' | b'[' | b']' | b'%')
357    })
358}
359
360/// Extract context values from a record using JSONPath expressions.
361///
362/// Each entry in `mapping` is `context_key -> json_path`. The function queries
363/// the record with each JSONPath and stores the first matched value under the
364/// corresponding context key.
365///
366/// Returns an error if any JSONPath matches nothing.
367pub fn extract_context(
368    record: &Value,
369    mapping: &HashMap<String, String>,
370) -> Result<HashMap<String, Value>, FaucetError> {
371    let mut context = HashMap::with_capacity(mapping.len());
372    for (context_key, json_path) in mapping {
373        let results = record
374            .query(json_path.as_str())
375            .map_err(|e| FaucetError::JsonPath(format!("invalid JSONPath '{json_path}': {e}")))?;
376        let value = results.first().ok_or_else(|| {
377            FaucetError::JsonPath(format!(
378                "JSONPath '{json_path}' matched nothing in record for context key '{context_key}'"
379            ))
380        })?;
381        context.insert(context_key.clone(), (*value).clone());
382    }
383    Ok(context)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use serde_json::json;
390
391    // ── quote_ident ─────────────────────────────────────────────────────
392
393    #[test]
394    fn quote_ident_simple() {
395        assert_eq!(quote_ident("my_table"), "\"my_table\"");
396    }
397
398    #[test]
399    fn quote_ident_with_embedded_quotes() {
400        assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
401    }
402
403    #[test]
404    fn quote_ident_empty() {
405        assert_eq!(quote_ident(""), "\"\"");
406    }
407
408    #[test]
409    fn quote_ident_special_chars() {
410        assert_eq!(quote_ident("table; DROP"), "\"table; DROP\"");
411    }
412
413    // ── extract_records ─────────────────────────────────────────────────
414
415    #[test]
416    fn extract_with_path() {
417        let body = json!({"data": [{"id": 1}, {"id": 2}]});
418        let records = extract_records(&body, Some("$.data[*]")).unwrap();
419        assert_eq!(records.len(), 2);
420        assert_eq!(records[0]["id"], 1);
421    }
422
423    #[test]
424    fn extract_without_path_array() {
425        let body = json!([{"id": 1}, {"id": 2}]);
426        let records = extract_records(&body, None).unwrap();
427        assert_eq!(records.len(), 2);
428    }
429
430    #[test]
431    fn extract_without_path_object() {
432        let body = json!({"id": 1});
433        let records = extract_records(&body, None).unwrap();
434        assert_eq!(records.len(), 1);
435    }
436
437    #[test]
438    fn extract_empty_result() {
439        let body = json!({"data": []});
440        let records = extract_records(&body, Some("$.data[*]")).unwrap();
441        assert!(records.is_empty());
442    }
443
444    #[test]
445    fn extract_invalid_path_returns_error() {
446        let body = json!({"data": 1});
447        // jsonpath-rust handles most paths gracefully; test error propagation.
448        let result = extract_records(&body, Some("$.data[*]"));
449        // This should succeed (empty match) or fail; either is fine as long as
450        // it doesn't panic.
451        let _ = result;
452    }
453
454    // ── substitute_context ──────────────────────────────────────────────
455
456    #[test]
457    fn substitute_context_string_values() {
458        let mut ctx = HashMap::new();
459        ctx.insert("org".to_string(), json!("acme"));
460        ctx.insert("repo".to_string(), json!("widgets"));
461        let result = substitute_context("/orgs/{org}/repos/{repo}", &ctx);
462        assert_eq!(result, "/orgs/acme/repos/widgets");
463    }
464
465    #[test]
466    fn substitute_context_number_value() {
467        let mut ctx = HashMap::new();
468        ctx.insert("id".to_string(), json!(42));
469        let result = substitute_context("/items/{id}", &ctx);
470        assert_eq!(result, "/items/42");
471    }
472
473    #[test]
474    fn substitute_context_bool_value() {
475        let mut ctx = HashMap::new();
476        ctx.insert("active".to_string(), json!(true));
477        let result = substitute_context("/filter?active={active}", &ctx);
478        assert_eq!(result, "/filter?active=true");
479    }
480
481    #[test]
482    fn substitute_context_null_value() {
483        let mut ctx = HashMap::new();
484        ctx.insert("val".to_string(), json!(null));
485        let result = substitute_context("/x/{val}", &ctx);
486        assert_eq!(result, "/x/null");
487    }
488
489    #[test]
490    fn substitute_context_array_value() {
491        let mut ctx = HashMap::new();
492        ctx.insert("ids".to_string(), json!([1, 2, 3]));
493        let result = substitute_context("/x/{ids}", &ctx);
494        assert_eq!(result, "/x/[1,2,3]");
495    }
496
497    #[test]
498    fn substitute_context_unmatched_placeholder_left_as_is() {
499        let ctx = HashMap::new();
500        let result = substitute_context("/orgs/{org}/repos", &ctx);
501        assert_eq!(result, "/orgs/{org}/repos");
502    }
503
504    #[test]
505    fn substitute_context_empty_template() {
506        let ctx = HashMap::new();
507        let result = substitute_context("", &ctx);
508        assert_eq!(result, "");
509    }
510
511    #[test]
512    fn substitute_context_replaces_all_occurrences() {
513        let mut ctx = HashMap::new();
514        ctx.insert("id".to_string(), Value::String("42".to_string()));
515        let result = substitute_context("/a/{id}/b/{id}", &ctx);
516        assert_eq!(result, "/a/42/b/42");
517    }
518
519    #[test]
520    fn substitute_context_does_not_rescan_replacement() {
521        // Single-pass: a replacement value that itself looks like a placeholder
522        // is emitted verbatim, never re-substituted (#78/#36).
523        let mut ctx = HashMap::new();
524        ctx.insert("a".to_string(), Value::String("{b}".to_string()));
525        ctx.insert("b".to_string(), Value::String("SECRET".to_string()));
526        let result = substitute_context("{a}", &ctx);
527        assert_eq!(result, "{b}");
528    }
529
530    // ── extract_context ─────────────────────────────────────────────────
531
532    #[test]
533    fn extract_context_simple_paths() {
534        let record = json!({"id": 1, "name": "alice"});
535        let mut mapping = HashMap::new();
536        mapping.insert("user_id".to_string(), "$.id".to_string());
537        mapping.insert("user_name".to_string(), "$.name".to_string());
538        let ctx = extract_context(&record, &mapping).unwrap();
539        assert_eq!(ctx["user_id"], json!(1));
540        assert_eq!(ctx["user_name"], json!("alice"));
541    }
542
543    #[test]
544    fn extract_context_nested_path() {
545        let record = json!({"data": {"info": {"id": 99}}});
546        let mut mapping = HashMap::new();
547        mapping.insert("deep_id".to_string(), "$.data.info.id".to_string());
548        let ctx = extract_context(&record, &mapping).unwrap();
549        assert_eq!(ctx["deep_id"], json!(99));
550    }
551
552    #[test]
553    fn extract_context_missing_path_returns_error() {
554        let record = json!({"id": 1});
555        let mut mapping = HashMap::new();
556        mapping.insert("missing".to_string(), "$.nonexistent".to_string());
557        let result = extract_context(&record, &mapping);
558        assert!(result.is_err());
559    }
560
561    #[test]
562    fn extract_context_empty_mapping() {
563        let record = json!({"id": 1});
564        let mapping = HashMap::new();
565        let ctx = extract_context(&record, &mapping).unwrap();
566        assert!(ctx.is_empty());
567    }
568
569    // ── substitute_context_bind_params ──────────────────────────────────
570
571    #[test]
572    fn bind_params_postgres_style() {
573        let mut ctx = HashMap::new();
574        ctx.insert("org".to_string(), json!("acme"));
575        ctx.insert("id".to_string(), json!(42));
576        let (query, values) = substitute_context_bind_params(
577            "SELECT * FROM t WHERE org = {org} AND id = {id}",
578            &ctx,
579            1,
580            |i| format!("${i}"),
581        );
582        assert_eq!(query, "SELECT * FROM t WHERE org = $1 AND id = $2");
583        assert_eq!(values.len(), 2);
584        assert_eq!(values[0], json!("acme"));
585        assert_eq!(values[1], json!(42));
586    }
587
588    #[test]
589    fn bind_params_question_mark_style() {
590        let mut ctx = HashMap::new();
591        ctx.insert("name".to_string(), json!("test"));
592        let (query, values) =
593            substitute_context_bind_params("SELECT * FROM t WHERE name = {name}", &ctx, 1, |_| {
594                "?".to_string()
595            });
596        assert_eq!(query, "SELECT * FROM t WHERE name = ?");
597        assert_eq!(values, vec![json!("test")]);
598    }
599
600    #[test]
601    fn bind_params_duplicate_key_produces_multiple_binds() {
602        let mut ctx = HashMap::new();
603        ctx.insert("id".to_string(), json!(5));
604        let (query, values) = substitute_context_bind_params(
605            "SELECT * FROM t WHERE a = {id} OR b = {id}",
606            &ctx,
607            3,
608            |i| format!("${i}"),
609        );
610        assert_eq!(query, "SELECT * FROM t WHERE a = $3 OR b = $4");
611        assert_eq!(values, vec![json!(5), json!(5)]);
612    }
613
614    #[test]
615    fn bind_params_unknown_key_left_as_is() {
616        let ctx = HashMap::new();
617        let (query, values) =
618            substitute_context_bind_params("SELECT * FROM t WHERE x = {unknown}", &ctx, 1, |i| {
619                format!("${i}")
620            });
621        assert_eq!(query, "SELECT * FROM t WHERE x = {unknown}");
622        assert!(values.is_empty());
623    }
624
625    #[test]
626    fn bind_params_mixed_known_and_unknown() {
627        let mut ctx = HashMap::new();
628        ctx.insert("id".to_string(), json!(1));
629        let (query, values) = substitute_context_bind_params(
630            "SELECT * FROM t WHERE id = {id} AND x = {unknown}",
631            &ctx,
632            1,
633            |i| format!("${i}"),
634        );
635        assert_eq!(query, "SELECT * FROM t WHERE id = $1 AND x = {unknown}");
636        assert_eq!(values, vec![json!(1)]);
637    }
638
639    #[test]
640    fn bind_params_empty_context() {
641        let ctx = HashMap::new();
642        let (query, values) =
643            substitute_context_bind_params("SELECT 1", &ctx, 1, |i| format!("${i}"));
644        assert_eq!(query, "SELECT 1");
645        assert!(values.is_empty());
646    }
647
648    #[test]
649    fn bind_params_start_index_offset() {
650        let mut ctx = HashMap::new();
651        ctx.insert("name".to_string(), json!("x"));
652        let (query, values) =
653            substitute_context_bind_params("SELECT * FROM t WHERE name = {name}", &ctx, 5, |i| {
654                format!("${i}")
655            });
656        assert_eq!(query, "SELECT * FROM t WHERE name = $5");
657        assert_eq!(values, vec![json!("x")]);
658    }
659
660    // ── substitute_context_json ─────────────────────────────────────────
661
662    #[test]
663    fn json_sub_escapes_double_quotes() {
664        let mut ctx = HashMap::new();
665        ctx.insert("name".to_string(), json!(r#"O'Brien "Bob""#));
666        let template = r#"{"name":"{name}"}"#;
667        let result = substitute_context_json(template, &ctx);
668        let parsed: Value = serde_json::from_str(&result).unwrap();
669        assert_eq!(parsed["name"], r#"O'Brien "Bob""#);
670    }
671
672    #[test]
673    fn json_sub_escapes_backslashes() {
674        let mut ctx = HashMap::new();
675        ctx.insert("path".to_string(), json!("C:\\Users\\test"));
676        let template = r#"{"path":"{path}"}"#;
677        let result = substitute_context_json(template, &ctx);
678        let parsed: Value = serde_json::from_str(&result).unwrap();
679        assert_eq!(parsed["path"], "C:\\Users\\test");
680    }
681
682    #[test]
683    fn json_sub_escapes_control_chars() {
684        let mut ctx = HashMap::new();
685        ctx.insert("text".to_string(), json!("line1\nline2\ttab"));
686        let template = r#"{"text":"{text}"}"#;
687        let result = substitute_context_json(template, &ctx);
688        let parsed: Value = serde_json::from_str(&result).unwrap();
689        assert_eq!(parsed["text"], "line1\nline2\ttab");
690    }
691
692    #[test]
693    fn json_sub_number_value() {
694        let mut ctx = HashMap::new();
695        ctx.insert("id".to_string(), json!(42));
696        let template = r#"{"user_id":"{id}"}"#;
697        let result = substitute_context_json(template, &ctx);
698        let parsed: Value = serde_json::from_str(&result).unwrap();
699        assert_eq!(parsed["user_id"], "42");
700    }
701
702    #[test]
703    fn json_sub_preserves_valid_json_without_special_chars() {
704        let mut ctx = HashMap::new();
705        ctx.insert("name".to_string(), json!("alice"));
706        let template = r#"{"filter":{"name":"{name}"}}"#;
707        let result = substitute_context_json(template, &ctx);
708        let parsed: Value = serde_json::from_str(&result).unwrap();
709        assert_eq!(parsed["filter"]["name"], "alice");
710    }
711
712    // ── json_escape_string ──────────────────────────────────────────────
713
714    #[test]
715    fn json_escape_plain_string() {
716        assert_eq!(json_escape_string("hello"), "hello");
717    }
718
719    #[test]
720    fn json_escape_quotes_and_backslashes() {
721        assert_eq!(json_escape_string(r#"a"b\c"#), r#"a\"b\\c"#);
722    }
723
724    #[test]
725    fn json_escape_newlines_and_tabs() {
726        assert_eq!(json_escape_string("a\nb\tc"), "a\\nb\\tc");
727    }
728
729    // ── redact_uri_credentials ──────────────────────────────────────────
730
731    #[test]
732    fn redact_strips_url_userinfo() {
733        assert_eq!(
734            redact_uri_credentials("postgres://user:pass@host:5432/db"),
735            "postgres://host:5432/db"
736        );
737        assert_eq!(
738            redact_uri_credentials("mongodb://u:p@h/db?x=1"),
739            "mongodb://h/db?x=1"
740        );
741    }
742
743    #[test]
744    fn redact_strips_user_only_userinfo() {
745        assert_eq!(
746            redact_uri_credentials("redis://user@127.0.0.1:6379"),
747            "redis://127.0.0.1:6379"
748        );
749    }
750
751    #[test]
752    fn redact_handles_adonet_password_tokens() {
753        assert_eq!(
754            redact_uri_credentials("Server=tcp:h,1433;Database=db;User Id=sa;Password=secret;"),
755            "Server=tcp:h,1433;Database=db;User Id=sa;Password=***;"
756        );
757        assert_eq!(
758            redact_uri_credentials("server=h;pwd=secret"),
759            "server=h;pwd=***"
760        );
761    }
762
763    #[test]
764    fn redact_passthrough_when_no_credentials() {
765        assert_eq!(
766            redact_uri_credentials("s3://bucket/prefix"),
767            "s3://bucket/prefix"
768        );
769        assert_eq!(
770            redact_uri_credentials("file:///tmp/x.csv"),
771            "file:///tmp/x.csv"
772        );
773    }
774
775    #[test]
776    fn redact_strips_password_containing_special_chars() {
777        // Passwords with '/', '?' or '@' must not leak (F5): the userinfo→host
778        // delimiter is the LAST '@', and the host segment after it is what's kept.
779        assert_eq!(
780            redact_uri_credentials("postgres://user:p/w@host:5432/db"),
781            "postgres://host:5432/db"
782        );
783        assert_eq!(
784            redact_uri_credentials("postgres://user:p?w@host/db"),
785            "postgres://host/db"
786        );
787        assert_eq!(
788            redact_uri_credentials("postgres://user:p@ss@host/db"),
789            "postgres://host/db"
790        );
791        assert_eq!(
792            redact_uri_credentials("mysql://u:a/b?c@d@127.0.0.1:3306/app"),
793            "mysql://127.0.0.1:3306/app"
794        );
795    }
796
797    #[test]
798    fn redact_strips_query_string_password() {
799        assert_eq!(
800            redact_uri_credentials("https://host/api?user=sa&password=secret&x=1"),
801            "https://host/api?user=sa&password=***&x=1"
802        );
803        assert_eq!(
804            redact_uri_credentials("snowflake://host/db?password=secret"),
805            "snowflake://host/db?password=***"
806        );
807    }
808
809    #[test]
810    fn redact_strips_common_secret_query_keys() {
811        // #321 M11: secret parameter names beyond password/pwd must be redacted
812        // before a URI is emitted to lineage / the catalog.
813        assert_eq!(
814            redact_uri_credentials("https://api.example.com/v2/?api_key=SECRET"),
815            "https://api.example.com/v2/?api_key=***"
816        );
817        assert_eq!(
818            redact_uri_credentials("https://h/?token=abc&access_token=xyz&keep=1"),
819            "https://h/?token=***&access_token=***&keep=1"
820        );
821        // Azure SAS style, and separator/casing variants normalize to one key.
822        assert_eq!(
823            redact_uri_credentials("https://acct.blob.core.windows.net/c?sig=DEAD&sp=r"),
824            "https://acct.blob.core.windows.net/c?sig=***&sp=r"
825        );
826        assert_eq!(
827            redact_uri_credentials("db=x;Client-Secret=shh;Account_Key=k"),
828            "db=x;Client-Secret=***;Account_Key=***"
829        );
830        // A non-secret key is untouched.
831        assert_eq!(
832            redact_uri_credentials("https://h/?region=us-east-1"),
833            "https://h/?region=us-east-1"
834        );
835    }
836}