use std::sync::Mutex;
use crate::verifier::fetch::{
wrap_fetch_outbound, FetchOutboundOptions, FetchTransport, HttpMethod, HttpPurpose,
OutboundError, RetryConfig, ThreadSleepClock, WrapFetchOutboundConfig,
};
#[derive(Debug, Clone)]
pub struct MultipartField {
pub name: String,
pub filename: Option<String>,
pub content_type: Option<String>,
pub value: Vec<u8>,
}
#[derive(Debug, Clone)]
pub enum RequestBody {
None,
Json(String),
Multipart(Vec<MultipartField>),
}
const MULTIPART_BOUNDARY: &str = "cip309sdkrsboundaryV1aaaaaaaaaaaa";
fn encode_multipart(fields: &[MultipartField]) -> Vec<u8> {
let mut out: Vec<u8> = Vec::new();
for field in fields {
out.extend_from_slice(b"--");
out.extend_from_slice(MULTIPART_BOUNDARY.as_bytes());
out.extend_from_slice(b"\r\n");
out.extend_from_slice(b"Content-Disposition: form-data; name=\"");
out.extend_from_slice(field.name.as_bytes());
out.push(b'"');
if let Some(filename) = &field.filename {
out.extend_from_slice(b"; filename=\"");
out.extend_from_slice(filename.as_bytes());
out.push(b'"');
}
out.extend_from_slice(b"\r\n");
if let Some(ct) = &field.content_type {
out.extend_from_slice(b"Content-Type: ");
out.extend_from_slice(ct.as_bytes());
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b"\r\n");
out.extend_from_slice(&field.value);
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b"--");
out.extend_from_slice(MULTIPART_BOUNDARY.as_bytes());
out.extend_from_slice(b"--\r\n");
out
}
#[derive(Debug, Clone, Default)]
pub struct ResponseHeaders {
pub request_id: Option<String>,
pub retry_after_seconds: Option<u64>,
}
struct UnitJitter;
impl crate::verifier::fetch::Jitter for UnitJitter {
fn multiplier(&self, _attempt_index: usize) -> f64 {
1.0
}
}
#[derive(Debug, Clone)]
pub struct ClientResponse {
pub status: u16,
pub body: Vec<u8>,
pub headers: ResponseHeaders,
}
pub trait ClientTransport {
fn send(
&self,
url: &str,
method: HttpMethod,
headers: &[(String, String)],
body: &RequestBody,
) -> Result<ClientResponse, OutboundError>;
}
#[derive(Default)]
pub struct ReqwestClientTransport {
deny_hosts: Vec<String>,
}
impl ReqwestClientTransport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_deny_hosts(deny_hosts: Vec<String>) -> Self {
Self { deny_hosts }
}
}
struct HeaderCapturingTransport {
multipart: Option<(Vec<u8>, String)>,
captured: Mutex<ResponseHeaders>,
}
impl FetchTransport for HeaderCapturingTransport {
fn fetch(
&self,
url: &str,
opts: &FetchOutboundOptions,
) -> Result<crate::verifier::fetch::FetchOutboundResult, OutboundError> {
let started = std::time::Instant::now();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(
crate::verifier::fetch::DEFAULT_TIMEOUT_MS,
))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| OutboundError::Transport {
url: String::new(),
message: e.to_string(),
})?;
let method = match opts.method {
HttpMethod::Get => reqwest::Method::GET,
HttpMethod::Post => reqwest::Method::POST,
};
let mut req = client.request(method, url);
for (k, v) in &opts.headers {
req = req.header(k.as_str(), v.as_str());
}
if let Some((raw, content_type)) = &self.multipart {
req = req
.header("content-type", content_type.as_str())
.body(raw.clone());
} else if let Some(body) = &opts.body {
req = req.body(body.clone());
}
let resp = req.send().map_err(|e| OutboundError::Transport {
url: url.to_string(),
message: e.to_string(),
})?;
let status = resp.status().as_u16();
let request_id = resp
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let retry_after_seconds = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok());
if let Ok(mut slot) = self.captured.lock() {
*slot = ResponseHeaders {
request_id,
retry_after_seconds,
};
}
let max_bytes = opts
.max_bytes
.unwrap_or(crate::verifier::fetch::DEFAULT_OUTBOUND_MAX_BYTES);
let bytes = read_body_capped(resp, url, max_bytes)?;
Ok(crate::verifier::fetch::FetchOutboundResult {
status,
bytes,
duration_ms: started.elapsed().as_millis() as u64,
})
}
}
fn read_body_capped(
mut resp: reqwest::blocking::Response,
url: &str,
max_bytes: u64,
) -> Result<Vec<u8>, OutboundError> {
use std::io::Read;
let mut out: Vec<u8> = Vec::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = resp.read(&mut buf).map_err(|e| OutboundError::Transport {
url: url.to_string(),
message: e.to_string(),
})?;
if n == 0 {
break;
}
if out.len() as u64 + n as u64 > max_bytes {
return Err(OutboundError::BodyTooLarge {
url: url.to_string(),
limit_bytes: max_bytes,
});
}
out.extend_from_slice(&buf[..n]);
}
Ok(out)
}
impl ClientTransport for ReqwestClientTransport {
fn send(
&self,
url: &str,
method: HttpMethod,
headers: &[(String, String)],
body: &RequestBody,
) -> Result<ClientResponse, OutboundError> {
let (string_body, multipart) = match body {
RequestBody::None => (None, None),
RequestBody::Json(s) => (Some(s.clone()), None),
RequestBody::Multipart(fields) => {
let raw = encode_multipart(fields);
let content_type = format!("multipart/form-data; boundary={MULTIPART_BOUNDARY}");
(None, Some((raw, content_type)))
}
};
let inner = HeaderCapturingTransport {
multipart,
captured: Mutex::new(ResponseHeaders::default()),
};
let mut audit = Vec::new();
let config = WrapFetchOutboundConfig {
deny_hosts: self.deny_hosts.clone(),
retry: RetryConfig {
retries: 0,
..RetryConfig::default()
},
};
let mut opts = FetchOutboundOptions::new(method, HttpPurpose::Https);
opts.headers = headers.to_vec();
opts.body = string_body;
let result = wrap_fetch_outbound(
&inner,
&mut audit,
&config,
&ThreadSleepClock,
&UnitJitter,
url,
&opts,
)?;
let captured = inner.captured.lock().map(|g| g.clone()).unwrap_or_default();
Ok(ClientResponse {
status: result.status,
body: result.bytes,
headers: captured,
})
}
}
#[cfg(test)]
mod tests {
use super::{encode_multipart, MultipartField, MULTIPART_BOUNDARY};
#[test]
fn encode_multipart_emits_exact_rfc2046_bytes() {
let fields = vec![
MultipartField {
name: "target".to_string(),
filename: None,
content_type: None,
value: b"arweave".to_vec(),
},
MultipartField {
name: "file_0".to_string(),
filename: Some("file_0.bin".to_string()),
content_type: Some("application/octet-stream".to_string()),
value: b"AB".to_vec(),
},
MultipartField {
name: "file_1".to_string(),
filename: Some("file_1.bin".to_string()),
content_type: Some("application/octet-stream".to_string()),
value: b"CD".to_vec(),
},
];
let raw = encode_multipart(&fields);
let b = MULTIPART_BOUNDARY;
let expected = format!(
"--{b}\r\n\
Content-Disposition: form-data; name=\"target\"\r\n\
\r\n\
arweave\r\n\
--{b}\r\n\
Content-Disposition: form-data; name=\"file_0\"; filename=\"file_0.bin\"\r\n\
Content-Type: application/octet-stream\r\n\
\r\n\
AB\r\n\
--{b}\r\n\
Content-Disposition: form-data; name=\"file_1\"; filename=\"file_1.bin\"\r\n\
Content-Type: application/octet-stream\r\n\
\r\n\
CD\r\n\
--{b}--\r\n"
);
assert_eq!(
String::from_utf8(raw.clone()).expect("encoder emits UTF-8 for ASCII inputs"),
expected,
"multipart wire bytes drifted (boundary / CRLF / header structure)"
);
assert!(raw.ends_with(format!("--{b}--\r\n").as_bytes()));
let opener = format!("--{b}\r\n");
let opener_count = expected.matches(&opener).count();
assert_eq!(opener_count, 3, "one opening delimiter per field");
}
#[test]
fn encode_multipart_passes_binary_value_through_verbatim() {
let fields = vec![MultipartField {
name: "file_0".to_string(),
filename: Some("file_0.bin".to_string()),
content_type: Some("application/octet-stream".to_string()),
value: vec![0x00, 0xff, 0x0d, 0x0a, 0xaa],
}];
let raw = encode_multipart(&fields);
let b = MULTIPART_BOUNDARY;
let header = format!(
"--{b}\r\n\
Content-Disposition: form-data; name=\"file_0\"; filename=\"file_0.bin\"\r\n\
Content-Type: application/octet-stream\r\n\
\r\n"
);
let mut expected = header.into_bytes();
expected.extend_from_slice(&[0x00, 0xff, 0x0d, 0x0a, 0xaa]);
expected.extend_from_slice(b"\r\n");
expected.extend_from_slice(format!("--{b}--\r\n").as_bytes());
assert_eq!(raw, expected);
}
}