1#[cfg(feature = "recording")]
52use std::collections::HashMap;
53#[cfg(feature = "recording")]
54use std::path::PathBuf;
55use std::sync::Arc;
56#[cfg(feature = "recording")]
57use std::sync::atomic::{AtomicU64, Ordering};
58use std::time::Duration;
59
60use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
61use reqwest::{Body, Client, IntoUrl, Method, RequestBuilder};
62use serde::Serialize;
63use tokio::runtime::Handle;
64
65use self::service::{HttpService, make_service};
66pub use self::token::{TemporaryToken, TokenCache};
67
68pub mod aws;
69pub mod azure;
70mod backoff;
71mod client;
72mod config;
73mod credential;
74pub mod databricks;
75mod error;
76pub mod gcp;
77pub mod service;
78
79mod retry;
80mod token;
81mod util;
82
83pub use client::{Certificate, ClientConfigKey, ClientOptions};
84pub use credential::*;
85pub use error::*;
86pub use retry::RetryConfig;
87pub use service::{ReqwestService, SpawnService};
88
89type SharedSigner = Arc<dyn RequestSigner>;
91
92#[derive(Debug)]
94struct NoopSigner;
95
96impl RequestSigner for NoopSigner {
97 fn sign<'a>(
98 &'a self,
99 req: RequestBuilder,
100 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
101 Box::pin(async move { Ok(req) })
102 }
103}
104
105#[derive(Debug)]
107struct BearerTokenSigner {
108 token: String,
109}
110
111impl RequestSigner for BearerTokenSigner {
112 fn sign<'a>(
113 &'a self,
114 req: RequestBuilder,
115 ) -> futures::future::BoxFuture<'a, Result<RequestBuilder>> {
116 let token = self.token.clone();
117 Box::pin(async move { Ok(req.bearer_auth(&token)) })
118 }
119}
120
121#[cfg(feature = "recording")]
122#[derive(Debug, Clone)]
123struct RecordingState {
124 out_dir: PathBuf,
125 counter: Arc<AtomicU64>,
126}
127
128#[derive(Clone)]
135pub struct CloudClient {
136 signer: SharedSigner,
137 reqwest_client: Client,
138 service: Arc<dyn HttpService>,
139 pub retry_config: RetryConfig,
146 #[cfg(feature = "recording")]
147 recording: Option<RecordingState>,
148}
149
150impl CloudClient {
151 fn new_with_signer(
152 signer: SharedSigner,
153 reqwest_client: Client,
154 service: Arc<dyn HttpService>,
155 retry_config: RetryConfig,
156 ) -> Self {
157 Self {
158 signer,
159 reqwest_client,
160 service,
161 retry_config,
162 #[cfg(feature = "recording")]
163 recording: None,
164 }
165 }
166
167 pub fn new_aws<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
172 where
173 I: IntoIterator<Item = (K, V)>,
174 K: AsRef<str>,
175 V: Into<String>,
176 {
177 let config = options
178 .into_iter()
179 .fold(
180 aws::AmazonBuilder::new(),
181 |builder, (key, value)| match key.as_ref().parse() {
182 Ok(k) => builder.with_config(k, value),
183 Err(_) => builder,
184 },
185 )
186 .build(runtime)?;
187
188 let reqwest_client = config.client_options.client()?;
189 let service = make_service(reqwest_client.clone(), runtime);
190 let retry_config = config.retry_config.clone();
191 Ok(Self::new_with_signer(
192 Arc::new(config),
193 reqwest_client,
194 service,
195 retry_config,
196 ))
197 }
198
199 pub fn new_google<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
204 where
205 I: IntoIterator<Item = (K, V)>,
206 K: AsRef<str>,
207 V: Into<String>,
208 {
209 let config = options
210 .into_iter()
211 .fold(
212 gcp::GoogleBuilder::new(),
213 |builder, (key, value)| match key.as_ref().parse() {
214 Ok(k) => builder.with_config(k, value),
215 Err(_) => builder,
216 },
217 )
218 .build(runtime)?;
219
220 let reqwest_client = config.client_options.client()?;
221 let service = make_service(reqwest_client.clone(), runtime);
222 let retry_config = config.retry_config.clone();
223 Ok(Self::new_with_signer(
224 Arc::new(config),
225 reqwest_client,
226 service,
227 retry_config,
228 ))
229 }
230
231 pub fn new_azure<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
236 where
237 I: IntoIterator<Item = (K, V)>,
238 K: AsRef<str>,
239 V: Into<String>,
240 {
241 let config = options
242 .into_iter()
243 .fold(
244 azure::AzureBuilder::new(),
245 |builder, (key, value)| match key.as_ref().parse() {
246 Ok(k) => builder.with_config(k, value),
247 Err(_) => builder,
248 },
249 )
250 .build(runtime)?;
251
252 let reqwest_client = config.client_options.client()?;
253 let service = make_service(reqwest_client.clone(), runtime);
254 let retry_config = config.retry_config.clone();
255 Ok(Self::new_with_signer(
256 Arc::new(config),
257 reqwest_client,
258 service,
259 retry_config,
260 ))
261 }
262
263 pub fn new_databricks<I, K, V>(options: I, runtime: Option<&Handle>) -> Result<Self>
268 where
269 I: IntoIterator<Item = (K, V)>,
270 K: AsRef<str>,
271 V: Into<String>,
272 {
273 use databricks::DatabricksBuilder;
274
275 let config = options
276 .into_iter()
277 .fold(
278 DatabricksBuilder::new(),
279 |builder, (key, value)| match key.as_ref().parse() {
280 Ok(k) => builder.with_config(k, value),
281 Err(_) => builder,
282 },
283 )
284 .build(runtime)?;
285
286 let reqwest_client = config.client_options.client()?;
287 let service = make_service(reqwest_client.clone(), runtime);
288 let retry_config = config.retry_config.clone();
289 Ok(Self::new_with_signer(
290 Arc::new(config),
291 reqwest_client,
292 service,
293 retry_config,
294 ))
295 }
296
297 pub fn new_with_token(token: impl ToString) -> Self {
299 let reqwest_client = Client::new();
300 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
301 Self::new_with_signer(
302 Arc::new(BearerTokenSigner {
303 token: token.to_string(),
304 }),
305 reqwest_client,
306 service,
307 RetryConfig::default(),
308 )
309 }
310
311 pub fn new_unauthenticated() -> Self {
313 let reqwest_client = Client::new();
314 let service: Arc<dyn HttpService> = Arc::new(ReqwestService::new(reqwest_client.clone()));
315 Self::new_with_signer(
316 Arc::new(NoopSigner),
317 reqwest_client,
318 service,
319 RetryConfig::default(),
320 )
321 }
322
323 pub fn with_runtime(mut self, handle: Handle) -> Self {
329 self.service = Arc::new(SpawnService::new(self.service, handle));
330 self
331 }
332
333 pub fn with_http_service(mut self, service: Arc<dyn HttpService>) -> Self {
335 self.service = service;
336 self
337 }
338
339 pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> CloudRequestBuilder {
345 CloudRequestBuilder {
346 builder: self.reqwest_client.request(method, url),
347 client: self.clone(),
348 #[cfg(feature = "recording")]
349 out_dir: self.recording.as_ref().map(|r| r.out_dir.clone()),
350 }
351 }
352
353 pub fn get<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
355 self.request(Method::GET, url)
356 }
357
358 pub fn post<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
360 self.request(Method::POST, url)
361 }
362
363 pub fn put<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
365 self.request(Method::PUT, url)
366 }
367
368 pub fn delete<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
370 self.request(Method::DELETE, url)
371 }
372
373 pub fn head<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
375 self.request(Method::HEAD, url)
376 }
377
378 pub fn patch<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
380 self.request(Method::PATCH, url)
381 }
382
383 pub fn options<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
385 self.request(Method::OPTIONS, url)
386 }
387
388 pub fn trace<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
390 self.request(Method::TRACE, url)
391 }
392
393 pub fn connect<U: IntoUrl>(&self, url: U) -> CloudRequestBuilder {
395 self.request(Method::CONNECT, url)
396 }
397
398 #[cfg(feature = "recording")]
417 pub fn set_recording_dir(&mut self, out_dir: std::path::PathBuf) -> Result<(), std::io::Error> {
418 let out_dir = std::fs::canonicalize(out_dir)?;
419 self.recording = Some(RecordingState {
420 out_dir,
421 counter: Arc::new(AtomicU64::new(0)),
422 });
423 Ok(())
424 }
425}
426
427pub struct CloudRequestBuilder {
433 builder: RequestBuilder,
434 client: CloudClient,
435 #[cfg(feature = "recording")]
436 out_dir: Option<PathBuf>,
437}
438
439impl CloudRequestBuilder {
440 pub fn header<K, V>(mut self, key: K, value: V) -> CloudRequestBuilder
442 where
443 HeaderName: TryFrom<K>,
444 <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
445 HeaderValue: TryFrom<V>,
446 <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
447 {
448 self.builder = self.builder.header(key, value);
449 self
450 }
451
452 pub fn headers(mut self, headers: HeaderMap) -> CloudRequestBuilder {
456 self.builder = self.builder.headers(headers);
457 self
458 }
459
460 pub fn body<T: Into<Body>>(mut self, body: T) -> CloudRequestBuilder {
462 self.builder = self.builder.body(body);
463 self
464 }
465
466 pub fn timeout(mut self, timeout: Duration) -> CloudRequestBuilder {
472 self.builder = self.builder.timeout(timeout);
473 self
474 }
475
476 pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> CloudRequestBuilder {
495 self.builder = self.builder.query(query);
496 self
497 }
498
499 pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> CloudRequestBuilder {
506 self.builder = self.builder.json(json);
507 self
508 }
509
510 pub async fn send(mut self) -> Result<reqwest::Response> {
530 self.builder = self.client.signer.sign(self.builder).await?;
531
532 #[cfg(not(feature = "recording"))]
533 {
534 let request = self.builder.build()?;
535 let response = self.client.service.call(request).await?;
536 Ok(response)
537 }
538 #[cfg(feature = "recording")]
539 {
540 let response = send_record(self).await?;
541 Ok(response)
542 }
543 }
544}
545
546#[cfg(feature = "recording")]
547#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
548pub struct RequestResponseInfo {
549 pub request: RequestInfo,
550 pub response: ResponseInfo,
551}
552
553#[cfg(feature = "recording")]
554#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
555pub struct RequestInfo {
556 pub method: String,
557 pub url_path: String,
558 pub body: Option<String>,
559}
560
561#[cfg(feature = "recording")]
562#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
563pub struct ResponseInfo {
564 pub status: u16,
565 pub headers: HashMap<String, String>,
566 pub body: Option<String>,
567}
568
569#[cfg(feature = "recording")]
574const SENSITIVE_HEADERS: &[&str] = &[
575 "authorization",
576 "x-amz-security-token",
577 "x-amz-content-sha256",
578 "x-databricks-authorization",
579 "x-ms-identity-principal-id",
580 "x-goog-iam-credentials-token",
581 "cookie",
582 "set-cookie",
583];
584
585#[cfg(feature = "recording")]
586fn redact_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
587 headers
588 .iter()
589 .map(|(k, v)| {
590 let v = if SENSITIVE_HEADERS.contains(&k.to_lowercase().as_str()) {
591 "<REDACTED>".to_string()
592 } else {
593 v.clone()
594 };
595 (k.clone(), v)
596 })
597 .collect()
598}
599
600#[cfg(feature = "recording")]
601async fn send_record(builder: CloudRequestBuilder) -> Result<reqwest::Response> {
602 let Some(out_dir) = builder.out_dir else {
603 let request = builder.builder.build().expect("request to be valid");
604 return builder.client.service.call(request).await;
605 };
606 let (_client, request) = builder.builder.build_split();
607 let request = request.expect("request to be valid");
608
609 let request_info = RequestInfo {
610 method: request.method().as_str().to_string(),
611 url_path: {
612 let url = request.url();
613 match url.query() {
614 Some(query) => format!("{}?{}", url.path(), query),
615 None => url.path().to_string(),
616 }
617 },
618 body: request
619 .body()
620 .and_then(|b| b.as_bytes().map(|b| String::from_utf8_lossy(b).to_string())),
621 };
622
623 let response = builder.client.service.call(request).await?;
624
625 let status = response.status().as_u16();
627 let raw_headers: HashMap<String, String> = response
628 .headers()
629 .iter()
630 .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
631 .collect();
632
633 let response_bytes = response.bytes().await?;
635 let response_body = if response_bytes.is_empty() {
636 None
637 } else {
638 Some(String::from_utf8_lossy(&response_bytes).to_string())
639 };
640
641 let recording = RequestResponseInfo {
642 request: request_info,
643 response: ResponseInfo {
644 status,
645 headers: redact_headers(&raw_headers),
647 body: response_body,
648 },
649 };
650
651 let counter = builder
652 .client
653 .recording
654 .as_ref()
655 .map(|r| r.counter.fetch_add(1, Ordering::SeqCst))
656 .unwrap_or(0);
657 let file_path = out_dir.join(format!("{:04}.json", counter));
658 if let Err(e) = std::fs::File::create(&file_path)
659 .and_then(|f| serde_json::to_writer_pretty(f, &recording).map_err(Into::into))
660 {
661 tracing::warn!(
662 "Failed to write recording to {}: {}",
663 file_path.display(),
664 e
665 );
666 }
667
668 let mut mock_response = http::Response::builder().status(status);
670 for (k, v) in &raw_headers {
671 mock_response = mock_response.header(k, v);
672 }
673 let mock_response = mock_response
674 .body(response_bytes)
675 .expect("valid status code and headers");
676
677 Ok(reqwest::Response::from(mock_response))
678}
679
680#[cfg(all(test, feature = "recording"))]
681mod tests {
682 use super::*;
683 use std::fs;
684 use tempfile::TempDir;
685
686 #[tokio::test]
687 async fn test_request_response_recording() {
688 let temp_dir = TempDir::new().unwrap();
690 let temp_path = temp_dir.path().to_path_buf();
691
692 let mut server = mockito::Server::new_async().await;
694 let mock = server
695 .mock("GET", "/test")
696 .with_status(200)
697 .with_header("content-type", "application/json")
698 .with_body(r#"{"message": "Hello, World!"}"#)
699 .create_async()
700 .await;
701
702 let mut client = CloudClient::new_unauthenticated();
704 client.set_recording_dir(temp_path.clone()).unwrap();
705
706 let url = format!("{}/test", server.url());
708 let response = client.get(&url).send().await.unwrap();
709
710 assert_eq!(response.status(), 200);
712 let body = response.text().await.unwrap();
713 assert_eq!(body, r#"{"message": "Hello, World!"}"#);
714
715 let recordings: Vec<_> = fs::read_dir(&temp_path)
717 .unwrap()
718 .filter_map(|entry| {
719 let entry = entry.ok()?;
720 let path = entry.path();
721 if path.extension()? == "json" {
722 Some(path)
723 } else {
724 None
725 }
726 })
727 .collect();
728
729 assert_eq!(recordings.len(), 1, "Expected exactly one recording file");
730
731 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
733 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
734
735 assert_eq!(recording.request.method, "GET");
737 assert_eq!(recording.request.url_path, "/test");
738 assert_eq!(recording.request.body, None);
739
740 assert_eq!(recording.response.status, 200);
742 assert_eq!(
743 recording.response.headers.get("content-type").unwrap(),
744 "application/json"
745 );
746 assert_eq!(
747 recording.response.body.as_ref().unwrap(),
748 r#"{"message": "Hello, World!"}"#
749 );
750
751 mock.assert_async().await;
752 }
753
754 #[tokio::test]
755 async fn test_recording_with_request_body() {
756 let temp_dir = TempDir::new().unwrap();
758 let temp_path = temp_dir.path().to_path_buf();
759
760 let mut server = mockito::Server::new_async().await;
762 let mock = server
763 .mock("POST", "/create")
764 .with_status(201)
765 .with_header("location", "/resource/123")
766 .with_body(r#"{"id": 123, "status": "created"}"#)
767 .create_async()
768 .await;
769
770 let mut client = CloudClient::new_unauthenticated();
772 client.set_recording_dir(temp_path.clone()).unwrap();
773
774 let url = format!("{}/create", server.url());
776 let response = client
777 .post(&url)
778 .json(&serde_json::json!({"name": "test resource"}))
779 .send()
780 .await
781 .unwrap();
782
783 assert_eq!(response.status(), 201);
785
786 let recordings: Vec<_> = fs::read_dir(&temp_path)
788 .unwrap()
789 .filter_map(|entry| {
790 let entry = entry.ok()?;
791 let path = entry.path();
792 if path.extension()? == "json" {
793 Some(path)
794 } else {
795 None
796 }
797 })
798 .collect();
799
800 assert_eq!(recordings.len(), 1);
801
802 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
804 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
805
806 assert_eq!(recording.request.method, "POST");
808 assert_eq!(recording.request.url_path, "/create");
809 assert!(recording.request.body.is_some());
810 assert!(recording.request.body.unwrap().contains("test resource"));
811
812 assert_eq!(recording.response.status, 201);
814 assert_eq!(
815 recording.response.headers.get("location").unwrap(),
816 "/resource/123"
817 );
818 assert!(recording.response.body.unwrap().contains("created"));
819
820 mock.assert_async().await;
821 }
822
823 #[tokio::test]
824 async fn test_counter_based_file_naming() {
825 let temp_dir = TempDir::new().unwrap();
827 let temp_path = temp_dir.path().to_path_buf();
828
829 let mut server = mockito::Server::new_async().await;
831 let mock1 = server
832 .mock("GET", "/first")
833 .with_status(200)
834 .with_body("first response")
835 .create_async()
836 .await;
837 let mock2 = server
838 .mock("GET", "/second")
839 .with_status(200)
840 .with_body("second response")
841 .create_async()
842 .await;
843
844 let mut client = CloudClient::new_unauthenticated();
846 client.set_recording_dir(temp_path.clone()).unwrap();
847
848 let url1 = format!("{}/first", server.url());
850 let url2 = format!("{}/second", server.url());
851
852 let _response1 = client.get(&url1).send().await.unwrap();
853 let _response2 = client.get(&url2).send().await.unwrap();
854
855 let mut recordings: Vec<_> = fs::read_dir(&temp_path)
857 .unwrap()
858 .filter_map(|entry| {
859 let entry = entry.ok()?;
860 let path = entry.path();
861 if path.extension()? == "json" {
862 Some(path)
863 } else {
864 None
865 }
866 })
867 .collect();
868
869 recordings.sort();
870 assert_eq!(recordings.len(), 2);
871
872 assert!(recordings[0].file_name().unwrap().to_str().unwrap() == "0000.json");
874 assert!(recordings[1].file_name().unwrap().to_str().unwrap() == "0001.json");
875
876 let first_content = fs::read_to_string(&recordings[0]).unwrap();
878 let first_recording: RequestResponseInfo = serde_json::from_str(&first_content).unwrap();
879 assert_eq!(first_recording.request.url_path, "/first");
880 assert_eq!(
881 first_recording.response.body.as_ref().unwrap(),
882 "first response"
883 );
884
885 let second_content = fs::read_to_string(&recordings[1]).unwrap();
886 let second_recording: RequestResponseInfo = serde_json::from_str(&second_content).unwrap();
887 assert_eq!(second_recording.request.url_path, "/second");
888 assert_eq!(
889 second_recording.response.body.as_ref().unwrap(),
890 "second response"
891 );
892
893 mock1.assert_async().await;
894 mock2.assert_async().await;
895 }
896
897 #[tokio::test]
898 async fn test_query_parameter_recording() {
899 let temp_dir = TempDir::new().unwrap();
901 let temp_path = temp_dir.path().to_path_buf();
902
903 let mut server = mockito::Server::new_async().await;
905
906 let mock = server
908 .mock("GET", "/catalogs?max_results=10&page_token=abc123")
909 .with_status(200)
910 .with_header("content-type", "application/json")
911 .with_body(r#"{"catalogs": []}"#)
912 .create_async()
913 .await;
914
915 let mut client = CloudClient::new_unauthenticated();
917 client.set_recording_dir(temp_path.clone()).unwrap();
918
919 let url = format!("{}/catalogs?max_results=10&page_token=abc123", server.url());
921 let response = client.get(&url).send().await.unwrap();
922
923 assert!(response.status().is_success());
924
925 let recordings: Vec<_> = fs::read_dir(&temp_path)
927 .unwrap()
928 .filter_map(|entry| {
929 let entry = entry.ok()?;
930 let path = entry.path();
931 if path.extension()? == "json" {
932 Some(path)
933 } else {
934 None
935 }
936 })
937 .collect();
938
939 assert_eq!(recordings.len(), 1);
940
941 let recording_content = fs::read_to_string(&recordings[0]).unwrap();
943 let recording: RequestResponseInfo = serde_json::from_str(&recording_content).unwrap();
944
945 assert_eq!(recording.request.method, "GET");
947 assert_eq!(
948 recording.request.url_path,
949 "/catalogs?max_results=10&page_token=abc123"
950 );
951 assert_eq!(recording.request.body, None);
952
953 assert_eq!(recording.response.status, 200);
955 assert_eq!(
956 recording.response.body.as_ref().unwrap(),
957 r#"{"catalogs": []}"#
958 );
959
960 mock.assert_async().await;
961 }
962
963 #[tokio::test]
968 async fn test_recording_does_not_contain_bearer_token_value() {
969 let temp_dir = TempDir::new().unwrap();
970 let temp_path = temp_dir.path().to_path_buf();
971
972 let mut server = mockito::Server::new_async().await;
973 let mock = server
974 .mock("GET", "/secret")
975 .match_header("authorization", mockito::Matcher::Any)
976 .with_status(200)
977 .with_body("ok")
979 .create_async()
980 .await;
981
982 let mut client = CloudClient::new_with_token("super-secret-token-12345");
983 client.set_recording_dir(temp_path.clone()).unwrap();
984
985 let url = format!("{}/secret", server.url());
986 client.get(&url).send().await.unwrap();
987
988 let recording_path = temp_path.join("0000.json");
989 let content = fs::read_to_string(&recording_path).unwrap();
990
991 assert!(
993 !content.contains("super-secret-token-12345"),
994 "raw bearer token leaked into recording: {content}"
995 );
996
997 mock.assert_async().await;
998 }
999
1000 #[tokio::test]
1004 async fn test_recording_redacts_sensitive_response_headers() {
1005 let temp_dir = TempDir::new().unwrap();
1006 let temp_path = temp_dir.path().to_path_buf();
1007
1008 let mut server = mockito::Server::new_async().await;
1009 let mock = server
1011 .mock("GET", "/s3")
1012 .with_status(200)
1013 .with_header("x-amz-security-token", "AQoXnyc4LLI2AJvUAMOGAR8a1234567890")
1014 .with_header("authorization", "Bearer should-be-redacted")
1015 .with_body("{}")
1016 .create_async()
1017 .await;
1018
1019 let mut client = CloudClient::new_unauthenticated();
1020 client.set_recording_dir(temp_path.clone()).unwrap();
1021
1022 let url = format!("{}/s3", server.url());
1023 client.get(&url).send().await.unwrap();
1024
1025 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1026 assert!(
1027 !content.contains("AQoXnyc4LLI2AJvUAMOGAR8a1234567890"),
1028 "x-amz-security-token leaked into recording: {content}"
1029 );
1030 assert!(
1031 !content.contains("should-be-redacted"),
1032 "Authorization value leaked into recording: {content}"
1033 );
1034 assert_eq!(
1036 content.matches("<REDACTED>").count(),
1037 2,
1038 "expected 2 <REDACTED> entries in recording: {content}"
1039 );
1040
1041 mock.assert_async().await;
1042 }
1043
1044 #[tokio::test]
1046 async fn test_recording_remains_valid_json_after_redaction() {
1047 let temp_dir = TempDir::new().unwrap();
1048 let temp_path = temp_dir.path().to_path_buf();
1049
1050 let mut server = mockito::Server::new_async().await;
1051 let _mock = server
1052 .mock("GET", "/check")
1053 .with_status(200)
1054 .with_header("authorization", "Bearer should-be-redacted")
1055 .with_body(r#"{"ok":true}"#)
1056 .create_async()
1057 .await;
1058
1059 let mut client = CloudClient::new_unauthenticated();
1060 client.set_recording_dir(temp_path.clone()).unwrap();
1061
1062 let url = format!("{}/check", server.url());
1063 client.get(&url).send().await.unwrap();
1064
1065 let content = fs::read_to_string(temp_path.join("0000.json")).unwrap();
1066 let parsed: RequestResponseInfo = serde_json::from_str(&content)
1068 .expect("recording file must be valid JSON even after redaction");
1069 assert_eq!(parsed.response.status, 200);
1070 }
1071}