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