1use crate::filter::Filter;
2use crate::model::{Capture, Entry};
3use crate::redact::{redact_body, redact_header_value, redact_url};
4use serde::Serialize;
5
6const MUTATING_METHODS: &[&str] = &["POST", "PUT", "PATCH", "DELETE"];
7const RISKY_KEYWORDS: &[&str] = &[
8 "payment",
9 "pay",
10 "order",
11 "checkout",
12 "charge",
13 "refund",
14 "subscription",
15];
16const BODY_MAX: usize = 4000;
17
18#[derive(Debug, Serialize)]
19pub struct CurlResult {
20 pub commands: Vec<CurlCommand>,
21}
22
23#[derive(Debug, Serialize)]
24pub struct CurlCommand {
25 pub id: String,
26 pub safe: bool,
27 pub label: String,
28 pub command: String,
29}
30
31pub fn entry_to_curl(e: &Entry, unsafe_include: bool) -> CurlCommand {
33 let method = e.method.to_ascii_uppercase();
34 let url = redact_url(&e.url, unsafe_include);
35
36 let mut parts = vec![format!("curl -X {method} '{url}'")];
37 for (k, v) in &e.req_headers {
38 if k.starts_with(':') {
39 continue; }
41 let rv = redact_header_value(k, v, unsafe_include);
42 parts.push(format!(" -H '{k}: {rv}'"));
43 }
44 if let Some(body) = e.req_body.as_deref().filter(|b| !b.is_empty()) {
45 let rb = redact_body(body, unsafe_include, BODY_MAX);
46 parts.push(format!(" --data '{rb}'"));
47 }
48
49 let (safe, label) = safety(&method, &e.norm_path);
50 CurlCommand {
51 id: e.id.clone(),
52 safe,
53 label,
54 command: parts.join(" \\\n"),
55 }
56}
57
58pub fn compute_curl(
60 cap: &Capture,
61 filter: &Filter,
62 top: usize,
63 unsafe_include: bool,
64) -> CurlResult {
65 let commands: Vec<CurlCommand> = cap
66 .entries
67 .iter()
68 .filter(|e| filter.matches(e))
69 .take(top)
70 .map(|e| entry_to_curl(e, unsafe_include))
71 .collect();
72 CurlResult { commands }
73}
74
75fn safety(method: &str, norm_path: &str) -> (bool, String) {
76 let lp = norm_path.to_ascii_lowercase();
77 if RISKY_KEYWORDS.iter().any(|k| lp.contains(k)) {
78 return (false, "payment/order endpoint".to_string());
79 }
80 if MUTATING_METHODS.contains(&method) {
81 return (false, format!("mutating method {method}"));
82 }
83 (true, "safe".to_string())
84}
85
86pub fn render_curl_text(r: &CurlResult) -> String {
88 let mut out = String::new();
89 out.push_str("== wiretrail curl ==\n");
90 for c in &r.commands {
91 let tag = if c.safe { "SAFE" } else { "UNSAFE" };
92 out.push_str(&format!(
93 "\n# {} [{}: {}]\n{}\n",
94 c.id, tag, c.label, c.command
95 ));
96 }
97 out
98}
99
100#[cfg(test)]
101mod tests {
102 use super::{compute_curl, entry_to_curl};
103 use crate::filter::Filter;
104 use crate::model::{sample_capture, sample_entry};
105
106 #[test]
107 fn get_is_safe_and_redacts_auth() {
108 let mut e = sample_entry(0, "api.x", "GET", "/data", 200);
109 e.req_headers = vec![
110 (":method".into(), "GET".into()), ("Authorization".into(), "Bearer secret".into()),
112 ("Accept".into(), "application/json".into()),
113 ];
114 let c = entry_to_curl(&e, false);
115 assert!(c.safe);
116 assert_eq!(c.label, "safe");
117 assert!(c.command.starts_with("curl -X GET 'https://api.x/data'"));
118 assert!(c.command.contains("Authorization: <redacted>"));
119 assert!(c.command.contains("Accept: application/json"));
120 assert!(!c.command.contains(":method"));
121 }
122
123 #[test]
124 fn redacts_query_in_url() {
125 let mut e = sample_entry(0, "api.x", "GET", "/data", 200);
126 e.url = "https://api.x/data?access_token=leak&page=2".into();
127 let c = entry_to_curl(&e, false);
128 assert!(!c.command.contains("leak"));
129 assert!(c.command.contains("page=2"));
130 }
131
132 #[test]
133 fn mutating_method_is_unsafe() {
134 let e = sample_entry(0, "api.x", "POST", "/things", 200);
135 let c = entry_to_curl(&e, false);
136 assert!(!c.safe);
137 assert!(c.label.contains("mutating"));
138 }
139
140 #[test]
141 fn payment_path_is_unsafe_even_for_get() {
142 let e = sample_entry(0, "api.x", "GET", "/v1/payment/charge", 200);
143 let c = entry_to_curl(&e, false);
144 assert!(!c.safe);
145 assert!(c.label.contains("payment"));
146 }
147
148 #[test]
149 fn unsafe_flag_shows_raw_auth() {
150 let mut e = sample_entry(0, "api.x", "GET", "/data", 200);
151 e.req_headers = vec![("Authorization".into(), "Bearer secret".into())];
152 let c = entry_to_curl(&e, true);
153 assert!(c.command.contains("Bearer secret"));
154 }
155
156 #[test]
157 fn compute_curl_bounds_by_top() {
158 let cap = sample_capture(vec![
159 sample_entry(0, "h", "GET", "/a", 200),
160 sample_entry(1, "h", "GET", "/b", 200),
161 sample_entry(2, "h", "GET", "/c", 200),
162 ]);
163 let r = compute_curl(&cap, &Filter::parse(&[]).unwrap(), 2, false);
164 assert_eq!(r.commands.len(), 2);
165 }
166}