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 Password=/Pwd= tokens. Splitting on both
288    //    ';' (ADO.NET) and '&'/'?' (URL query) catches a password carried as a
289    //    query parameter (`...?password=secret`) as well as keyword form.
290    if out.contains('=') {
291        out = out
292            .split_inclusive([';', '&', '?'])
293            .map(|seg| {
294                // Preserve any trailing delimiter the split kept on the segment.
295                let (body, delim) = match seg.char_indices().next_back() {
296                    Some((i, ';' | '&' | '?')) => (&seg[..i], &seg[i..]),
297                    _ => (seg, ""),
298                };
299                match body.find('=') {
300                    Some(eq)
301                        if {
302                            let k = body[..eq].trim();
303                            k.eq_ignore_ascii_case("password") || k.eq_ignore_ascii_case("pwd")
304                        } =>
305                    {
306                        format!("{}=***{delim}", &body[..eq])
307                    }
308                    _ => seg.to_string(),
309                }
310            })
311            .collect::<String>();
312    }
313    out
314}
315
316/// Best-effort check that a string looks like a `host[:port]` authority — used
317/// to identify the userinfo→host `@` delimiter when redacting credentials.
318/// Accepts letters, digits, `.`, `-`, `:`, `_`, and bracketed IPv6 forms.
319fn is_host_shaped(s: &str) -> bool {
320    s.bytes().all(|b| {
321        b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'_' | b'[' | b']' | b'%')
322    })
323}
324
325/// Extract context values from a record using JSONPath expressions.
326///
327/// Each entry in `mapping` is `context_key -> json_path`. The function queries
328/// the record with each JSONPath and stores the first matched value under the
329/// corresponding context key.
330///
331/// Returns an error if any JSONPath matches nothing.
332pub fn extract_context(
333    record: &Value,
334    mapping: &HashMap<String, String>,
335) -> Result<HashMap<String, Value>, FaucetError> {
336    let mut context = HashMap::with_capacity(mapping.len());
337    for (context_key, json_path) in mapping {
338        let results = record
339            .query(json_path.as_str())
340            .map_err(|e| FaucetError::JsonPath(format!("invalid JSONPath '{json_path}': {e}")))?;
341        let value = results.first().ok_or_else(|| {
342            FaucetError::JsonPath(format!(
343                "JSONPath '{json_path}' matched nothing in record for context key '{context_key}'"
344            ))
345        })?;
346        context.insert(context_key.clone(), (*value).clone());
347    }
348    Ok(context)
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use serde_json::json;
355
356    // ── quote_ident ─────────────────────────────────────────────────────
357
358    #[test]
359    fn quote_ident_simple() {
360        assert_eq!(quote_ident("my_table"), "\"my_table\"");
361    }
362
363    #[test]
364    fn quote_ident_with_embedded_quotes() {
365        assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
366    }
367
368    #[test]
369    fn quote_ident_empty() {
370        assert_eq!(quote_ident(""), "\"\"");
371    }
372
373    #[test]
374    fn quote_ident_special_chars() {
375        assert_eq!(quote_ident("table; DROP"), "\"table; DROP\"");
376    }
377
378    // ── extract_records ─────────────────────────────────────────────────
379
380    #[test]
381    fn extract_with_path() {
382        let body = json!({"data": [{"id": 1}, {"id": 2}]});
383        let records = extract_records(&body, Some("$.data[*]")).unwrap();
384        assert_eq!(records.len(), 2);
385        assert_eq!(records[0]["id"], 1);
386    }
387
388    #[test]
389    fn extract_without_path_array() {
390        let body = json!([{"id": 1}, {"id": 2}]);
391        let records = extract_records(&body, None).unwrap();
392        assert_eq!(records.len(), 2);
393    }
394
395    #[test]
396    fn extract_without_path_object() {
397        let body = json!({"id": 1});
398        let records = extract_records(&body, None).unwrap();
399        assert_eq!(records.len(), 1);
400    }
401
402    #[test]
403    fn extract_empty_result() {
404        let body = json!({"data": []});
405        let records = extract_records(&body, Some("$.data[*]")).unwrap();
406        assert!(records.is_empty());
407    }
408
409    #[test]
410    fn extract_invalid_path_returns_error() {
411        let body = json!({"data": 1});
412        // jsonpath-rust handles most paths gracefully; test error propagation.
413        let result = extract_records(&body, Some("$.data[*]"));
414        // This should succeed (empty match) or fail; either is fine as long as
415        // it doesn't panic.
416        let _ = result;
417    }
418
419    // ── substitute_context ──────────────────────────────────────────────
420
421    #[test]
422    fn substitute_context_string_values() {
423        let mut ctx = HashMap::new();
424        ctx.insert("org".to_string(), json!("acme"));
425        ctx.insert("repo".to_string(), json!("widgets"));
426        let result = substitute_context("/orgs/{org}/repos/{repo}", &ctx);
427        assert_eq!(result, "/orgs/acme/repos/widgets");
428    }
429
430    #[test]
431    fn substitute_context_number_value() {
432        let mut ctx = HashMap::new();
433        ctx.insert("id".to_string(), json!(42));
434        let result = substitute_context("/items/{id}", &ctx);
435        assert_eq!(result, "/items/42");
436    }
437
438    #[test]
439    fn substitute_context_bool_value() {
440        let mut ctx = HashMap::new();
441        ctx.insert("active".to_string(), json!(true));
442        let result = substitute_context("/filter?active={active}", &ctx);
443        assert_eq!(result, "/filter?active=true");
444    }
445
446    #[test]
447    fn substitute_context_null_value() {
448        let mut ctx = HashMap::new();
449        ctx.insert("val".to_string(), json!(null));
450        let result = substitute_context("/x/{val}", &ctx);
451        assert_eq!(result, "/x/null");
452    }
453
454    #[test]
455    fn substitute_context_array_value() {
456        let mut ctx = HashMap::new();
457        ctx.insert("ids".to_string(), json!([1, 2, 3]));
458        let result = substitute_context("/x/{ids}", &ctx);
459        assert_eq!(result, "/x/[1,2,3]");
460    }
461
462    #[test]
463    fn substitute_context_unmatched_placeholder_left_as_is() {
464        let ctx = HashMap::new();
465        let result = substitute_context("/orgs/{org}/repos", &ctx);
466        assert_eq!(result, "/orgs/{org}/repos");
467    }
468
469    #[test]
470    fn substitute_context_empty_template() {
471        let ctx = HashMap::new();
472        let result = substitute_context("", &ctx);
473        assert_eq!(result, "");
474    }
475
476    #[test]
477    fn substitute_context_replaces_all_occurrences() {
478        let mut ctx = HashMap::new();
479        ctx.insert("id".to_string(), Value::String("42".to_string()));
480        let result = substitute_context("/a/{id}/b/{id}", &ctx);
481        assert_eq!(result, "/a/42/b/42");
482    }
483
484    #[test]
485    fn substitute_context_does_not_rescan_replacement() {
486        // Single-pass: a replacement value that itself looks like a placeholder
487        // is emitted verbatim, never re-substituted (#78/#36).
488        let mut ctx = HashMap::new();
489        ctx.insert("a".to_string(), Value::String("{b}".to_string()));
490        ctx.insert("b".to_string(), Value::String("SECRET".to_string()));
491        let result = substitute_context("{a}", &ctx);
492        assert_eq!(result, "{b}");
493    }
494
495    // ── extract_context ─────────────────────────────────────────────────
496
497    #[test]
498    fn extract_context_simple_paths() {
499        let record = json!({"id": 1, "name": "alice"});
500        let mut mapping = HashMap::new();
501        mapping.insert("user_id".to_string(), "$.id".to_string());
502        mapping.insert("user_name".to_string(), "$.name".to_string());
503        let ctx = extract_context(&record, &mapping).unwrap();
504        assert_eq!(ctx["user_id"], json!(1));
505        assert_eq!(ctx["user_name"], json!("alice"));
506    }
507
508    #[test]
509    fn extract_context_nested_path() {
510        let record = json!({"data": {"info": {"id": 99}}});
511        let mut mapping = HashMap::new();
512        mapping.insert("deep_id".to_string(), "$.data.info.id".to_string());
513        let ctx = extract_context(&record, &mapping).unwrap();
514        assert_eq!(ctx["deep_id"], json!(99));
515    }
516
517    #[test]
518    fn extract_context_missing_path_returns_error() {
519        let record = json!({"id": 1});
520        let mut mapping = HashMap::new();
521        mapping.insert("missing".to_string(), "$.nonexistent".to_string());
522        let result = extract_context(&record, &mapping);
523        assert!(result.is_err());
524    }
525
526    #[test]
527    fn extract_context_empty_mapping() {
528        let record = json!({"id": 1});
529        let mapping = HashMap::new();
530        let ctx = extract_context(&record, &mapping).unwrap();
531        assert!(ctx.is_empty());
532    }
533
534    // ── substitute_context_bind_params ──────────────────────────────────
535
536    #[test]
537    fn bind_params_postgres_style() {
538        let mut ctx = HashMap::new();
539        ctx.insert("org".to_string(), json!("acme"));
540        ctx.insert("id".to_string(), json!(42));
541        let (query, values) = substitute_context_bind_params(
542            "SELECT * FROM t WHERE org = {org} AND id = {id}",
543            &ctx,
544            1,
545            |i| format!("${i}"),
546        );
547        assert_eq!(query, "SELECT * FROM t WHERE org = $1 AND id = $2");
548        assert_eq!(values.len(), 2);
549        assert_eq!(values[0], json!("acme"));
550        assert_eq!(values[1], json!(42));
551    }
552
553    #[test]
554    fn bind_params_question_mark_style() {
555        let mut ctx = HashMap::new();
556        ctx.insert("name".to_string(), json!("test"));
557        let (query, values) =
558            substitute_context_bind_params("SELECT * FROM t WHERE name = {name}", &ctx, 1, |_| {
559                "?".to_string()
560            });
561        assert_eq!(query, "SELECT * FROM t WHERE name = ?");
562        assert_eq!(values, vec![json!("test")]);
563    }
564
565    #[test]
566    fn bind_params_duplicate_key_produces_multiple_binds() {
567        let mut ctx = HashMap::new();
568        ctx.insert("id".to_string(), json!(5));
569        let (query, values) = substitute_context_bind_params(
570            "SELECT * FROM t WHERE a = {id} OR b = {id}",
571            &ctx,
572            3,
573            |i| format!("${i}"),
574        );
575        assert_eq!(query, "SELECT * FROM t WHERE a = $3 OR b = $4");
576        assert_eq!(values, vec![json!(5), json!(5)]);
577    }
578
579    #[test]
580    fn bind_params_unknown_key_left_as_is() {
581        let ctx = HashMap::new();
582        let (query, values) =
583            substitute_context_bind_params("SELECT * FROM t WHERE x = {unknown}", &ctx, 1, |i| {
584                format!("${i}")
585            });
586        assert_eq!(query, "SELECT * FROM t WHERE x = {unknown}");
587        assert!(values.is_empty());
588    }
589
590    #[test]
591    fn bind_params_mixed_known_and_unknown() {
592        let mut ctx = HashMap::new();
593        ctx.insert("id".to_string(), json!(1));
594        let (query, values) = substitute_context_bind_params(
595            "SELECT * FROM t WHERE id = {id} AND x = {unknown}",
596            &ctx,
597            1,
598            |i| format!("${i}"),
599        );
600        assert_eq!(query, "SELECT * FROM t WHERE id = $1 AND x = {unknown}");
601        assert_eq!(values, vec![json!(1)]);
602    }
603
604    #[test]
605    fn bind_params_empty_context() {
606        let ctx = HashMap::new();
607        let (query, values) =
608            substitute_context_bind_params("SELECT 1", &ctx, 1, |i| format!("${i}"));
609        assert_eq!(query, "SELECT 1");
610        assert!(values.is_empty());
611    }
612
613    #[test]
614    fn bind_params_start_index_offset() {
615        let mut ctx = HashMap::new();
616        ctx.insert("name".to_string(), json!("x"));
617        let (query, values) =
618            substitute_context_bind_params("SELECT * FROM t WHERE name = {name}", &ctx, 5, |i| {
619                format!("${i}")
620            });
621        assert_eq!(query, "SELECT * FROM t WHERE name = $5");
622        assert_eq!(values, vec![json!("x")]);
623    }
624
625    // ── substitute_context_json ─────────────────────────────────────────
626
627    #[test]
628    fn json_sub_escapes_double_quotes() {
629        let mut ctx = HashMap::new();
630        ctx.insert("name".to_string(), json!(r#"O'Brien "Bob""#));
631        let template = r#"{"name":"{name}"}"#;
632        let result = substitute_context_json(template, &ctx);
633        let parsed: Value = serde_json::from_str(&result).unwrap();
634        assert_eq!(parsed["name"], r#"O'Brien "Bob""#);
635    }
636
637    #[test]
638    fn json_sub_escapes_backslashes() {
639        let mut ctx = HashMap::new();
640        ctx.insert("path".to_string(), json!("C:\\Users\\test"));
641        let template = r#"{"path":"{path}"}"#;
642        let result = substitute_context_json(template, &ctx);
643        let parsed: Value = serde_json::from_str(&result).unwrap();
644        assert_eq!(parsed["path"], "C:\\Users\\test");
645    }
646
647    #[test]
648    fn json_sub_escapes_control_chars() {
649        let mut ctx = HashMap::new();
650        ctx.insert("text".to_string(), json!("line1\nline2\ttab"));
651        let template = r#"{"text":"{text}"}"#;
652        let result = substitute_context_json(template, &ctx);
653        let parsed: Value = serde_json::from_str(&result).unwrap();
654        assert_eq!(parsed["text"], "line1\nline2\ttab");
655    }
656
657    #[test]
658    fn json_sub_number_value() {
659        let mut ctx = HashMap::new();
660        ctx.insert("id".to_string(), json!(42));
661        let template = r#"{"user_id":"{id}"}"#;
662        let result = substitute_context_json(template, &ctx);
663        let parsed: Value = serde_json::from_str(&result).unwrap();
664        assert_eq!(parsed["user_id"], "42");
665    }
666
667    #[test]
668    fn json_sub_preserves_valid_json_without_special_chars() {
669        let mut ctx = HashMap::new();
670        ctx.insert("name".to_string(), json!("alice"));
671        let template = r#"{"filter":{"name":"{name}"}}"#;
672        let result = substitute_context_json(template, &ctx);
673        let parsed: Value = serde_json::from_str(&result).unwrap();
674        assert_eq!(parsed["filter"]["name"], "alice");
675    }
676
677    // ── json_escape_string ──────────────────────────────────────────────
678
679    #[test]
680    fn json_escape_plain_string() {
681        assert_eq!(json_escape_string("hello"), "hello");
682    }
683
684    #[test]
685    fn json_escape_quotes_and_backslashes() {
686        assert_eq!(json_escape_string(r#"a"b\c"#), r#"a\"b\\c"#);
687    }
688
689    #[test]
690    fn json_escape_newlines_and_tabs() {
691        assert_eq!(json_escape_string("a\nb\tc"), "a\\nb\\tc");
692    }
693
694    // ── redact_uri_credentials ──────────────────────────────────────────
695
696    #[test]
697    fn redact_strips_url_userinfo() {
698        assert_eq!(
699            redact_uri_credentials("postgres://user:pass@host:5432/db"),
700            "postgres://host:5432/db"
701        );
702        assert_eq!(
703            redact_uri_credentials("mongodb://u:p@h/db?x=1"),
704            "mongodb://h/db?x=1"
705        );
706    }
707
708    #[test]
709    fn redact_strips_user_only_userinfo() {
710        assert_eq!(
711            redact_uri_credentials("redis://user@127.0.0.1:6379"),
712            "redis://127.0.0.1:6379"
713        );
714    }
715
716    #[test]
717    fn redact_handles_adonet_password_tokens() {
718        assert_eq!(
719            redact_uri_credentials("Server=tcp:h,1433;Database=db;User Id=sa;Password=secret;"),
720            "Server=tcp:h,1433;Database=db;User Id=sa;Password=***;"
721        );
722        assert_eq!(
723            redact_uri_credentials("server=h;pwd=secret"),
724            "server=h;pwd=***"
725        );
726    }
727
728    #[test]
729    fn redact_passthrough_when_no_credentials() {
730        assert_eq!(
731            redact_uri_credentials("s3://bucket/prefix"),
732            "s3://bucket/prefix"
733        );
734        assert_eq!(
735            redact_uri_credentials("file:///tmp/x.csv"),
736            "file:///tmp/x.csv"
737        );
738    }
739
740    #[test]
741    fn redact_strips_password_containing_special_chars() {
742        // Passwords with '/', '?' or '@' must not leak (F5): the userinfo→host
743        // delimiter is the LAST '@', and the host segment after it is what's kept.
744        assert_eq!(
745            redact_uri_credentials("postgres://user:p/w@host:5432/db"),
746            "postgres://host:5432/db"
747        );
748        assert_eq!(
749            redact_uri_credentials("postgres://user:p?w@host/db"),
750            "postgres://host/db"
751        );
752        assert_eq!(
753            redact_uri_credentials("postgres://user:p@ss@host/db"),
754            "postgres://host/db"
755        );
756        assert_eq!(
757            redact_uri_credentials("mysql://u:a/b?c@d@127.0.0.1:3306/app"),
758            "mysql://127.0.0.1:3306/app"
759        );
760    }
761
762    #[test]
763    fn redact_strips_query_string_password() {
764        assert_eq!(
765            redact_uri_credentials("https://host/api?user=sa&password=secret&x=1"),
766            "https://host/api?user=sa&password=***&x=1"
767        );
768        assert_eq!(
769            redact_uri_credentials("snowflake://host/db?password=secret"),
770            "snowflake://host/db?password=***"
771        );
772    }
773}