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("://") {
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 out.replace_range(after..after + at_rel + 1, "");
267 }
268 }
269 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
290pub 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 #[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 #[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 let result = extract_records(&body, Some("$.data[*]"));
379 let _ = result;
382 }
383
384 #[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 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 #[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 #[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 #[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 #[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 #[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}