use super::key::{SECRET_HEADERS, body_sha256, clean_url, fixture_path};
use crate::error::ScopeError;
use crate::http::{HttpClient, Request, Response};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Replay,
Record,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureRequest {
pub method: String,
pub url: String,
pub body_sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedResponse {
pub status: u16,
pub headers: BTreeMap<String, String>,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fixture {
pub recorded: String,
pub request: FixtureRequest,
pub responses: Vec<RecordedResponse>,
}
pub struct ReplayHttpClient {
mode: Mode,
dir: PathBuf,
inner: Option<Arc<dyn HttpClient>>,
secrets: Vec<String>,
served: Mutex<HashMap<PathBuf, usize>>,
written: Mutex<HashSet<PathBuf>>,
}
impl ReplayHttpClient {
pub fn replay(dir: PathBuf) -> Self {
Self {
mode: Mode::Replay,
dir,
inner: None,
secrets: vec![],
served: Default::default(),
written: Default::default(),
}
}
pub fn record(dir: PathBuf, inner: Arc<dyn HttpClient>, secrets: Vec<String>) -> Self {
let secrets = secrets.into_iter().filter(|s| !s.is_empty()).collect();
Self {
mode: Mode::Record,
dir,
inner: Some(inner),
secrets,
served: Default::default(),
written: Default::default(),
}
}
fn miss(req: &Request) -> ScopeError {
ScopeError::Network(format!(
"no fixture for {} {}; run `just tui-record <scenario>`",
req.method,
clean_url(&req.url)
))
}
fn leaks(text: &str, secret: &str) -> bool {
let quoted = serde_json::to_string(secret).unwrap_or_default();
let escaped = quoted.trim_matches('"');
text.contains(secret) || (!escaped.is_empty() && text.contains(escaped))
}
fn read(path: &std::path::Path) -> Option<Fixture> {
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
}
#[async_trait]
impl HttpClient for ReplayHttpClient {
async fn send(&self, req: Request) -> Result<Response, ScopeError> {
let path = fixture_path(&self.dir, &req.method, &req.url, req.body.as_deref());
match self.mode {
Mode::Replay => {
let fixture = Self::read(&path).ok_or_else(|| Self::miss(&req))?;
let mut served = self.served.lock().expect("served lock");
let n = served.entry(path).or_insert(0);
let idx = (*n).min(fixture.responses.len().saturating_sub(1));
*n += 1;
let r = fixture.responses.get(idx).ok_or_else(|| Self::miss(&req))?;
Ok(Response {
status_code: r.status,
headers: r.headers.clone().into_iter().collect(),
body: r.body.clone(),
})
}
Mode::Record => {
let inner = self
.inner
.as_ref()
.expect("record mode has an inner transport");
let resp = inner.send(req.clone()).await?;
let recorded = RecordedResponse {
status: resp.status_code,
headers: resp
.headers
.iter()
.filter(|(k, _)| !SECRET_HEADERS.contains(&k.to_lowercase().as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
body: resp.body.clone(),
};
let mut written = self.written.lock().expect("written lock");
let mut fixture = if written.contains(&path) {
Some(Self::read(&path).ok_or_else(|| {
ScopeError::Network(format!(
"fixture {} was written in this run but cannot be read back",
path.display()
))
})?)
} else {
None
}
.unwrap_or_else(|| Fixture {
recorded: chrono::Utc::now().to_rfc3339(),
request: FixtureRequest {
method: req.method.to_uppercase(),
url: clean_url(&req.url),
body_sha256: body_sha256(req.body.as_deref()),
},
responses: vec![],
});
fixture.responses.push(recorded);
let text = serde_json::to_string_pretty(&fixture)
.map_err(|e| ScopeError::Network(format!("cannot serialize fixture: {e}")))?;
if self.secrets.iter().any(|s| Self::leaks(&text, s)) {
return Err(ScopeError::Network(format!(
"fixture for {} {} would contain a configured secret; not written",
req.method,
clean_url(&req.url)
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ScopeError::io_context(e, format!("cannot create {}", parent.display()))
})?;
}
std::fs::write(&path, text).map_err(|e| {
ScopeError::io_context(e, format!("cannot write {}", path.display()))
})?;
written.insert(path);
Ok(resp)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct Script(Mutex<Vec<&'static str>>);
#[async_trait::async_trait]
impl HttpClient for Script {
async fn send(&self, _r: Request) -> Result<Response, ScopeError> {
let body = self.0.lock().unwrap().remove(0);
Ok(Response {
status_code: 200,
headers: [
("content-type".into(), "application/json".into()),
("set-cookie".into(), "s=1".into()),
]
.into(),
body: body.into(),
})
}
}
#[tokio::test]
async fn test_record_then_replay_returns_responses_in_order_then_repeats_the_last() {
let dir = tempfile::tempdir().unwrap();
let inner = Arc::new(Script(Mutex::new(vec!["one", "two"])));
let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![]);
let url = "https://api.example.com/x?apikey=SECRET&a=1";
assert_eq!(rec.send(Request::get(url)).await.unwrap().body, "one");
assert_eq!(rec.send(Request::get(url)).await.unwrap().body, "two");
let rep = ReplayHttpClient::replay(dir.path().into());
let r1 = rep.send(Request::get(url)).await.unwrap();
assert_eq!(r1.body, "one");
assert!(!r1.headers.contains_key("set-cookie"));
assert_eq!(rep.send(Request::get(url)).await.unwrap().body, "two");
assert_eq!(rep.send(Request::get(url)).await.unwrap().body, "two");
}
#[tokio::test]
async fn test_replay_miss_names_the_request_and_never_uses_the_network() {
let dir = tempfile::tempdir().unwrap();
let rep = ReplayHttpClient::replay(dir.path().into());
let err = rep
.send(Request::get("https://api.example.com/missing"))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("no fixture for GET https://api.example.com/missing"),
"{err}"
);
}
#[tokio::test]
async fn test_record_refuses_a_fixture_that_contains_a_configured_secret() {
let dir = tempfile::tempdir().unwrap();
let inner = Arc::new(Script(Mutex::new(vec![r#"{"echo":"MYKEY-0123456789"}"#])));
let rec =
ReplayHttpClient::record(dir.path().into(), inner, vec!["MYKEY-0123456789".into()]);
let err = rec
.send(Request::get("https://api.example.com/echo"))
.await
.unwrap_err()
.to_string();
assert!(err.contains("configured secret"), "{err}");
assert!(
walk(dir.path()).is_empty(),
"no fixture file may be written"
);
}
#[tokio::test]
async fn test_stored_url_has_no_secret() {
let dir = tempfile::tempdir().unwrap();
let inner = Arc::new(Script(Mutex::new(vec!["{}"])));
let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![]);
rec.send(Request::get("https://api.example.com/x?Token=abc123"))
.await
.unwrap();
let text: String = walk(dir.path())
.iter()
.map(|p| std::fs::read_to_string(p).unwrap())
.collect();
assert!(!text.contains("abc123"), "{text}");
}
#[tokio::test]
async fn test_short_secret_blocks_the_write_and_is_not_echoed() {
let dir = tempfile::tempdir().unwrap();
let inner = Arc::new(Script(Mutex::new(vec![r#"{"echo":"abc"}"#])));
let rec = ReplayHttpClient::record(dir.path().into(), inner, vec!["abc".into()]);
let err = rec
.send(Request::get("https://api.example.com/e"))
.await
.unwrap_err()
.to_string();
assert!(err.contains("configured secret"), "{err}");
assert!(!err.contains("abc"), "{err}");
assert!(walk(dir.path()).is_empty());
}
#[tokio::test]
async fn test_secret_with_a_quote_blocks_the_write_in_escaped_form() {
let dir = tempfile::tempdir().unwrap();
let inner = Arc::new(Script(Mutex::new(vec![r#"echo pa"ss123 end"#])));
let rec = ReplayHttpClient::record(dir.path().into(), inner, vec![r#"pa"ss123"#.into()]);
let err = rec
.send(Request::get("https://api.example.com/q"))
.await
.unwrap_err()
.to_string();
assert!(err.contains("configured secret"), "{err}");
assert!(walk(dir.path()).is_empty());
}
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = vec![];
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
out.extend(walk(&p));
} else {
out.push(p);
}
}
out
}
}