1use std::collections::HashMap;
4
5use crate::FaucetError;
6use jsonpath_rust::JsonPath;
7use serde_json::Value;
8
9pub fn quote_ident(name: &str) -> String {
22 format!("\"{}\"", name.replace('"', "\"\""))
23}
24
25pub 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
48pub 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
77pub const DEFAULT_ERROR_BODY_MAX_LEN: usize = 2048;
79
80pub 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
107fn 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
148pub 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
208pub 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
225fn 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
246pub fn redact_uri_credentials(uri: &str) -> String {
256 let mut out = uri.to_string();
257 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 out.replace_range(after..after + at + 1, "");
285 }
286 }
287 if out.contains('=') {
291 out = out
292 .split_inclusive([';', '&', '?'])
293 .map(|seg| {
294 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
316fn 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
325pub 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 #[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 #[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 let result = extract_records(&body, Some("$.data[*]"));
414 let _ = result;
417 }
418
419 #[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 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 #[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 #[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 #[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 #[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 #[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 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}