use async_trait::async_trait;
use bytes::Bytes;
use crate::{
source::{BlobSource, FetchTier},
verify::Verifier,
BlakeHash,
};
pub(crate) struct HttpFetcher {
url_template: String,
client: reqwest::Client,
}
impl HttpFetcher {
pub fn new(url_template: impl Into<String>) -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()?;
Ok(Self { url_template: url_template.into(), client })
}
fn url_for(&self, hash: &BlakeHash) -> String {
self.url_template.replace("{blake3}", &hash.to_hex())
}
}
#[async_trait]
impl BlobSource for HttpFetcher {
fn tier(&self) -> FetchTier {
FetchTier::Cdn
}
async fn fetch_raw(&self, hash: &BlakeHash) -> Option<Bytes> {
let url = self.url_for(hash);
tracing::debug!(%hash, %url, "CDN HTTP fetch");
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| tracing::warn!(%hash, %url, "CDN connect error: {e}"))
.ok()?;
if !resp.status().is_success() {
tracing::warn!(%hash, status = %resp.status(), "CDN non-2xx response");
return None;
}
let body = resp
.bytes()
.await
.map_err(|e| tracing::warn!(%hash, "CDN body read error: {e}"))
.ok()?;
let mut verifier = Verifier::new(*hash);
verifier.update(&body);
verifier
.finish()
.map_err(|e| tracing::warn!(%hash, "CDN BLAKE3 mismatch: {e}"))
.ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BlakeHash;
fn start_test_server(status: u16, body: Vec<u8>) -> String {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let url = format!("http://127.0.0.1:{port}/{{blake3}}");
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let body = body.clone();
let status = status;
std::thread::spawn(move || {
let mut buf = vec![0u8; 4096];
let _ = stream.read(&mut buf);
let status_line = match status {
200 => "200 OK",
404 => "404 Not Found",
_ => "500 Internal Server Error",
};
let header = format!(
"HTTP/1.1 {status_line}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(&body);
});
}
});
url
}
#[tokio::test]
async fn fetches_blob_from_http_server() {
let data = b"hello from cdn edge";
let hash = BlakeHash::hash(data);
let url_template = start_test_server(200, data.to_vec());
let fetcher = HttpFetcher::new(&url_template).unwrap();
let result = fetcher.fetch_raw(&hash).await;
assert!(result.is_some(), "expected bytes, got None");
assert_eq!(&result.unwrap()[..], data);
}
#[tokio::test]
async fn returns_none_on_404() {
let hash = BlakeHash::hash(b"missing blob");
let url_template = start_test_server(404, vec![]);
let fetcher = HttpFetcher::new(&url_template).unwrap();
let result = fetcher.fetch_raw(&hash).await;
assert!(result.is_none(), "expected None for 404, got Some");
}
#[tokio::test]
async fn returns_none_on_hash_mismatch() {
let real_data = b"authentic content";
let hash = BlakeHash::hash(real_data);
let wrong_data = b"tampered cdn response";
let url_template = start_test_server(200, wrong_data.to_vec());
let fetcher = HttpFetcher::new(&url_template).unwrap();
let result = fetcher.fetch_raw(&hash).await;
assert!(result.is_none(), "tampered bytes must be rejected");
}
}