1#[cfg(feature = "recording")]
2use std::collections::HashMap;
3#[cfg(feature = "recording")]
4use std::path::PathBuf;
5use std::sync::Arc;
6#[cfg(feature = "recording")]
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::time::Duration;
9
10use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
11use reqwest::{Body, Client, IntoUrl, Method, RequestBuilder};
12use serde::Serialize;
13use tokio::runtime::Handle;
14
15use self::service::{HttpService, make_service};
16pub use self::token::{TemporaryToken, TokenCache};
17
18pub mod aws;
19pub mod azure;
20mod backoff;
21mod client;
22mod config;
23mod credential;
24pub mod databricks;
25mod error;
26pub mod gcp;
27pub mod service;
28
29mod retry;
30mod token;
31mod util;
32
33pub use client::{Certificate, ClientConfigKey, ClientOptions};
34pub use credential::*;
35pub use error::*;
36pub use retry::RetryConfig;
37pub use service::{ReqwestService, SpawnService};
38
39type SharedSigner = Arc<dyn RequestSigner>;
41
42#[derive(Debug)]
44struct NoopSigner;
45
46impl RequestSigner for NoopSigner {
47 fn sign<'a>(
48 &'a self,
49 req: RequestBuilder,
50 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
51 Box::pin(async move { Ok(req) })
52 }
53}
54
55#[derive(Debug)]
57struct BearerTokenSigner {
58 token: String,
59}
60
61impl RequestSigner for BearerTokenSigner {
62 fn sign<'a>(
63 &'a self,
64 req: RequestBuilder,
65 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
66 let token = self.token.clone();
67 Box::pin(async move { Ok(req.bearer_auth(&token)) })
68 }
69}
70
71#[cfg(feature = "recording")]
72#[derive(Debug, Clone)]
73struct RecordingState {
74 out_dir: PathBuf,
75 counter: Arc<AtomicU64>,
76}
77
78#[derive(Clone)]
85pub struct CloudClient {
86 signer: SharedSigner,
87 reqwest_client: Client,
88 service: Arc<dyn HttpService>,
89 pub retry_config: RetryConfig,
96 #[cfg(feature = "recording")]
97 recording: Option<RecordingState>,
98}
99
100impl CloudClient {
101 fn new_with_signer(
102 signer: SharedSigner,
103 reqwest_client: Client,
104 service: Arc<dyn HttpService>,
105 retry_config: RetryConfig,
106 ) -> Self {
107 Self {
108 signer,
109 reqwest_client,
110 service,
111 retry_config,
112 #[cfg(feature = "recording")]
113 recording: None,
114 }
115 }
116
117 pub fn new_aws<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
122 where
123 I: IntoIterator<Item = (K, V)>,
124 K: AsRef<str>,
125 V: Into<String>,
126 {
127 let config = options
128 .into_iter()
129 .fold(
130 aws::AmazonBuilder::new(),
131 |builder, (key, value)| match key.as_ref().parse() {
132 Ok(k) => builder.with_config(k, value),
133 Err(_) => builder,
134 },
135 )
136 .build(runtime)?;
137
138 let reqwest_client = config.client_options.client()?;
139 let service = make_service(reqwest_client.clone(), runtime);
140 let retry_config = config.retry_config.clone();
141 Ok(Self::new_with_signer(
142 Arc::new(config),
143 reqwest_client,
144 service,
145 retry_config,
146 ))
147 }
148
149 pub fn new_google<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
154 where
155 I: IntoIterator<Item = (K, V)>,
156 K: AsRef<str>,
157 V: Into<String>,
158 {
159 let config = options
160 .into_iter()
161 .fold(
162 gcp::GoogleBuilder::new(),
163 |builder, (key, value)| match key.as_ref().parse() {
164 Ok(k) => builder.with_config(k, value),
165 Err(_) => builder,
166 },
167 )
168 .build(runtime)?;
169
170 let reqwest_client = config.client_options.client()?;
171 let service = make_service(reqwest_client.clone(), runtime);
172 let retry_config = config.retry_config.clone();
173 Ok(Self::new_with_signer(
174 Arc::new(config),
175 reqwest_client,
176 service,
177 retry_config,
178 ))
179 }
180
181 pub fn new_azure<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
186 where
187 I: IntoIterator<Item = (K, V)>,
188 K: AsRef<str>,
189 V: Into<String>,
190 {
191 let config = options
192 .into_iter()
193 .fold(
194 azure::AzureBuilder::new(),
195 |builder, (key, value)| match key.as_ref().parse() {
196 Ok(k) => builder.with_config(k, value),
197 Err(_) => builder,
198 },
199 )
200 .build(runtime)?;
201
202 let reqwest_client = config.client_options.client()?;
203 let service = make_service(reqwest_client.clone(), runtime);
204 let retry_config = config.retry_config.clone();
205 Ok(Self::new_with_signer(
206 Arc::new(config),
207 reqwest_client,
208 service,
209 retry_config,
210 ))
211 }
212
213 pub fn new_databricks<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
218 where
219 I: IntoIterator<Item = (K, V)>,
220 K: AsRef<str>,
221 V: Into<String>,
222 {
223 use databricks::DatabricksBuilder;
224
225 let config = options
226 .into_iter()
227 .fold(
228 DatabricksBuilder::new(),
229 |builder, (key, value)| match key.as_ref().parse() {
230 Ok(k) => builder.with_config(k, value),
231 Err(_) => builder,
232 },
233 )
234 .build(runtime)?;
235
236 let reqwest_client = config.client_options.client()?;
237 let service = make_service(reqwest_client.clone(), runtime);
238 let retry_config = config.retry_config.clone();
239 Ok(Self::new_with_signer(
240 Arc::new(config),
241 reqwest_client,
242 service,
243 retry_config,
244 ))
245 }
246
247 pub fn new_with_token(token: impl ToString) -> Self {
249 let reqwest_client = Client::new();
250 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
251 Self::new_with_signer(
252 Arc::new(BearerTokenSigner {
253 token: token.to_string(),
254 }),
255 reqwest_client,
256 service,
257 RetryConfig::default(),
258 )
259 }
260
261 pub fn new_unauthenticated() -> Self {
263 let reqwest_client = Client::new();
264 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
265 Self::new_with_signer(
266 Arc::new(NoopSigner),
267 reqwest_client,
268 service,
269 RetryConfig::default(),
270 )
271 }
272
273 pub fn with_runtime(mut self, handle: Handle) -> Self {
279 self.service = Arc::new(SpawnService::new(self.service, handle));
280 self
281 }
282
283 pub fn with_http_service(mut self, service: Arc<dyn HttpService>) -> Self {
285 self.service = service;
286 self
287 }
288
289 pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> CloudRequestBuilder {
290 CloudRequestBuilder {
291 builder: self.reqwest_client.request(method, url),
292 client: self.clone(),
293 #[cfg(feature = "recording")]
294 out_dir: self.recording.as_ref().map(|r| r.out_dir.clone()),
295 }
296 }
297
298 pub fn get<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
299 self.request(Method::GET, url)
300 }
301
302 pub fn post<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
303 self.request(Method::POST, url)
304 }
305
306 pub fn put<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
307 self.request(Method::PUT, url)
308 }
309
310 pub fn delete<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
311 self.request(Method::DELETE, url)
312 }
313
314 pub fn head<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
315 self.request(Method::HEAD, url)
316 }
317
318 pub fn patch<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
319 self.request(Method::PATCH, url)
320 }
321
322 pub fn options<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
323 self.request(Method::OPTIONS, url)
324 }
325
326 pub fn trace<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
327 self.request(Method::TRACE, url)
328 }
329
330 pub fn connect<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
331 self.request(Method::CONNECT, url)
332 }
333
334 #[cfg(feature = "recording")]
335 pub fn set_recording_dir(&mut self, out_dir: std::path::PathBuf) -> Result<(), std::io::Error> {
336 let out_dir = std::fs::canonicalize(out_dir)?;
337 self.recording = Some(RecordingState {
338 out_dir,
339 counter: Arc::new(AtomicU64::new(0)),
340 });
341 Ok(())
342 }
343}
344
345pub struct CloudRequestBuilder {
346 builder: RequestBuilder,
347 client: CloudClient,
348 #[cfg(feature = "recording")]
349 out_dir: Option<PathBuf>,
350}
351
352impl CloudRequestBuilder {
353 pub fn header<K, V>(mut self, key: K, value: V) -> CloudRequestBuilder
355 where
356 HeaderName: TryFrom<K>,
357 <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
358 HeaderValue: TryFrom<V>,
359 <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
360 {
361 self.builder = self.builder.header(key, value);
362 self
363 }
364
365 pub fn headers(mut self, headers: HeaderMap) -> CloudRequestBuilder {
369 self.builder = self.builder.headers(headers);
370 self
371 }
372
373 pub fn body<T: Into<Body>>(mut self, body: T) -> CloudRequestBuilder {
375 self.builder = self.builder.body(body);
376 self
377 }
378
379 pub fn timeout(mut self, timeout: Duration) -> CloudRequestBuilder {
385 self.builder = self.builder.timeout(timeout);
386 self
387 }
388
389 pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> CloudRequestBuilder {
408 self.builder = self.builder.query(query);
409 self
410 }
411
412 pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> CloudRequestBuilder {
419 self.builder = self.builder.json(json);
420 self
421 }
422
423 pub async fn send(mut self) -> Result<reqwest::Response> {
424 self.builder = self.client.signer.sign(self.builder).await?;
425
426 #[cfg(not(feature = "recording"))]
427 {
428 let request = self.builder.build()?;
429 let response = self.client.service.call(request).await?;
430 Ok(response)
431 }
432 #[cfg(feature = "recording")]
433 {
434 let response = send_record(self).await?;
435 Ok(response)
436 }
437 }
438}
439
440#[cfg(feature = "recording")]
441#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
442pub struct RequestResponseInfo {
443 pub request: RequestInfo,
444 pub response: ResponseInfo,
445}
446
447#[cfg(feature = "recording")]
448#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
449pub struct RequestInfo {
450 pub method: String,
451 pub url_path: String,
452 pub body: Option<String>,
453}
454
455#[cfg(feature = "recording")]
456#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
457pub struct ResponseInfo {
458 pub status: u16,
459 pub headers: HashMap<String, String>,
460 pub body: Option<String>,
461}
462
463#[cfg(feature = "recording")]
468const SENSITIVE_HEADERS: &[&str] = &[
469 "authorization",
470 "x-amz-security-token",
471 "x-amz-content-sha256",
472 "x-databricks-authorization",
473 "x-ms-identity-principal-id",
474 "x-goog-iam-credentials-token",
475 "cookie",
476 "set-cookie",
477];
478
479#[cfg(feature = "recording")]
480fn redact_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
481 headers
482 .iter()
483 .map(|(k, v)| {
484 let v = if SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) {
485 "<REDACTED>".to_string()
486 } else {
487 v.clone()
488 };
489 (k.clone(), v)
490 })
491 .collect()
492}
493
494#[cfg(feature = "recording")]
495async fn send_record(builder: CloudRequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
496 let Some(out_dir) = builder.out_dir else {
497 let request = builder.builder.build().expect("request to be valid");
498 return builder.client.service.call(request).await;
499 };
500 let (_client, request) = builder.builder.build_split();
501 let request = request.expect("request to be valid");
502
503 let request_info = RequestInfo {
504 method: request.method().as_str().to_string(),
505 url_path: {
506 let url = request.url();
507 match url.query() {
508 Some(query) => format!("{}?{}", url.path(), query),
509 None => url.path().to_string(),
510 }
511 },
512 body: request
513 .body()
514 .and_then(|b| b.as_bytes().map(|b| String::from_utf8_lossy(b).to_string())),
515 };
516
517 let response = builder.client.service.call(request).await?;
518
519 let status = response.status().as_u16();
521 let raw_headers: HashMap<String, String> = response
522 .headers()
523 .iter()
524 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
525 .collect();
526
527 let response_bytes = response.bytes().await?;
529 let response_body = if response_bytes.is_empty() {
530 None
531 } else {
532 Some(String::from_utf8_lossy(&response_bytes).to_string())
533 };
534
535 let recording = RequestResponseInfo {
536 request: request_info,
537 response: ResponseInfo {
538 status,
539 headers: redact_headers(&raw_headers),
541 body: response_body,
542 },
543 };
544
545 let counter = builder
546 .client
547 .recording
548 .as_ref()
549 .map(|r| r.counter.fetch_add(1, Ordering::SeqCst))
550 .unwrap_or(0);
551 let file_path = out_dir.join(format!("{:04}.json", counter));
552 if let Err(e) = std::fs::File::create(&file_path)
553 .and_then(|f| serde_json::to_writer_pretty(f, &recording).map_err(Into::into))
554 {
555 tracing::warn!(
556 "Failed to write recording to {}: {}",
557 file_path.display(),
558 e
559 );
560 }
561
562 let mut mock_response = http::Response::builder().status(status);
564 for (k, v) in &raw_headers {
565 mock_response = mock_response.header(k, v);
566 }
567 let mock_response = mock_response
568 .body(response_bytes)
569 .expect("valid status code and headers");
570
571 Ok(reqwest::Response::from(mock_response))
572}
573
574#[cfg(all(test, feature = "recording"))]
575mod tests {
576 use super::*;
577 use std::fs;
578 use tempfile::TempDir;
579
580 #[tokio::test]
581 async fn test_request_response_recording() {
582 let temp_dir = TempDir::new().unwrap();
584 let temp_path = temp_dir.path().to_path_buf();
585
586 let mut server = mockito::Server::new_async().await;
588 let mock = server
589 .mock("GET", "/test")
590 .with_status(200)
591 .with_header("content-type", "application/json")
592 .with_body(r#"{"message": "Hello, World!"}"#)
593 .create_async()
594 .await;
595
596 let mut client = CloudClient::new_unauthenticated();
598 client.set_recording_dir(temp_path.clone()).unwrap();
599
600 let url = format!("{}/test", server.url());
602 let response = client.get(&url).send().await.unwrap();
603
604 assert_eq!(response.status(), 200);
606 let body = response.text().await.unwrap();
607 assert_eq!(body, r#"{"message": "Hello, World!"}"#);
608
609 let recordings: Vec<_> = fs::read_dir(&temp_path)
611 .unwrap()
612 .filter_map(|entry| {
613 let entry = entry.ok()?;
614 let path = entry.path();
615 if path.extension()? == "json" {
616 Some(path)
617 } else {
618 None
619 }
620 })
621 .collect();
622
623 assert_eq!(recordings.len(), 1, "Expected exactly one recording file");
624
625 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
627 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
628
629 assert_eq!(recording.request.method, "GET");
631 assert_eq!(recording.request.url_path, "/test");
632 assert_eq!(recording.request.body, None);
633
634 assert_eq!(recording.response.status, 200);
636 assert_eq!(
637 recording.response.headers.get("content-type").unwrap(),
638 "application/json"
639 );
640 assert_eq!(
641 recording.response.body.as_ref().unwrap(),
642 r#"{"message": "Hello, World!"}"#
643 );
644
645 mock.assert_async().await;
646 }
647
648 #[tokio::test]
649 async fn test_recording_with_request_body() {
650 let temp_dir = TempDir::new().unwrap();
652 let temp_path = temp_dir.path().to_path_buf();
653
654 let mut server = mockito::Server::new_async().await;
656 let mock = server
657 .mock("POST", "/create")
658 .with_status(201)
659 .with_header("location", "/resource/123")
660 .with_body(r#"{"id": 123, "status": "created"}"#)
661 .create_async()
662 .await;
663
664 let mut client = CloudClient::new_unauthenticated();
666 client.set_recording_dir(temp_path.clone()).unwrap();
667
668 let url = format!("{}/create", server.url());
670 let response = client
671 .post(&url)
672 .json(&serde_json::json!({"name": "test resource"}))
673 .send()
674 .await
675 .unwrap();
676
677 assert_eq!(response.status(), 201);
679
680 let recordings: Vec<_> = fs::read_dir(&temp_path)
682 .unwrap()
683 .filter_map(|entry| {
684 let entry = entry.ok()?;
685 let path = entry.path();
686 if path.extension()? == "json" {
687 Some(path)
688 } else {
689 None
690 }
691 })
692 .collect();
693
694 assert_eq!(recordings.len(), 1);
695
696 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
698 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
699
700 assert_eq!(recording.request.method, "POST");
702 assert_eq!(recording.request.url_path, "/create");
703 assert!(recording.request.body.is_some());
704 assert!(recording.request.body.unwrap().contains("test resource"));
705
706 assert_eq!(recording.response.status, 201);
708 assert_eq!(
709 recording.response.headers.get("location").unwrap(),
710 "/resource/123"
711 );
712 assert!(recording.response.body.unwrap().contains("created"));
713
714 mock.assert_async().await;
715 }
716
717 #[tokio::test]
718 async fn test_counter_based_file_naming() {
719 let temp_dir = TempDir::new().unwrap();
721 let temp_path = temp_dir.path().to_path_buf();
722
723 let mut server = mockito::Server::new_async().await;
725 let mock1 = server
726 .mock("GET", "/first")
727 .with_status(200)
728 .with_body("first response")
729 .create_async()
730 .await;
731 let mock2 = server
732 .mock("GET", "/second")
733 .with_status(200)
734 .with_body("second response")
735 .create_async()
736 .await;
737
738 let mut client = CloudClient::new_unauthenticated();
740 client.set_recording_dir(temp_path.clone()).unwrap();
741
742 let url1 = format!("{}/first", server.url());
744 let url2 = format!("{}/second", server.url());
745
746 let _response1 = client.get(&url1).send().await.unwrap();
747 let _response2 = client.get(&url2).send().await.unwrap();
748
749 let mut recordings: Vec<_> = fs::read_dir(&temp_path)
751 .unwrap()
752 .filter_map(|entry| {
753 let entry = entry.ok()?;
754 let path = entry.path();
755 if path.extension()? == "json" {
756 Some(path)
757 } else {
758 None
759 }
760 })
761 .collect();
762
763 recordings.sort();
764 assert_eq!(recordings.len(), 2);
765
766 assert!(recordings[0].file_name().unwrap().to_str().unwrap() == "0000.json");
768 assert!(recordings[1].file_name().unwrap().to_str().unwrap() == "0001.json");
769
770 let first_content = fs::read_to_string(&recordings[0]).unwrap();
772 let first_recording: RequestResponseInfo = serde_json::from_str(&first_content).unwrap();
773 assert_eq!(first_recording.request.url_path, "/first");
774 assert_eq!(
775 first_recording.response.body.as_ref().unwrap(),
776 "first response"
777 );
778
779 let second_content = fs::read_to_string(&recordings[1]).unwrap();
780 let second_recording: RequestResponseInfo = serde_json::from_str(&second_content).unwrap();
781 assert_eq!(second_recording.request.url_path, "/second");
782 assert_eq!(
783 second_recording.response.body.as_ref().unwrap(),
784 "second response"
785 );
786
787 mock1.assert_async().await;
788 mock2.assert_async().await;
789 }
790
791 #[tokio::test]
792 async fn test_query_parameter_recording() {
793 let temp_dir = TempDir::new().unwrap();
795 let temp_path = temp_dir.path().to_path_buf();
796
797 let mut server = mockito::Server::new_async().await;
799
800 let mock = server
802 .mock("GET", "/catalogs?max_results=10&page_token=abc123")
803 .with_status(200)
804 .with_header("content-type", "application/json")
805 .with_body(r#"{"catalogs": []}"#)
806 .create_async()
807 .await;
808
809 let mut client = CloudClient::new_unauthenticated();
811 client.set_recording_dir(temp_path.clone()).unwrap();
812
813 let url = format!("{}/catalogs?max_results=10&page_token=abc123", server.url());
815 let response = client.get(&url).send().await.unwrap();
816
817 assert!(response.status().is_success());
818
819 let recordings: Vec<_> = fs::read_dir(&temp_path)
821 .unwrap()
822 .filter_map(|entry| {
823 let entry = entry.ok()?;
824 let path = entry.path();
825 if path.extension()? == "json" {
826 Some(path)
827 } else {
828 None
829 }
830 })
831 .collect();
832
833 assert_eq!(recordings.len(), 1);
834
835 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
837 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
838
839 assert_eq!(recording.request.method, "GET");
841 assert_eq!(
842 recording.request.url_path,
843 "/catalogs?max_results=10&page_token=abc123"
844 );
845 assert_eq!(recording.request.body, None);
846
847 assert_eq!(recording.response.status, 200);
849 assert_eq!(
850 recording.response.body.as_ref().unwrap(),
851 r#"{"catalogs": []}"#
852 );
853
854 mock.assert_async().await;
855 }
856
857 #[tokio::test]
862 async fn test_recording_does_not_contain_bearer_token_value() {
863 let temp_dir = TempDir::new().unwrap();
864 let temp_path = temp_dir.path().to_path_buf();
865
866 let mut server = mockito::Server::new_async().await;
867 let mock = server
868 .mock("GET", "/secret")
869 .match_header("authorization", mockito::Matcher::Any)
870 .with_status(200)
871 .with_body("ok")
873 .create_async()
874 .await;
875
876 let mut client = CloudClient::new_with_token("super-secret-token-12345");
877 client.set_recording_dir(temp_path.clone()).unwrap();
878
879 let url = format!("{}/secret", server.url());
880 client.get(&url).send().await.unwrap();
881
882 let recording_path = temp_path.join("0000.json");
883 let content = fs::read_to_string(&recording_path).unwrap();
884
885 assert!(
887 !content.contains("super-secret-token-12345"),
888 "raw bearer token leaked into recording: {content}"
889 );
890
891 mock.assert_async().await;
892 }
893
894 #[tokio::test]
898 async fn test_recording_redacts_sensitive_response_headers() {
899 let temp_dir = TempDir::new().unwrap();
900 let temp_path = temp_dir.path().to_path_buf();
901
902 let mut server = mockito::Server::new_async().await;
903 let mock = server
905 .mock("GET", "/s3")
906 .with_status(200)
907 .with_header("x-amz-security-token", "AQoXnyc4LLI2AJvUAMOGAR8a1234567890")
908 .with_header("authorization", "Bearer should-be-redacted")
909 .with_body("{}")
910 .create_async()
911 .await;
912
913 let mut client = CloudClient::new_unauthenticated();
914 client.set_recording_dir(temp_path.clone()).unwrap();
915
916 let url = format!("{}/s3", server.url());
917 client.get(&url).send().await.unwrap();
918
919 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
920 assert!(
921 !content.contains("AQoXnyc4LLI2AJvUAMOGAR8a1234567890"),
922 "x-amz-security-token leaked into recording: {content}"
923 );
924 assert!(
925 !content.contains("should-be-redacted"),
926 "Authorization value leaked into recording: {content}"
927 );
928 assert_eq!(
930 content.matches("<REDACTED>").count(),
931 2,
932 "expected 2 <REDACTED> entries in recording: {content}"
933 );
934
935 mock.assert_async().await;
936 }
937
938 #[tokio::test]
940 async fn test_recording_remains_valid_json_after_redaction() {
941 let temp_dir = TempDir::new().unwrap();
942 let temp_path = temp_dir.path().to_path_buf();
943
944 let mut server = mockito::Server::new_async().await;
945 let _mock = server
946 .mock("GET", "/check")
947 .with_status(200)
948 .with_header("authorization", "Bearer should-be-redacted")
949 .with_body(r#"{"ok":true}"#)
950 .create_async()
951 .await;
952
953 let mut client = CloudClient::new_unauthenticated();
954 client.set_recording_dir(temp_path.clone()).unwrap();
955
956 let url = format!("{}/check", server.url());
957 client.get(&url).send().await.unwrap();
958
959 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
960 let parsed: RequestResponseInfo = serde_json::from_str(&content)
962 .expect("recording file must be valid JSON even after redaction");
963 assert_eq!(parsed.response.status, 200);
964 }
965}