1use crate::detect::Finding;
10use crate::normalize::sql::normalize_sql;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct PgStatEntry {
16 pub query: String,
18 pub normalized_template: String,
20 pub calls: u64,
22 pub total_exec_time_ms: f64,
24 pub mean_exec_time_ms: f64,
26 pub rows: u64,
28 pub shared_blks_hit: u64,
30 pub shared_blks_read: u64,
32 #[serde(default)]
34 pub seen_in_traces: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct PgStatRanking {
40 pub label: String,
42 pub entries: Vec<PgStatEntry>,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize)]
48pub struct PgStatReport {
49 pub total_entries: usize,
51 pub top_n: usize,
53 pub rankings: Vec<PgStatRanking>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PgStatFormat {
64 Csv,
65 Json,
66}
67
68#[derive(Debug, thiserror::Error)]
72#[non_exhaustive]
73pub enum PgStatError {
74 #[error("payload too large: {size} bytes exceeds maximum of {max} bytes")]
75 PayloadTooLarge { size: usize, max: usize },
76 #[error("CSV parse error at line {line}: {detail}")]
77 CsvParse { line: usize, detail: String },
78 #[error("JSON parse error: {0}")]
79 JsonParse(String),
80 #[error("missing required column: {0}")]
81 MissingColumn(String),
82 #[error("empty input")]
83 EmptyInput,
84 #[cfg(any(feature = "daemon", feature = "tempo"))]
85 #[error("Prometheus request failed: {0}")]
86 PrometheusRequest(String),
87 #[cfg(any(feature = "daemon", feature = "tempo"))]
88 #[error("Prometheus response parse error: {0}")]
89 PrometheusFormat(String),
90}
91
92#[derive(Deserialize)]
94struct RawJsonEntry {
95 query: String,
96 calls: u64,
97 #[serde(alias = "total_exec_time")]
98 total_exec_time_ms: f64,
99 #[serde(alias = "mean_exec_time")]
100 mean_exec_time_ms: f64,
101 #[serde(default)]
102 rows: u64,
103 #[serde(default)]
104 shared_blks_hit: u64,
105 #[serde(default)]
106 shared_blks_read: u64,
107}
108
109#[must_use]
115pub fn detect_pg_stat_format(raw: &[u8]) -> PgStatFormat {
116 let trimmed = raw.iter().position(|&b| !b.is_ascii_whitespace());
117 match trimmed.map(|i| raw[i]) {
118 Some(b'[' | b'{') => PgStatFormat::Json,
119 _ => PgStatFormat::Csv,
120 }
121}
122
123pub fn parse_pg_stat(raw: &[u8], max_size: usize) -> Result<Vec<PgStatEntry>, PgStatError> {
133 if raw.len() > max_size {
134 return Err(PgStatError::PayloadTooLarge {
135 size: raw.len(),
136 max: max_size,
137 });
138 }
139 if raw.is_empty() || raw.iter().all(|&b| b.is_ascii_whitespace()) {
140 return Err(PgStatError::EmptyInput);
141 }
142
143 let text = std::str::from_utf8(raw).map_err(|e| PgStatError::CsvParse {
144 line: 0,
145 detail: format!("invalid UTF-8: {e}"),
146 })?;
147
148 match detect_pg_stat_format(raw) {
149 PgStatFormat::Csv => parse_csv(text),
150 PgStatFormat::Json => parse_json(text),
151 }
152}
153
154#[must_use]
177pub fn rank_pg_stat(entries: &[PgStatEntry], top_n: usize) -> PgStatReport {
178 let total_entries = entries.len();
179
180 let top_n_by =
181 |cmp: fn(&PgStatEntry, &PgStatEntry) -> std::cmp::Ordering, label: &str| -> PgStatRanking {
182 let mut indices: Vec<usize> = (0..entries.len()).collect();
183 indices.sort_by(|&a, &b| cmp(&entries[a], &entries[b]));
184 indices.truncate(top_n);
185 PgStatRanking {
186 label: label.to_string(),
187 entries: indices.iter().map(|&i| entries[i].clone()).collect(),
188 }
189 };
190
191 let by_total_time = top_n_by(
192 |a, b| {
193 b.total_exec_time_ms
194 .partial_cmp(&a.total_exec_time_ms)
195 .unwrap_or(std::cmp::Ordering::Equal)
196 },
197 "top by total_exec_time",
198 );
199
200 let by_calls = top_n_by(|a, b| b.calls.cmp(&a.calls), "top by calls");
201
202 let by_mean_time = top_n_by(
203 |a, b| {
204 b.mean_exec_time_ms
205 .partial_cmp(&a.mean_exec_time_ms)
206 .unwrap_or(std::cmp::Ordering::Equal)
207 },
208 "top by mean_exec_time",
209 );
210
211 let by_io_blocks = top_n_by(
216 |a, b| {
217 let bt = b.shared_blks_read.saturating_add(b.shared_blks_hit);
218 let at = a.shared_blks_read.saturating_add(a.shared_blks_hit);
219 bt.cmp(&at)
220 },
221 "top by shared_blks_total",
222 );
223
224 PgStatReport {
225 total_entries,
226 top_n,
227 rankings: vec![by_total_time, by_calls, by_mean_time, by_io_blocks],
228 }
229}
230
231pub fn cross_reference(entries: &mut [PgStatEntry], findings: &[Finding]) {
235 let templates: std::collections::HashSet<&str> = findings
236 .iter()
237 .map(|f| f.pattern.template.as_str())
238 .collect();
239
240 for entry in entries {
241 if templates.contains(entry.normalized_template.as_str()) {
242 entry.seen_in_traces = true;
243 }
244 }
245}
246
247const MAX_CSV_ROWS: usize = 1_000_000;
252
253fn parse_csv(text: &str) -> Result<Vec<PgStatEntry>, PgStatError> {
254 let mut lines = text.lines();
255
256 let header_line = lines.next().ok_or(PgStatError::EmptyInput)?;
257 let headers = parse_csv_row(header_line);
258 let col = |name: &str| -> Result<usize, PgStatError> {
259 headers
260 .iter()
261 .position(|h| h.eq_ignore_ascii_case(name))
262 .ok_or_else(|| PgStatError::MissingColumn(name.to_string()))
263 };
264
265 let query_idx = col("query")?;
266 let calls_idx = col("calls")?;
267 let total_time_idx = col("total_exec_time")?;
268 let mean_time_idx = col("mean_exec_time")?;
269 let rows_idx = col("rows").ok();
270 let hit_idx = col("shared_blks_hit").ok();
271 let read_idx = col("shared_blks_read").ok();
272
273 let estimated = (text.len() / 100).min(100_000);
275 let mut entries = Vec::with_capacity(estimated);
276 for (line_num, line) in lines.enumerate() {
277 if entries.len() >= MAX_CSV_ROWS {
278 return Err(PgStatError::CsvParse {
279 line: line_num + 2,
280 detail: format!("CSV exceeds maximum of {MAX_CSV_ROWS} rows"),
281 });
282 }
283 let line = line.trim();
284 if line.is_empty() {
285 continue;
286 }
287 let fields = parse_csv_row(line);
288 let line_num = line_num + 2; let query = fields.get(query_idx).cloned().unwrap_or_default();
291 let calls = parse_u64(&fields, calls_idx, line_num, "calls")?;
292 let total_exec_time_ms = parse_f64(&fields, total_time_idx, line_num, "total_exec_time")?;
293 let mean_exec_time_ms = parse_f64(&fields, mean_time_idx, line_num, "mean_exec_time")?;
294 let rows = rows_idx.map_or(Ok(0), |i| parse_u64(&fields, i, line_num, "rows"))?;
295 let shared_blks_hit = hit_idx.map_or(Ok(0), |i| {
296 parse_u64(&fields, i, line_num, "shared_blks_hit")
297 })?;
298 let shared_blks_read = read_idx.map_or(Ok(0), |i| {
299 parse_u64(&fields, i, line_num, "shared_blks_read")
300 })?;
301
302 let normalized = normalize_sql(&query);
303
304 entries.push(PgStatEntry {
305 query,
306 normalized_template: normalized.template,
307 calls,
308 total_exec_time_ms,
309 mean_exec_time_ms,
310 rows,
311 shared_blks_hit,
312 shared_blks_read,
313 seen_in_traces: false,
314 });
315 }
316
317 if entries.is_empty() {
318 return Err(PgStatError::EmptyInput);
319 }
320 Ok(entries)
321}
322
323pub(crate) fn parse_csv_row(line: &str) -> Vec<String> {
329 let mut fields = Vec::with_capacity(8);
330 let mut current = String::new();
331 let mut in_quotes = false;
332 let mut chars = line.chars().peekable();
333
334 while let Some(c) = chars.next() {
335 if in_quotes {
336 if c == '"' {
337 if chars.peek() == Some(&'"') {
338 current.push('"');
340 chars.next();
341 } else {
342 in_quotes = false;
344 }
345 } else {
346 current.push(c);
347 }
348 } else if c == '"' {
349 in_quotes = true;
350 } else if c == ',' {
351 fields.push(std::mem::take(&mut current));
352 } else {
353 current.push(c);
354 }
355 }
356 fields.push(current);
357 fields
358}
359
360fn parse_u64(
361 fields: &[String],
362 idx: usize,
363 line: usize,
364 col_name: &str,
365) -> Result<u64, PgStatError> {
366 let val = fields.get(idx).map_or("", String::as_str).trim();
367 val.parse::<u64>().map_err(|_| PgStatError::CsvParse {
368 line,
369 detail: format!("cannot parse '{val}' as integer for column {col_name}"),
370 })
371}
372
373fn parse_f64(
374 fields: &[String],
375 idx: usize,
376 line: usize,
377 col_name: &str,
378) -> Result<f64, PgStatError> {
379 let val = fields.get(idx).map_or("", String::as_str).trim();
380 val.parse::<f64>().map_err(|_| PgStatError::CsvParse {
381 line,
382 detail: format!("cannot parse '{val}' as float for column {col_name}"),
383 })
384}
385
386fn parse_json(text: &str) -> Result<Vec<PgStatEntry>, PgStatError> {
391 let raw_entries: Vec<RawJsonEntry> =
392 serde_json::from_str(text).map_err(|e| PgStatError::JsonParse(e.to_string()))?;
393
394 if raw_entries.is_empty() {
395 return Err(PgStatError::EmptyInput);
396 }
397 if raw_entries.len() > MAX_CSV_ROWS {
398 return Err(PgStatError::JsonParse(format!(
399 "JSON array exceeds maximum of {MAX_CSV_ROWS} entries (got {})",
400 raw_entries.len()
401 )));
402 }
403
404 let entries = raw_entries
405 .into_iter()
406 .map(|raw| {
407 let normalized = normalize_sql(&raw.query);
408 PgStatEntry {
409 query: raw.query,
410 normalized_template: normalized.template,
411 calls: raw.calls,
412 total_exec_time_ms: raw.total_exec_time_ms,
413 mean_exec_time_ms: raw.mean_exec_time_ms,
414 rows: raw.rows,
415 shared_blks_hit: raw.shared_blks_hit,
416 shared_blks_read: raw.shared_blks_read,
417 seen_in_traces: false,
418 }
419 })
420 .collect();
421
422 Ok(entries)
423}
424
425#[cfg(any(feature = "daemon", feature = "tempo"))]
445pub async fn fetch_from_prometheus(
446 endpoint: &str,
447 top_n: usize,
448 auth_header: Option<&str>,
449) -> Result<Vec<PgStatEntry>, PgStatError> {
450 use crate::ingest::auth_header::AuthHeader;
451
452 validate_prometheus_endpoint(endpoint)?;
456
457 let parsed_auth = auth_header
461 .map(AuthHeader::parse)
462 .transpose()
463 .map_err(|msg| PgStatError::PrometheusRequest(format!("invalid auth header: {msg}")))?;
464 if parsed_auth.is_some() && endpoint.starts_with("http://") {
465 tracing::warn!(
466 "Sending auth header over cleartext HTTP, prefer https:// to avoid credential leak"
467 );
468 }
469
470 let client = crate::http_client::build_client();
471 let query = format!("topk({top_n}%2C%20pg_stat_statements_seconds_total)");
474 let url = format!("{endpoint}/api/v1/query?query={query}");
475 let uri: crate::http_client::Uri = url
476 .parse()
477 .map_err(|e| PgStatError::PrometheusRequest(format!("invalid URL: {e}")))?;
478
479 let timeout = std::time::Duration::from_secs(30);
480 let body = crate::http_client::fetch_get(
481 &client,
482 &uri,
483 "perf-sentinel/pg-stat",
484 timeout,
485 parsed_auth.as_ref(),
486 )
487 .await
488 .map_err(|e| {
489 PgStatError::PrometheusRequest(format!(
493 "{e} (endpoint: {})",
494 crate::http_client::redact_endpoint(&uri)
495 ))
496 })?;
497
498 parse_prometheus_response(&body)
499}
500
501#[cfg(any(feature = "daemon", feature = "tempo"))]
509fn validate_prometheus_endpoint(endpoint: &str) -> Result<(), PgStatError> {
510 if endpoint.bytes().any(|b| b < 0x20 || b == 0x7f) {
511 return Err(PgStatError::PrometheusRequest(
512 "endpoint must not contain ASCII control characters".to_string(),
513 ));
514 }
515 let uri: crate::http_client::Uri = endpoint
516 .parse()
517 .map_err(|e| PgStatError::PrometheusRequest(format!("invalid endpoint URL: {e}")))?;
518
519 match uri.scheme_str() {
520 Some("http" | "https") => {}
521 Some(other) => {
522 return Err(PgStatError::PrometheusRequest(format!(
523 "unsupported scheme `{other}`, only http and https are accepted"
524 )));
525 }
526 None => {
527 return Err(PgStatError::PrometheusRequest(
528 "endpoint URL must include a scheme (http:// or https://)".to_string(),
529 ));
530 }
531 }
532
533 if let Some(authority) = uri.authority()
537 && authority.as_str().contains('@')
538 {
539 return Err(PgStatError::PrometheusRequest(
540 "credentials in the URL are not accepted; use env vars instead".to_string(),
541 ));
542 }
543
544 Ok(())
545}
546
547#[cfg(any(feature = "daemon", feature = "tempo"))]
549fn parse_prometheus_response(body: &[u8]) -> Result<Vec<PgStatEntry>, PgStatError> {
550 let json: serde_json::Value = serde_json::from_slice(body)
551 .map_err(|e| PgStatError::PrometheusFormat(format!("invalid JSON: {e}")))?;
552
553 let results = json
554 .get("data")
555 .and_then(|d| d.get("result"))
556 .and_then(|r| r.as_array())
557 .ok_or_else(|| PgStatError::PrometheusFormat("missing data.result array".to_string()))?;
558
559 let mut entries = Vec::with_capacity(results.len());
560 for result in results {
561 let metric = result.get("metric").unwrap_or(&serde_json::Value::Null);
562 let query_text = metric
563 .get("query")
564 .or_else(|| metric.get("queryid"))
565 .and_then(|v| v.as_str())
566 .unwrap_or("unknown")
567 .to_string();
568
569 let value = result
571 .get("value")
572 .and_then(|v| v.as_array())
573 .and_then(|arr| arr.get(1))
574 .and_then(|v| v.as_str())
575 .and_then(|s| s.parse::<f64>().ok())
576 .unwrap_or(0.0);
577
578 let total_exec_time_ms = value * 1000.0; let calls = metric
584 .get("calls")
585 .and_then(serde_json::Value::as_str)
586 .and_then(|s| s.parse::<u64>().ok())
587 .unwrap_or(0);
588
589 #[allow(clippy::cast_precision_loss)]
590 let mean_exec_time_ms = if calls > 0 {
591 total_exec_time_ms / (calls as f64)
592 } else {
593 total_exec_time_ms
594 };
595
596 let normalized = normalize_sql(&query_text);
597
598 entries.push(PgStatEntry {
599 query: query_text,
600 normalized_template: normalized.template,
601 calls,
602 total_exec_time_ms,
603 mean_exec_time_ms,
604 rows: 0,
605 shared_blks_hit: 0,
606 shared_blks_read: 0,
607 seen_in_traces: false,
608 });
609 }
610
611 Ok(entries)
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use core::assert_matches;
618
619 fn sample_csv() -> &'static str {
620 "query,calls,total_exec_time,mean_exec_time,rows,shared_blks_hit,shared_blks_read\n\
621 SELECT * FROM order_item WHERE order_id = 42,1500,4500.50,3.000,1500,12000,150\n\
622 \"SELECT * FROM orders WHERE id = 1 AND status = 'active'\",800,2400.00,3.000,800,6400,80\n\
623 INSERT INTO audit_log VALUES (1),200,600.00,3.000,200,0,200\n\
624 SELECT count(*) FROM order_item,50,250.00,5.000,50,500,10"
625 }
626
627 fn sample_json() -> &'static str {
628 r#"[
629 {
630 "query": "SELECT * FROM order_item WHERE order_id = 42",
631 "calls": 1500,
632 "total_exec_time_ms": 4500.50,
633 "mean_exec_time_ms": 3.0,
634 "rows": 1500,
635 "shared_blks_hit": 12000,
636 "shared_blks_read": 150
637 },
638 {
639 "query": "SELECT * FROM orders WHERE id = 1 AND status = 'active'",
640 "calls": 800,
641 "total_exec_time_ms": 2400.0,
642 "mean_exec_time_ms": 3.0,
643 "rows": 800,
644 "shared_blks_hit": 6400,
645 "shared_blks_read": 80
646 },
647 {
648 "query": "INSERT INTO audit_log VALUES (1)",
649 "calls": 200,
650 "total_exec_time_ms": 600.0,
651 "mean_exec_time_ms": 3.0,
652 "rows": 200,
653 "shared_blks_hit": 0,
654 "shared_blks_read": 200
655 },
656 {
657 "query": "SELECT count(*) FROM order_item",
658 "calls": 50,
659 "total_exec_time_ms": 250.0,
660 "mean_exec_time_ms": 5.0,
661 "rows": 50,
662 "shared_blks_hit": 500,
663 "shared_blks_read": 10
664 }
665 ]"#
666 }
667
668 #[test]
671 fn detect_format_csv() {
672 assert_eq!(
673 detect_pg_stat_format(b"query,calls,total_exec_time"),
674 PgStatFormat::Csv
675 );
676 }
677
678 #[test]
679 fn detect_format_json_array() {
680 assert_eq!(
681 detect_pg_stat_format(b"[{\"query\": \"SELECT 1\"}]"),
682 PgStatFormat::Json
683 );
684 }
685
686 #[test]
687 fn detect_format_json_with_whitespace() {
688 assert_eq!(
689 detect_pg_stat_format(b" \n [{\"query\": \"SELECT 1\"}]"),
690 PgStatFormat::Json
691 );
692 }
693
694 #[test]
695 fn detect_format_empty_defaults_csv() {
696 assert_eq!(detect_pg_stat_format(b""), PgStatFormat::Csv);
697 }
698
699 #[test]
702 fn parse_csv_basic() {
703 let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
704 assert_eq!(entries.len(), 4);
705 assert_eq!(entries[0].calls, 1500);
706 assert!((entries[0].total_exec_time_ms - 4500.50).abs() < f64::EPSILON);
707 assert_eq!(entries[0].rows, 1500);
708 assert_eq!(entries[0].shared_blks_hit, 12000);
709 }
710
711 #[test]
712 fn parse_csv_quoted_field() {
713 let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
714 assert!(entries[1].query.contains("status = 'active'"));
716 }
717
718 #[test]
719 fn parse_csv_normalization_applied() {
720 let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
721 assert_eq!(
723 entries[0].normalized_template,
724 "SELECT * FROM order_item WHERE order_id = ?"
725 );
726 }
727
728 #[test]
729 fn parse_csv_empty_input() {
730 let result = parse_pg_stat(b"", 1_048_576);
731 assert_matches!(result, Err(PgStatError::EmptyInput));
732 }
733
734 #[test]
735 fn parse_csv_whitespace_only() {
736 let result = parse_pg_stat(b" \n \n ", 1_048_576);
737 assert_matches!(result, Err(PgStatError::EmptyInput));
738 }
739
740 #[test]
741 fn parse_csv_header_only() {
742 let result = parse_pg_stat(b"query,calls,total_exec_time,mean_exec_time\n", 1_048_576);
743 assert_matches!(result, Err(PgStatError::EmptyInput));
744 }
745
746 #[test]
747 fn parse_csv_missing_column() {
748 let result = parse_pg_stat(b"query,calls\nSELECT 1,100", 1_048_576);
749 assert_matches!(result, Err(PgStatError::MissingColumn(_)));
750 }
751
752 #[test]
753 fn parse_csv_oversized_payload() {
754 let result = parse_pg_stat(sample_csv().as_bytes(), 10);
755 assert_matches!(result, Err(PgStatError::PayloadTooLarge { .. }));
756 }
757
758 #[test]
759 fn parse_csv_escaped_quotes() {
760 let csv = "query,calls,total_exec_time,mean_exec_time\n\
761 \"SELECT * FROM t WHERE name = \"\"O'Brien\"\"\",100,500.0,5.0";
762 let entries = parse_pg_stat(csv.as_bytes(), 1_048_576).unwrap();
763 assert!(entries[0].query.contains("O'Brien"));
764 }
765
766 #[test]
769 fn parse_json_basic() {
770 let entries = parse_pg_stat(sample_json().as_bytes(), 1_048_576).unwrap();
771 assert_eq!(entries.len(), 4);
772 assert_eq!(entries[0].calls, 1500);
773 }
774
775 #[test]
776 fn parse_json_normalization_applied() {
777 let entries = parse_pg_stat(sample_json().as_bytes(), 1_048_576).unwrap();
778 assert_eq!(
779 entries[0].normalized_template,
780 "SELECT * FROM order_item WHERE order_id = ?"
781 );
782 }
783
784 #[test]
785 fn parse_json_empty_array() {
786 let result = parse_pg_stat(b"[]", 1_048_576);
787 assert_matches!(result, Err(PgStatError::EmptyInput));
788 }
789
790 #[test]
791 fn parse_json_invalid() {
792 let result = parse_pg_stat(b"[{invalid json}]", 1_048_576);
793 assert_matches!(result, Err(PgStatError::JsonParse(_)));
794 }
795
796 #[test]
797 fn parse_json_field_alias() {
798 let json = r#"[{
800 "query": "SELECT 1",
801 "calls": 10,
802 "total_exec_time": 100.0,
803 "mean_exec_time": 10.0,
804 "rows": 10
805 }]"#;
806 let entries = parse_pg_stat(json.as_bytes(), 1_048_576).unwrap();
807 assert!((entries[0].total_exec_time_ms - 100.0).abs() < f64::EPSILON);
808 }
809
810 #[test]
813 fn rank_by_total_time() {
814 let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
815 let report = rank_pg_stat(&entries, 2);
816 assert_eq!(report.total_entries, 4);
817 assert_eq!(report.top_n, 2);
818 let by_time = &report.rankings[0];
819 assert_eq!(by_time.label, "top by total_exec_time");
820 assert_eq!(by_time.entries.len(), 2);
821 assert!(by_time.entries[0].total_exec_time_ms >= by_time.entries[1].total_exec_time_ms);
823 }
824
825 #[test]
826 fn rank_by_calls() {
827 let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
828 let report = rank_pg_stat(&entries, 10);
829 let by_calls = &report.rankings[1];
830 assert_eq!(by_calls.label, "top by calls");
831 assert!(by_calls.entries[0].calls >= by_calls.entries[1].calls);
832 }
833
834 #[test]
835 fn rank_by_mean_time() {
836 let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
837 let report = rank_pg_stat(&entries, 10);
838 let by_mean = &report.rankings[2];
839 assert_eq!(by_mean.label, "top by mean_exec_time");
840 assert!(by_mean.entries[0].mean_exec_time_ms >= by_mean.entries[1].mean_exec_time_ms);
841 }
842
843 #[test]
844 fn rank_top_n_limits_output() {
845 let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
846 let report = rank_pg_stat(&entries, 1);
847 for ranking in &report.rankings {
848 assert_eq!(ranking.entries.len(), 1);
849 }
850 }
851
852 #[test]
853 fn rank_pg_stat_emits_four_rankings_in_stable_order() {
854 let entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
855 let report = rank_pg_stat(&entries, 10);
856 assert_eq!(report.rankings.len(), 4, "exactly 4 rankings expected");
857 assert_eq!(report.rankings[0].label, "top by total_exec_time");
858 assert_eq!(report.rankings[1].label, "top by calls");
859 assert_eq!(report.rankings[2].label, "top by mean_exec_time");
860 assert_eq!(report.rankings[3].label, "top by shared_blks_total");
861
862 let by_io = &report.rankings[3];
865 let expected_top_sum = entries
866 .iter()
867 .map(|e| e.shared_blks_read.saturating_add(e.shared_blks_hit))
868 .max()
869 .unwrap();
870 let actual_top_sum = by_io.entries[0]
871 .shared_blks_read
872 .saturating_add(by_io.entries[0].shared_blks_hit);
873 assert_eq!(
874 actual_top_sum, expected_top_sum,
875 "by_io_blocks top must be the entry with max hits+reads"
876 );
877 }
878
879 #[test]
880 fn rank_empty_entries() {
881 let report = rank_pg_stat(&[], 10);
882 assert_eq!(report.total_entries, 0);
883 for ranking in &report.rankings {
884 assert!(ranking.entries.is_empty());
885 }
886 }
887
888 use crate::detect::test_finding_with_template as make_finding;
891
892 #[test]
893 fn cross_reference_marks_matching_templates() {
894 let mut entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
895 let findings = vec![make_finding("SELECT * FROM order_item WHERE order_id = ?")];
896 cross_reference(&mut entries, &findings);
897 assert!(entries[0].seen_in_traces);
898 assert!(!entries[1].seen_in_traces);
899 }
900
901 #[test]
902 fn cross_reference_no_matches() {
903 let mut entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
904 let findings = vec![make_finding("SELECT * FROM nonexistent WHERE id = ?")];
905 cross_reference(&mut entries, &findings);
906 assert!(entries.iter().all(|e| !e.seen_in_traces));
907 }
908
909 #[test]
910 fn cross_reference_empty_findings() {
911 let mut entries = parse_pg_stat(sample_csv().as_bytes(), 1_048_576).unwrap();
912 cross_reference(&mut entries, &[]);
913 assert!(entries.iter().all(|e| !e.seen_in_traces));
914 }
915
916 #[test]
919 fn csv_row_with_embedded_comma() {
920 let row = r#""SELECT a, b FROM t",100,500.0,5.0"#;
921 let fields = parse_csv_row(row);
922 assert_eq!(fields[0], "SELECT a, b FROM t");
923 assert_eq!(fields[1], "100");
924 }
925
926 #[test]
927 fn csv_row_simple() {
928 let row = "a,b,c,d";
929 let fields = parse_csv_row(row);
930 assert_eq!(fields, vec!["a", "b", "c", "d"]);
931 }
932
933 #[test]
934 fn csv_row_with_utf8_content() {
935 let row = "\"SELECT * FROM café WHERE naïve = 'résumé'\",100,500.0,5.0";
936 let fields = parse_csv_row(row);
937 assert_eq!(fields[0], "SELECT * FROM café WHERE naïve = 'résumé'");
938 }
939
940 #[test]
941 fn parse_invalid_utf8_returns_error() {
942 let data: &[u8] = &[0xFF, 0xFE, 0x00, 0x01];
943 let result = parse_pg_stat(data, 1_048_576);
944 assert_matches!(result, Err(PgStatError::CsvParse { line: 0, .. }));
945 }
946
947 #[test]
948 fn parse_csv_invalid_number_returns_error() {
949 let csv = "query,calls,total_exec_time,mean_exec_time\nSELECT 1,abc,500.0,5.0";
950 let result = parse_pg_stat(csv.as_bytes(), 1_048_576);
951 assert_matches!(result, Err(PgStatError::CsvParse { line: 2, .. }));
952 }
953
954 #[cfg(any(feature = "daemon", feature = "tempo"))]
957 #[test]
958 fn parse_prometheus_response_basic() {
959 let json = br#"{
960 "status": "success",
961 "data": {
962 "resultType": "vector",
963 "result": [
964 {
965 "metric": {
966 "__name__": "pg_stat_statements_seconds_total",
967 "query": "SELECT * FROM orders WHERE id = $1"
968 },
969 "value": [1720000000, "4.5"]
970 },
971 {
972 "metric": {
973 "__name__": "pg_stat_statements_seconds_total",
974 "query": "INSERT INTO audit_log VALUES ($1)"
975 },
976 "value": [1720000000, "1.2"]
977 }
978 ]
979 }
980 }"#;
981
982 let entries = parse_prometheus_response(json).unwrap();
983 assert_eq!(entries.len(), 2);
984 assert!((entries[0].total_exec_time_ms - 4500.0).abs() < f64::EPSILON);
985 assert!((entries[1].total_exec_time_ms - 1200.0).abs() < f64::EPSILON);
986 assert!(entries[0].normalized_template.contains('?'));
988 }
989
990 #[cfg(any(feature = "daemon", feature = "tempo"))]
991 #[test]
992 fn parse_prometheus_response_empty_result() {
993 let json = br#"{"status":"success","data":{"resultType":"vector","result":[]}}"#;
994 let entries = parse_prometheus_response(json).unwrap();
995 assert!(entries.is_empty());
996 }
997
998 #[cfg(any(feature = "daemon", feature = "tempo"))]
999 #[test]
1000 fn parse_prometheus_response_invalid_json() {
1001 let result = parse_prometheus_response(b"not json");
1002 assert_matches!(result, Err(PgStatError::PrometheusFormat(_)));
1003 }
1004
1005 #[cfg(any(feature = "daemon", feature = "tempo"))]
1008 #[test]
1009 fn validate_endpoint_accepts_http_and_https() {
1010 assert!(validate_prometheus_endpoint("http://prometheus:9090").is_ok());
1011 assert!(validate_prometheus_endpoint("https://prometheus.example.com").is_ok());
1012 assert!(validate_prometheus_endpoint("http://127.0.0.1:9090").is_ok());
1013 }
1014
1015 #[cfg(any(feature = "daemon", feature = "tempo"))]
1016 #[test]
1017 fn validate_endpoint_rejects_malformed_url() {
1018 let result = validate_prometheus_endpoint("not a url");
1019 assert_matches!(result, Err(PgStatError::PrometheusRequest(_)));
1020 }
1021
1022 #[cfg(any(feature = "daemon", feature = "tempo"))]
1023 #[test]
1024 fn validate_endpoint_rejects_userinfo() {
1025 let result = validate_prometheus_endpoint("http://user:pass@prometheus:9090");
1026 assert!(
1027 matches!(result, Err(PgStatError::PrometheusRequest(msg)) if msg.contains("credentials")),
1028 "must reject userinfo in URL"
1029 );
1030 }
1031
1032 #[cfg(any(feature = "daemon", feature = "tempo"))]
1033 #[test]
1034 fn validate_endpoint_rejects_non_http_scheme() {
1035 let result = validate_prometheus_endpoint("ftp://prometheus:9090");
1036 assert!(
1037 matches!(result, Err(PgStatError::PrometheusRequest(msg)) if msg.contains("scheme")),
1038 "must reject non-http(s) schemes"
1039 );
1040 }
1041
1042 #[cfg(any(feature = "daemon", feature = "tempo"))]
1043 #[tokio::test]
1044 async fn fetch_from_prometheus_sends_auth_header_on_wire() {
1045 let body = r#"{"status":"success","data":{"resultType":"vector","result":[]}}"#;
1046 let response = crate::test_helpers::http_200_text("application/json", body);
1047 let (endpoint, mut rx, server) = crate::test_helpers::spawn_capture_server(response).await;
1048
1049 let entries = fetch_from_prometheus(&endpoint, 5, Some("Authorization: Bearer topsecret"))
1050 .await
1051 .expect("fetch_from_prometheus must succeed");
1052 assert!(entries.is_empty());
1053
1054 let captured = rx.recv().await.expect("captured request");
1055 let text = std::str::from_utf8(&captured).expect("utf8");
1056 assert!(
1057 text.contains("authorization: Bearer topsecret")
1058 || text.contains("Authorization: Bearer topsecret"),
1059 "auth header missing from request, got:\n{text}"
1060 );
1061 server.await.expect("server join");
1062 }
1063
1064 #[cfg(any(feature = "daemon", feature = "tempo"))]
1065 #[tokio::test]
1066 async fn fetch_from_prometheus_rejects_invalid_auth_header() {
1067 let err = fetch_from_prometheus("http://prometheus.local:9090", 5, Some("NoColonHere"))
1068 .await
1069 .expect_err("malformed auth header must be rejected");
1070 match err {
1071 PgStatError::PrometheusRequest(msg) => {
1072 assert!(
1073 msg.contains("invalid auth header"),
1074 "error message should flag the auth header parse failure, got: {msg}"
1075 );
1076 }
1077 other => panic!("expected PrometheusRequest, got {other:?}"),
1078 }
1079 }
1080}