1use super::key::{SECRET_HEADERS, body_sha256, clean_url, fixture_path};
4use crate::error::ScopeError;
5use crate::http::{HttpClient, Request, Response};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14 Replay,
15 Record,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct FixtureRequest {
20 pub method: String,
21 pub url: String,
22 pub body_sha256: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct RecordedResponse {
27 pub status: u16,
28 pub headers: BTreeMap<String, String>,
29 pub body: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Fixture {
34 pub recorded: String,
35 pub request: FixtureRequest,
36 pub responses: Vec<RecordedResponse>,
37}
38
39pub struct ReplayHttpClient {
40 mode: Mode,
41 dir: PathBuf,
42 inner: Option<Arc<dyn HttpClient>>,
43 secrets: Vec<String>,
44 served: Mutex<HashMap<PathBuf, usize>>,
46 written: Mutex<HashSet<PathBuf>>,
48}
49
50impl ReplayHttpClient {
51 pub fn replay(dir: PathBuf) -> Self {
52 Self {
53 mode: Mode::Replay,
54 dir,
55 inner: None,
56 secrets: vec![],
57 served: Default::default(),
58 written: Default::default(),
59 }
60 }
61
62 pub fn record(dir: PathBuf, inner: Arc<dyn HttpClient>, secrets: Vec<String>) -> Self {
63 let secrets = secrets.into_iter().filter(|s| !s.is_empty()).collect();
64 Self {
65 mode: Mode::Record,
66 dir,
67 inner: Some(inner),
68 secrets,
69 served: Default::default(),
70 written: Default::default(),
71 }
72 }
73
74 fn miss(req: &Request) -> ScopeError {
75 ScopeError::Network(format!(
76 "no fixture for {} {}; run `just tui-record <scenario>`",
77 req.method,
78 clean_url(&req.url)
79 ))
80 }
81
82 fn leaks(text: &str, secret: &str) -> bool {
84 let quoted = serde_json::to_string(secret).unwrap_or_default();
85 let escaped = quoted.trim_matches('"');
86 text.contains(secret) || (!escaped.is_empty() && text.contains(escaped))
87 }
88
89 fn read(path: &std::path::Path) -> Option<Fixture> {
90 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
91 }
92}
93
94#[async_trait]
95impl HttpClient for ReplayHttpClient {
96 async fn send(&self, req: Request) -> Result<Response, ScopeError> {
97 let path = fixture_path(&self.dir, &req.method, &req.url, req.body.as_deref());
98 match self.mode {
99 Mode::Replay => {
100 let fixture = Self::read(&path).ok_or_else(|| Self::miss(&req))?;
101 let mut served = self.served.lock().expect("served lock");
102 let n = served.entry(path).or_insert(0);
103 let idx = (*n).min(fixture.responses.len().saturating_sub(1));
104 *n += 1;
105 let r = fixture.responses.get(idx).ok_or_else(|| Self::miss(&req))?;
106 Ok(Response {
107 status_code: r.status,
108 headers: r.headers.clone().into_iter().collect(),
109 body: r.body.clone(),
110 })
111 }
112 Mode::Record => {
113 let inner = self
114 .inner
115 .as_ref()
116 .expect("record mode has an inner transport");
117 let resp = inner.send(req.clone()).await?;
118 let recorded = RecordedResponse {
119 status: resp.status_code,
120 headers: resp
121 .headers
122 .iter()
123 .filter(|(k, _)| !SECRET_HEADERS.contains(&k.to_lowercase().as_str()))
124 .map(|(k, v)| (k.clone(), v.clone()))
125 .collect(),
126 body: resp.body.clone(),
127 };
128 let mut written = self.written.lock().expect("written lock");
129 let mut fixture = if written.contains(&path) {
130 Some(Self::read(&path).ok_or_else(|| {
131 ScopeError::Network(format!(
132 "fixture {} was written in this run but cannot be read back",
133 path.display()
134 ))
135 })?)
136 } else {
137 None
138 }
139 .unwrap_or_else(|| Fixture {
140 recorded: chrono::Utc::now().to_rfc3339(),
141 request: FixtureRequest {
142 method: req.method.to_uppercase(),
143 url: clean_url(&req.url),
144 body_sha256: body_sha256(req.body.as_deref()),
145 },
146 responses: vec![],
147 });
148 fixture.responses.push(recorded);
149 let text = serde_json::to_string_pretty(&fixture)
150 .map_err(|e| ScopeError::Network(format!("cannot serialize fixture: {e}")))?;
151 if self.secrets.iter().any(|s| Self::leaks(&text, s)) {
152 return Err(ScopeError::Network(format!(
153 "fixture for {} {} would contain a configured secret; not written",
154 req.method,
155 clean_url(&req.url)
156 )));
157 }
158 if let Some(parent) = path.parent() {
159 std::fs::create_dir_all(parent).map_err(|e| {
160 ScopeError::io_context(e, format!("cannot create {}", parent.display()))
161 })?;
162 }
163 std::fs::write(&path, text).map_err(|e| {
164 ScopeError::io_context(e, format!("cannot write {}", path.display()))
165 })?;
166 written.insert(path);
167 Ok(resp)
168 }
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use std::sync::Mutex;
177
178 struct Script(Mutex<Vec<&'static str>>);
180
181 #[async_trait::async_trait]
182 impl HttpClient for Script {
183 async fn send(&self, _r: Request) -> Result<Response, ScopeError> {
184 let body = self.0.lock().unwrap().remove(0);
185 Ok(Response {
186 status_code: 200,
187 headers: [
188 ("content-type".into(), "application/json".into()),
189 ("set-cookie".into(), "s=1".into()),
190 ]
191 .into(),
192 body: body.into(),
193 })
194 }
195 }
196
197 #[tokio::test]
198 async fn test_record_then_replay_returns_responses_in_order_then_repeats_the_last() {
199 let dir = tempfile::tempdir().unwrap();
200 let inner = Arc::new(Script(Mutex::new(vec!["one", "two"])));
201 let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![]);
202 let url = "https://api.example.com/x?apikey=SECRET&a=1";
203 assert_eq!(rec.send(Request::get(url)).await.unwrap().body, "one");
204 assert_eq!(rec.send(Request::get(url)).await.unwrap().body, "two");
205
206 let rep = ReplayHttpClient::replay(dir.path().into());
207 let r1 = rep.send(Request::get(url)).await.unwrap();
208 assert_eq!(r1.body, "one");
209 assert!(!r1.headers.contains_key("set-cookie"));
210 assert_eq!(rep.send(Request::get(url)).await.unwrap().body, "two");
211 assert_eq!(rep.send(Request::get(url)).await.unwrap().body, "two");
213 }
214
215 #[tokio::test]
216 async fn test_replay_miss_names_the_request_and_never_uses_the_network() {
217 let dir = tempfile::tempdir().unwrap();
218 let rep = ReplayHttpClient::replay(dir.path().into());
219 let err = rep
220 .send(Request::get("https://api.example.com/missing"))
221 .await
222 .unwrap_err()
223 .to_string();
224 assert!(
225 err.contains("no fixture for GET https://api.example.com/missing"),
226 "{err}"
227 );
228 }
229
230 #[tokio::test]
231 async fn test_record_refuses_a_fixture_that_contains_a_configured_secret() {
232 let dir = tempfile::tempdir().unwrap();
233 let inner = Arc::new(Script(Mutex::new(vec![r#"{"echo":"MYKEY-0123456789"}"#])));
234 let rec =
235 ReplayHttpClient::record(dir.path().into(), inner, vec!["MYKEY-0123456789".into()]);
236 let err = rec
237 .send(Request::get("https://api.example.com/echo"))
238 .await
239 .unwrap_err()
240 .to_string();
241 assert!(err.contains("configured secret"), "{err}");
242 assert!(
243 walk(dir.path()).is_empty(),
244 "no fixture file may be written"
245 );
246 }
247
248 #[tokio::test]
249 async fn test_stored_url_has_no_secret() {
250 let dir = tempfile::tempdir().unwrap();
251 let inner = Arc::new(Script(Mutex::new(vec!["{}"])));
252 let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![]);
253 rec.send(Request::get("https://api.example.com/x?Token=abc123"))
254 .await
255 .unwrap();
256 let text: String = walk(dir.path())
257 .iter()
258 .map(|p| std::fs::read_to_string(p).unwrap())
259 .collect();
260 assert!(!text.contains("abc123"), "{text}");
261 }
262
263 #[tokio::test]
264 async fn test_short_secret_blocks_the_write_and_is_not_echoed() {
265 let dir = tempfile::tempdir().unwrap();
266 let inner = Arc::new(Script(Mutex::new(vec![r#"{"echo":"abc"}"#])));
267 let rec = ReplayHttpClient::record(dir.path().into(), inner, vec!["abc".into()]);
268 let err = rec
269 .send(Request::get("https://api.example.com/e"))
270 .await
271 .unwrap_err()
272 .to_string();
273 assert!(err.contains("configured secret"), "{err}");
274 assert!(!err.contains("abc"), "{err}");
275 assert!(walk(dir.path()).is_empty());
276 }
277
278 #[tokio::test]
279 async fn test_secret_with_a_quote_blocks_the_write_in_escaped_form() {
280 let dir = tempfile::tempdir().unwrap();
281 let inner = Arc::new(Script(Mutex::new(vec![r#"echo pa"ss123 end"#])));
282 let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![r#"pa"ss123"#.into()]);
283 let err = rec
284 .send(Request::get("https://api.example.com/q"))
285 .await
286 .unwrap_err()
287 .to_string();
288 assert!(err.contains("configured secret"), "{err}");
289 assert!(walk(dir.path()).is_empty());
290 }
291
292 fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
293 let mut out = vec![];
294 for e in std::fs::read_dir(dir).unwrap() {
295 let p = e.unwrap().path();
296 if p.is_dir() {
297 out.extend(walk(&p));
298 } else {
299 out.push(p);
300 }
301 }
302 out
303 }
304}