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('=') {
294 out = out
295 .split_inclusive([';', '&', '?'])
296 .map(|seg| {
297 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
314fn 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
351fn 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
360pub 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 #[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 #[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 let result = extract_records(&body, Some("$.data[*]"));
449 let _ = result;
452 }
453
454 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 assert_eq!(
832 redact_uri_credentials("https://h/?region=us-east-1"),
833 "https://h/?region=us-east-1"
834 );
835 }
836}