http_client_vcr/
utils.rs

1use crate::cassette::Cassette;
2use crate::filter::FilterChain;
3use crate::serializable::{SerializableRequest, SerializableResponse};
4use http_client::Error;
5use std::path::PathBuf;
6
7/// Utility function to apply filters to a cassette file and save the filtered version
8/// This is useful for batch processing cassette files without creating a VcrClient
9pub async fn filter_cassette_file<P: Into<PathBuf>>(
10    cassette_path: P,
11    filter_chain: FilterChain,
12) -> Result<(), Error> {
13    let path = cassette_path.into();
14
15    // Load the cassette
16    let mut cassette = Cassette::load_from_file(path.clone()).await?;
17
18    // Apply filters to all interactions
19    for interaction in &mut cassette.interactions {
20        filter_chain.filter_request(&mut interaction.request);
21        filter_chain.filter_response(&mut interaction.response);
22    }
23
24    // Save the filtered cassette
25    cassette.save_to_file().await?;
26
27    log::debug!(
28        "Applied filters to {} interactions in {path:?}",
29        cassette.interactions.len()
30    );
31    Ok(())
32}
33
34/// Apply a filter function to all requests in a cassette file
35/// This allows for custom mutation logic beyond the standard filter chains
36pub async fn mutate_all_requests<P, F>(cassette_path: P, mut mutator: F) -> Result<(), Error>
37where
38    P: Into<PathBuf>,
39    F: FnMut(&mut SerializableRequest),
40{
41    let path = cassette_path.into();
42    let mut cassette = Cassette::load_from_file(path.clone()).await?;
43
44    for interaction in &mut cassette.interactions {
45        mutator(&mut interaction.request);
46    }
47
48    cassette.save_to_file().await?;
49    log::debug!(
50        "Applied custom mutations to {} requests in {path:?}",
51        cassette.interactions.len()
52    );
53    Ok(())
54}
55
56/// Apply a filter function to all responses in a cassette file
57pub async fn mutate_all_responses<P, F>(cassette_path: P, mut mutator: F) -> Result<(), Error>
58where
59    P: Into<PathBuf>,
60    F: FnMut(&mut SerializableResponse),
61{
62    let path = cassette_path.into();
63    let mut cassette = Cassette::load_from_file(path.clone()).await?;
64
65    for interaction in &mut cassette.interactions {
66        mutator(&mut interaction.response);
67    }
68
69    cassette.save_to_file().await?;
70    log::debug!(
71        "Applied custom mutations to {} responses in {path:?}",
72        cassette.interactions.len()
73    );
74    Ok(())
75}
76
77/// Apply mutation functions to both requests and responses in a cassette file
78pub async fn mutate_all_interactions<P, RF, ResF>(
79    cassette_path: P,
80    mut request_mutator: RF,
81    mut response_mutator: ResF,
82) -> Result<(), Error>
83where
84    P: Into<PathBuf>,
85    RF: FnMut(&mut SerializableRequest),
86    ResF: FnMut(&mut SerializableResponse),
87{
88    let path = cassette_path.into();
89    let mut cassette = Cassette::load_from_file(path.clone()).await?;
90
91    for interaction in &mut cassette.interactions {
92        request_mutator(&mut interaction.request);
93        response_mutator(&mut interaction.response);
94    }
95
96    cassette.save_to_file().await?;
97    log::debug!(
98        "Applied custom mutations to {} interactions in {path:?}",
99        cassette.interactions.len()
100    );
101    Ok(())
102}
103
104/// Helper to remove all sensitive form data from requests using smart detection
105pub async fn strip_all_credentials_from_requests<P: Into<PathBuf>>(
106    cassette_path: P,
107) -> Result<(), Error> {
108    mutate_all_requests(cassette_path, |request| {
109        if let Some(body) = &mut request.body {
110            // Check if this looks like form data
111            if body.contains('=') && (body.contains('&') || !body.contains(' ')) {
112                let filtered = crate::form_data::filter_form_data(body, "[REMOVED]");
113                *body = filtered;
114            }
115        }
116    })
117    .await
118}
119
120/// Helper to remove all cookie headers from requests and set-cookie from responses
121pub async fn strip_all_cookies<P: Into<PathBuf>>(cassette_path: P) -> Result<(), Error> {
122    mutate_all_interactions(
123        cassette_path,
124        |request| {
125            request.headers.remove("cookie");
126            request.headers.remove("Cookie");
127        },
128        |response| {
129            response.headers.remove("set-cookie");
130            response.headers.remove("Set-Cookie");
131        },
132    )
133    .await
134}
135
136/// Replace specific field values in all form data requests
137pub async fn replace_form_field_in_all_requests<P: Into<PathBuf>>(
138    cassette_path: P,
139    field_name: &str,
140    replacement_value: &str,
141) -> Result<(), Error> {
142    let field = field_name.to_string();
143    let replacement = replacement_value.to_string();
144
145    mutate_all_requests(cassette_path, move |request| {
146        if let Some(body) = &mut request.body {
147            if body.contains('=') && (body.contains('&') || !body.contains(' ')) {
148                let mut params = crate::form_data::parse_form_data(body);
149                if params.contains_key(&field) {
150                    params.insert(field.clone(), replacement.clone());
151                    *body = crate::form_data::encode_form_data(&params);
152                }
153            }
154        }
155    })
156    .await
157}
158
159/// Remove specific header from all requests
160pub async fn remove_header_from_all_requests<P: Into<PathBuf>>(
161    cassette_path: P,
162    header_name: &str,
163) -> Result<(), Error> {
164    let header = header_name.to_string();
165
166    mutate_all_requests(cassette_path, move |request| {
167        request.headers.remove(&header);
168        // Also try lowercase version
169        request.headers.remove(&header.to_lowercase());
170    })
171    .await
172}
173
174/// Replace specific header value in all requests
175pub async fn replace_header_in_all_requests<P: Into<PathBuf>>(
176    cassette_path: P,
177    header_name: &str,
178    replacement_value: &str,
179) -> Result<(), Error> {
180    let header = header_name.to_string();
181    let replacement = replacement_value.to_string();
182
183    mutate_all_requests(cassette_path, move |request| {
184        if request.headers.contains_key(&header) {
185            request
186                .headers
187                .insert(header.clone(), vec![replacement.clone()]);
188        }
189        // Also check lowercase version
190        let header_lower = header.to_lowercase();
191        if request.headers.contains_key(&header_lower) {
192            request
193                .headers
194                .insert(header_lower, vec![replacement.clone()]);
195        }
196    })
197    .await
198}
199
200/// Scrub URLs by removing or replacing query parameters
201pub async fn scrub_urls_in_all_requests<P: Into<PathBuf>, F>(
202    cassette_path: P,
203    mut url_mutator: F,
204) -> Result<(), Error>
205where
206    F: FnMut(&str) -> String,
207{
208    mutate_all_requests(cassette_path, move |request| {
209        request.url = url_mutator(&request.url);
210    })
211    .await
212}
213
214/// Helper to replace all instances of a specific username across all requests
215pub async fn replace_username_in_all_requests<P: Into<PathBuf>>(
216    cassette_path: P,
217    new_username: &str,
218) -> Result<(), Error> {
219    let replacement = new_username.to_string();
220
221    mutate_all_requests(cassette_path, move |request| {
222        // Handle form data
223        if let Some(body) = &mut request.body {
224            if body.contains('=') && (body.contains('&') || !body.contains(' ')) {
225                let mut params = crate::form_data::parse_form_data(body);
226
227                // Look for common username fields
228                let username_fields = ["username", "user", "username_or_email", "email", "login"];
229                for field in &username_fields {
230                    if params.contains_key(*field) {
231                        params.insert(field.to_string(), replacement.clone());
232                    }
233                }
234
235                *body = crate::form_data::encode_form_data(&params);
236            }
237        }
238
239        // Handle basic auth in headers
240        if let Some(auth_headers) = request.headers.get_mut("authorization") {
241            for auth_header in auth_headers.iter_mut() {
242                if auth_header.starts_with("Basic ") {
243                    // For basic auth, we'd need to decode, replace username, re-encode
244                    // For now, just replace the whole thing
245                    *auth_header = "[FILTERED_BASIC_AUTH]".to_string();
246                }
247            }
248        }
249    })
250    .await
251}
252
253/// One-stop function to sanitize an entire cassette for sharing/testing
254pub async fn sanitize_cassette_for_sharing<P: Into<PathBuf>>(
255    cassette_path: P,
256) -> Result<(), Error> {
257    let path = cassette_path.into();
258
259    log::debug!("๐Ÿงน Sanitizing cassette for sharing: {path:?}");
260
261    // First analyze what we're dealing with
262    let analysis = analyze_cassette_file(&path).await?;
263    analysis.print_report();
264
265    log::debug!("\n๐Ÿ”ง Applying sanitization...");
266
267    // Apply comprehensive cleaning
268    mutate_all_interactions(
269        &path,
270        |request| {
271            // Clean headers
272            request.headers.remove("authorization");
273            request.headers.remove("Authorization");
274
275            // Clean form data
276            if let Some(body) = &mut request.body {
277                if body.contains('=') && (body.contains('&') || !body.contains(' ')) {
278                    *body = crate::form_data::filter_form_data(body, "[SANITIZED]");
279                }
280            }
281
282            // Clean URLs of sensitive query params
283            if let Ok(mut url) = url::Url::parse(&request.url) {
284                let sensitive_params = ["api_key", "access_token", "key"];
285                let query_pairs: Vec<(String, String)> = url
286                    .query_pairs()
287                    .filter(|(key, _)| !sensitive_params.contains(&key.as_ref()))
288                    .map(|(k, v)| (k.to_string(), v.to_string()))
289                    .collect();
290
291                url.query_pairs_mut().clear();
292                for (key, value) in query_pairs {
293                    url.query_pairs_mut().append_pair(&key, &value);
294                }
295
296                request.url = url.to_string();
297            }
298        },
299        |response| {
300            // Clean response headers
301
302            // Clean sensitive data from response bodies
303            if let Some(body) = &mut response.body {
304                // Simple replacements for common sensitive patterns
305                *body = body.replace(r#""sessionid":"[^"]*""#, r#""sessionid":"[SANITIZED]""#);
306            }
307        },
308    )
309    .await?;
310
311    log::debug!("โœ… Cassette sanitized successfully!");
312    log::debug!("๐Ÿ”’ All credentials, session data, and sensitive headers have been removed");
313
314    Ok(())
315}
316
317/// Analyze a cassette file for sensitive data without modifying it
318/// This helps identify what needs to be filtered
319pub async fn analyze_cassette_file<P: Into<PathBuf>>(
320    cassette_path: P,
321) -> Result<CassetteAnalysis, Error> {
322    let path = cassette_path.into();
323    let cassette = Cassette::load_from_file(path.clone()).await?;
324
325    let mut analysis = CassetteAnalysis {
326        file_path: path,
327        total_interactions: cassette.interactions.len(),
328        requests_with_form_data: Vec::new(),
329        requests_with_credentials: Vec::new(),
330        sensitive_headers: Vec::new(),
331    };
332
333    for (i, interaction) in cassette.interactions.iter().enumerate() {
334        // Analyze request body for form data
335        if let Some(body) = &interaction.request.body {
336            if body.contains('=') && (body.contains('&') || !body.contains(' ')) {
337                let form_analysis = crate::form_data::analyze_form_data(body);
338                if !form_analysis.credential_fields.is_empty() {
339                    analysis.requests_with_form_data.push(i);
340                    analysis
341                        .requests_with_credentials
342                        .push((i, form_analysis.credential_fields));
343                }
344            }
345        }
346
347        // Analyze headers for sensitive data
348        for (header_name, header_values) in &interaction.request.headers {
349            let header_lower = header_name.to_lowercase();
350            if header_lower.contains("cookie")
351                || header_lower.contains("authorization")
352                || header_lower.contains("token")
353            {
354                analysis
355                    .sensitive_headers
356                    .push((i, header_name.clone(), header_values.clone()));
357            }
358        }
359
360        // Also check response headers
361        for (header_name, header_values) in &interaction.response.headers {
362            let header_lower = header_name.to_lowercase();
363            if header_lower.contains("set-cookie")
364                || header_lower.contains("authorization")
365                || header_lower.contains("token")
366            {
367                analysis.sensitive_headers.push((
368                    i,
369                    format!("response-{header_name}"),
370                    header_values.clone(),
371                ));
372            }
373        }
374    }
375
376    Ok(analysis)
377}
378
379/// Replace the password in all requests with a test password
380/// This is useful when you want to use a known test password for replay
381pub async fn set_test_password_in_cassette<P: Into<PathBuf>>(
382    cassette_path: P,
383    test_password: &str,
384) -> Result<(), Error> {
385    let path = cassette_path.into();
386    let password = test_password.to_string();
387
388    log::debug!("๐Ÿ”‘ Setting test password in cassette: {path:?}");
389
390    mutate_all_requests(&path, move |request| {
391        if let Some(body) = &mut request.body {
392            if body.contains('=') && (body.contains('&') || !body.contains(' ')) {
393                let mut params = crate::form_data::parse_form_data(body);
394
395                if params.contains_key("password") {
396                    params.insert("password".to_string(), password.clone());
397                    *body = crate::form_data::encode_form_data(&params);
398                }
399            }
400        }
401    })
402    .await?;
403
404    log::debug!("โœ… Test password set in cassette");
405    Ok(())
406}
407
408/// Get the username from a cassette (useful for test setup)
409/// Returns the first username found in form data
410pub async fn extract_username_from_cassette<P: Into<PathBuf>>(
411    cassette_path: P,
412) -> Result<Option<String>, Error> {
413    let path = cassette_path.into();
414    let cassette = Cassette::load_from_file(path).await?;
415
416    for interaction in &cassette.interactions {
417        if let Some(body) = &interaction.request.body {
418            if body.contains('=') && (body.contains('&') || !body.contains(' ')) {
419                let params = crate::form_data::parse_form_data(body);
420
421                // Look for common username fields
422                let username_fields = ["username", "username_or_email", "user", "email"];
423                for field in &username_fields {
424                    if let Some(username) = params.get(*field) {
425                        // Skip filtered values
426                        if !username.starts_with("[FILTERED") && !username.starts_with("[SANITIZED")
427                        {
428                            return Ok(Some(username.clone()));
429                        }
430                    }
431                }
432            }
433        }
434    }
435
436    Ok(None)
437}
438
439#[derive(Debug)]
440pub struct CassetteAnalysis {
441    pub file_path: PathBuf,
442    pub total_interactions: usize,
443    pub requests_with_form_data: Vec<usize>,
444    pub requests_with_credentials: Vec<(usize, Vec<(String, String)>)>,
445    pub sensitive_headers: Vec<(usize, String, Vec<String>)>,
446}
447
448impl CassetteAnalysis {
449    /// Print a detailed analysis report
450    pub fn print_report(&self) {
451        log::debug!("๐Ÿ“Š Cassette Analysis Report");
452        log::debug!("=====================================");
453        log::debug!("File: {:?}", self.file_path);
454        log::debug!("Total interactions: {}", self.total_interactions);
455        log::debug!("");
456
457        if !self.requests_with_form_data.is_empty() {
458            log::debug!(
459                "๐Ÿ” Interactions with form data: {}",
460                self.requests_with_form_data.len()
461            );
462            for idx in &self.requests_with_form_data {
463                log::debug!("  - Interaction #{idx}");
464            }
465            log::debug!("");
466        }
467
468        if !self.requests_with_credentials.is_empty() {
469            log::debug!(
470                "๐Ÿ” Interactions containing credentials: {}",
471                self.requests_with_credentials.len()
472            );
473            for (idx, credentials) in &self.requests_with_credentials {
474                log::debug!(
475                    "  - Interaction #{}: {} credential fields",
476                    idx,
477                    credentials.len()
478                );
479                for (key, value) in credentials {
480                    let preview = if value.len() > 20 {
481                        format!("{}...", &value[..20])
482                    } else {
483                        value.clone()
484                    };
485                    log::debug!("    * {key}: {preview}");
486                }
487            }
488            log::debug!("");
489        }
490
491        if !self.sensitive_headers.is_empty() {
492            log::debug!(
493                "๐Ÿท๏ธ  Interactions with sensitive headers: {}",
494                self.sensitive_headers.len()
495            );
496            for (idx, header_name, header_values) in &self.sensitive_headers {
497                log::debug!("  - Interaction #{idx}: {header_name} header");
498                for value in header_values {
499                    let preview = if value.len() > 50 {
500                        format!("{}...", &value[..50])
501                    } else {
502                        value.clone()
503                    };
504                    log::debug!("    * {preview}");
505                }
506            }
507            log::debug!("");
508        }
509
510        log::debug!("๐Ÿ’ก Recommendations:");
511        if !self.requests_with_credentials.is_empty() {
512            log::debug!(
513                "  - Use SmartFormFilter to automatically detect and filter form credentials"
514            );
515        }
516        if !self.sensitive_headers.is_empty() {
517            log::debug!("  - Use HeaderFilter to filter sensitive headers like cookies and tokens");
518        }
519        if self.requests_with_form_data.is_empty() && self.sensitive_headers.is_empty() {
520            log::debug!("  - No obvious sensitive data detected, but consider reviewing manually");
521        }
522    }
523}