use std::{
fmt,
path::{Path, PathBuf},
sync::{Arc, Weak},
};
use crate::{
body::StreamingBody,
error::{Result, StreamingError},
HttpHeaders, StreamingCacheManager, Url,
};
use bytes::{Buf, Bytes};
use http::{Response, Version};
use http_body::Body;
use http_body_util::{combinators::UnsyncBoxBody, BodyExt};
use http_cache_semantics::CachePolicy;
use moka::future::Cache;
use rand::RngExt;
use redb::{Database, ReadableDatabase, TableDefinition};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::sync::RwLock;
use crate::CachedUserMetadata;
pub const DEFAULT_MAX_BODY_SIZE: u64 = 100 * 1024 * 1024;
const NONCE_LEN: usize = 16;
type ManagerBody = StreamingBody<UnsyncBoxBody<Bytes, StreamingError>>;
const KEY_LOCK_SHARDS: usize = 64;
const METADATA_TABLE: TableDefinition<&str, &[u8]> =
TableDefinition::new("http_streaming_metadata_v1");
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CacheMetadata {
status: u16,
version: u8,
headers: HttpHeaders,
body_size: u64,
nonce: [u8; NONCE_LEN],
checksum: [u8; 32],
policy: CachePolicy,
#[serde(default)]
user_metadata: Option<Vec<u8>>,
}
fn version_to_u8(version: Version) -> u8 {
match version {
Version::HTTP_09 => 9,
Version::HTTP_10 => 10,
Version::HTTP_11 => 11,
Version::HTTP_2 => 2,
Version::HTTP_3 => 3,
_ => 11, }
}
fn version_from_u8(v: u8) -> Version {
match v {
9 => Version::HTTP_09,
10 => Version::HTTP_10,
11 => Version::HTTP_11,
2 => Version::HTTP_2,
3 => Version::HTTP_3,
_ => Version::HTTP_11,
}
}
fn body_hash_for(key: &str) -> String {
blake3::hash(key.as_bytes()).to_hex().to_string()
}
fn body_path_for(body_dir: &Path, body_hash: &str) -> PathBuf {
body_dir.join(&body_hash[0..2]).join(format!("{body_hash}.bin"))
}
#[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
#[derive(Clone)]
pub struct StreamingManager {
cache_dir: PathBuf,
body_dir: PathBuf,
tmp_dir: PathBuf,
db: Arc<Database>,
metadata: Cache<String, CacheMetadata>,
max_body_size: u64,
key_locks: Arc<Vec<RwLock<()>>>,
}
impl fmt::Debug for StreamingManager {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StreamingManager")
.field("cache_dir", &self.cache_dir)
.field("entry_count", &self.metadata.entry_count())
.field("max_body_size", &self.max_body_size)
.finish()
}
}
impl StreamingManager {
pub async fn new(cache_dir: PathBuf, capacity: u64) -> Result<Self> {
Self::with_max_body_size(cache_dir, capacity, DEFAULT_MAX_BODY_SIZE)
.await
}
pub async fn with_max_body_size(
cache_dir: PathBuf,
capacity: u64,
max_body_size: u64,
) -> Result<Self> {
tokio::fs::create_dir_all(&cache_dir).await.map_err(|e| {
crate::HttpCacheError::cache(format!(
"Failed to create cache directory: {e}"
))
})?;
let body_dir = cache_dir.join("bodies");
let tmp_dir = cache_dir.join("tmp");
tokio::fs::create_dir_all(&body_dir).await.map_err(|e| {
crate::HttpCacheError::cache(format!(
"Failed to create body directory: {e}"
))
})?;
tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| {
crate::HttpCacheError::cache(format!(
"Failed to create tmp directory: {e}"
))
})?;
let db_path = cache_dir.join("metadata.redb");
let db = tokio::task::spawn_blocking(move || Database::create(db_path))
.await
.map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb open join failed: {e}"
))
})?
.map_err(|e| {
crate::HttpCacheError::cache(format!(
"Failed to open redb database (another StreamingManager \
instance may be active against this cache_dir): {e}"
))
})?;
let db = Arc::new(db);
{
let db_init = db.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let write_txn = db_init.begin_write().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb begin_write failed during init: {e}"
))
})?;
{
let _table =
write_txn.open_table(METADATA_TABLE).map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb open_table failed during init: {e}"
))
})?;
}
write_txn.commit().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb commit failed during init: {e}"
))
})?;
Ok(())
})
.await
.map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb init join failed: {e}"
))
})??;
}
sweep_tmp_dir(&tmp_dir).await;
let metadata: Cache<String, CacheMetadata> =
Cache::builder().max_capacity(capacity).build();
let key_locks =
Arc::new((0..KEY_LOCK_SHARDS).map(|_| RwLock::new(())).collect());
Ok(Self {
cache_dir,
body_dir,
tmp_dir,
db,
metadata,
max_body_size,
key_locks,
})
}
#[deprecated(
since = "1.1.0",
note = "renamed to with_temp_dir() for clarity"
)]
pub async fn in_memory(capacity: u64) -> Result<Self> {
Self::with_temp_dir(capacity).await
}
pub async fn with_temp_dir(capacity: u64) -> Result<Self> {
let random_suffix: u32 = rand::rng().random();
let temp_dir = std::env::temp_dir().join(format!(
"http-cache-streaming-{}-{:08x}",
std::process::id(),
random_suffix
));
Self::new(temp_dir, capacity).await
}
#[must_use]
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
#[must_use]
pub fn entry_count(&self) -> u64 {
self.metadata.entry_count()
}
#[must_use]
pub fn max_body_size(&self) -> u64 {
self.max_body_size
}
pub async fn clear(&self) -> Result<()> {
let db = self.db.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let write_txn = db.begin_write().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb begin_write (clear) failed: {e}"
))
})?;
write_txn.delete_table(METADATA_TABLE).map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb delete_table failed: {e}"
))
})?;
{
let _table =
write_txn.open_table(METADATA_TABLE).map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb open_table (clear recreate) failed: {e}"
))
})?;
}
write_txn.commit().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb commit (clear) failed: {e}"
))
})?;
Ok(())
})
.await
.map_err(|e| {
crate::HttpCacheError::cache(format!("clear join failed: {e}"))
})??;
recreate_dir(&self.body_dir).await.map_err(|e| {
crate::HttpCacheError::cache(format!(
"Failed to recreate body directory: {e}"
))
})?;
recreate_dir(&self.tmp_dir).await.map_err(|e| {
crate::HttpCacheError::cache(format!(
"Failed to recreate tmp directory: {e}"
))
})?;
self.metadata.invalidate_all();
self.metadata.run_pending_tasks().await;
Ok(())
}
pub async fn run_pending_tasks(&self) {
self.metadata.run_pending_tasks().await;
}
async fn redb_remove(&self, cache_key: &str) {
redb_remove_row(self.db.clone(), cache_key.to_string()).await;
}
async fn self_heal(&self, cache_key: &str, body_path: &Path) {
self.metadata.invalidate(cache_key).await;
self.redb_remove(cache_key).await;
let _ = tokio::fs::remove_file(body_path).await;
}
fn key_lock(&self, body_hash: &str) -> &RwLock<()> {
&self.key_locks[shard_index(body_hash)]
}
async fn redb_get(&self, cache_key: &str) -> Result<Option<CacheMetadata>> {
let db = self.db.clone();
let key = cache_key.to_string();
let bytes =
tokio::task::spawn_blocking(move || -> Result<Option<Vec<u8>>> {
let read_txn = db.begin_read().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb begin_read failed: {e}"
))
})?;
let table =
read_txn.open_table(METADATA_TABLE).map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb open_table failed: {e}"
))
})?;
match table.get(key.as_str()).map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb get failed: {e}"
))
})? {
Some(g) => Ok(Some(g.value().to_vec())),
None => Ok(None),
}
})
.await
.map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb_get join failed: {e}"
))
})??;
match bytes {
None => Ok(None),
Some(b) => match postcard::from_bytes::<CacheMetadata>(&b) {
Ok(m) => Ok(Some(m)),
Err(e) => {
log::debug!(
"Poisoned metadata for key {cache_key}; removing: {e}"
);
self.redb_remove(cache_key).await;
Ok(None)
}
},
}
}
fn build_response_from_parts(
&self,
cache_key: &str,
body_hash: &str,
body_path: &Path,
metadata: &CacheMetadata,
file: tokio::fs::File,
) -> Result<Response<ManagerBody>> {
let mut response_builder = Response::builder()
.status(metadata.status)
.version(version_from_u8(metadata.version));
for (name, value) in metadata.headers.iter() {
response_builder =
response_builder.header(name.as_str(), value.as_str());
}
let heal = CorruptHeal {
db: Arc::downgrade(&self.db),
metadata: self.metadata.clone(),
key_locks: self.key_locks.clone(),
key: cache_key.to_string(),
body_hash: body_hash.to_string(),
body_path: body_path.to_path_buf(),
nonce: metadata.nonce,
};
let body = StreamingBody::from_file_verified(
file,
metadata.body_size,
metadata.checksum,
move || {
tokio::spawn(heal.run());
},
);
let mut response = response_builder.body(body).map_err(|e| {
crate::HttpCacheError::cache(format!(
"Failed to build response: {e}"
))
})?;
response
.extensions_mut()
.insert(CachedUserMetadata(metadata.user_metadata.clone()));
response
.extensions_mut()
.insert(crate::CacheEntryToken(metadata.nonce.to_vec()));
Ok(response)
}
}
async fn spool_write<W: tokio::io::AsyncWrite + Unpin>(
writer: &mut W,
data: &[u8],
) -> std::io::Result<()> {
writer.write_all(data).await?;
writer.flush().await
}
struct TmpGuard {
path: PathBuf,
defused: bool,
}
impl TmpGuard {
fn new(path: PathBuf) -> Self {
Self { path, defused: false }
}
fn defuse(&mut self) {
self.defused = true;
}
}
impl Drop for TmpGuard {
fn drop(&mut self) {
if !self.defused {
let _ = std::fs::remove_file(&self.path);
}
}
}
fn parse_content_length(headers: &http::HeaderMap) -> Option<u64> {
let mut iter = headers.get_all(http::header::CONTENT_LENGTH).iter();
let first = iter.next()?.to_str().ok()?.trim().parse::<u64>().ok()?;
for v in iter {
if v.to_str().ok()?.trim().parse::<u64>().ok()? != first {
return None;
}
}
Some(first)
}
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
fn stored_headers(headers: &http::HeaderMap) -> HttpHeaders {
let nominated: Vec<String> = headers
.get_all(http::header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.map(|t| t.trim().to_ascii_lowercase())
.filter(|t| !t.is_empty())
.collect();
let mut out = HttpHeaders::new();
for (name, value) in headers.iter() {
let name = name.as_str();
if HOP_BY_HOP.contains(&name) || nominated.iter().any(|n| n == name) {
continue;
}
if let Ok(value_str) = value.to_str() {
out.append(name.to_string(), value_str.to_string());
}
}
out
}
fn shard_index(body_hash: &str) -> usize {
usize::from_str_radix(&body_hash[..2], 16).unwrap_or(0) % KEY_LOCK_SHARDS
}
async fn redb_remove_row(db: Arc<Database>, key: String) {
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
let write_txn = db.begin_write().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb begin_write (remove) failed: {e}"
))
})?;
{
let mut table =
write_txn.open_table(METADATA_TABLE).map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb open_table (remove) failed: {e}"
))
})?;
let _ = table.remove(key.as_str());
}
write_txn.commit().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb commit (remove) failed: {e}"
))
})?;
Ok(())
})
.await;
}
async fn redb_read_nonce(
db: Arc<Database>,
key: String,
) -> Option<[u8; NONCE_LEN]> {
tokio::task::spawn_blocking(move || {
let read_txn = db.begin_read().ok()?;
let table = read_txn.open_table(METADATA_TABLE).ok()?;
let guard = table.get(key.as_str()).ok()??;
postcard::from_bytes::<CacheMetadata>(guard.value())
.ok()
.map(|m| m.nonce)
})
.await
.ok()
.flatten()
}
struct CorruptHeal {
db: Weak<Database>,
metadata: Cache<String, CacheMetadata>,
key_locks: Arc<Vec<RwLock<()>>>,
key: String,
body_hash: String,
body_path: PathBuf,
nonce: [u8; NONCE_LEN],
}
impl CorruptHeal {
async fn run(self) {
let Some(db) = self.db.upgrade() else {
return;
};
let _guard = self.key_locks[shard_index(&self.body_hash)].write().await;
let current = match self.metadata.get(&self.key).await {
Some(m) => Some(m.nonce),
None => redb_read_nonce(db.clone(), self.key.clone()).await,
};
if current != Some(self.nonce) {
return;
}
self.metadata.invalidate(&self.key).await;
redb_remove_row(db, self.key).await;
let _ = tokio::fs::remove_file(&self.body_path).await;
}
}
async fn recreate_dir(dir: &Path) -> std::io::Result<()> {
let _ = tokio::fs::remove_dir_all(dir).await;
match tokio::fs::create_dir_all(dir).await {
Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => Err(e),
_ => Ok(()),
}
}
async fn sweep_tmp_dir(tmp_dir: &Path) {
let mut rd = match tokio::fs::read_dir(tmp_dir).await {
Ok(rd) => rd,
Err(e) => {
log::debug!("tmp sweep: read_dir failed: {e}");
return;
}
};
let mut removed = 0usize;
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let p = entry.path();
if let Err(e) = tokio::fs::remove_file(&p).await {
log::debug!(
"tmp sweep: remove_file {} failed: {e}",
p.display()
);
} else {
removed += 1;
}
}
Ok(None) => break,
Err(e) => {
log::debug!("tmp sweep: next_entry failed: {e}");
break;
}
}
}
if removed > 0 {
log::debug!("tmp sweep removed {removed} stale file(s)");
}
}
pin_project_lite::pin_project! {
struct GuardedStream<S> {
#[pin]
inner: S,
guard: TmpGuard,
}
}
impl<S: futures_util::Stream> futures_util::Stream for GuardedStream<S> {
type Item = S::Item;
fn poll_next(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.project().inner.poll_next(cx)
}
}
fn serve_uncached_spooled(
parts: http::response::Parts,
file: tokio::fs::File,
guard: TmpGuard,
written: u64,
pending: Option<Bytes>,
rest: Option<UnsyncBoxBody<Bytes, StreamingError>>,
) -> Result<Response<ManagerBody>> {
use futures_util::{StreamExt, TryStreamExt};
use http_body_util::{BodyStream, StreamBody};
let prefix = futures_util::stream::once(async move {
let mut file = file;
file.seek(std::io::SeekFrom::Start(NONCE_LEN as u64))
.await
.map_err(|e| StreamingError::new(Box::new(e)))?;
Ok::<_, StreamingError>(BodyStream::new(StreamingBody::<
UnsyncBoxBody<Bytes, StreamingError>,
>::from_file_with_size(
file, written
)))
})
.try_flatten();
let pending_stream = futures_util::stream::iter(
pending.into_iter().map(|b| Ok(http_body::Frame::data(b))),
);
let rest_stream = rest
.map(BodyStream::new)
.map(StreamExt::left_stream)
.unwrap_or_else(|| futures_util::stream::empty().right_stream());
let chained = prefix.chain(pending_stream).chain(rest_stream);
let body = StreamBody::new(GuardedStream { inner: chained, guard });
Ok(Response::from_parts(
parts,
StreamingBody::streaming(body.boxed_unsync()),
))
}
impl StreamingCacheManager for StreamingManager {
type Body = ManagerBody;
async fn get(
&self,
cache_key: &str,
) -> Result<Option<(Response<Self::Body>, CachePolicy)>>
where
<Self::Body as Body>::Data: Send,
<Self::Body as Body>::Error:
Into<StreamingError> + Send + Sync + 'static,
{
let body_hash = body_hash_for(cache_key);
let body_path = body_path_for(&self.body_dir, &body_hash);
let _guard = self.key_lock(&body_hash).read().await;
let metadata = match self.metadata.get(cache_key).await {
Some(m) => m,
None => match self.redb_get(cache_key).await? {
Some(m) => {
self.metadata
.insert(cache_key.to_string(), m.clone())
.await;
m
}
None => return Ok(None),
},
};
let mut file = match tokio::fs::File::open(&body_path).await {
Ok(f) => f,
Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {
self.self_heal(cache_key, &body_path).await;
return Ok(None);
}
Err(e) => {
log::debug!(
"body file open failed for {cache_key}; treating as \
miss: {e}"
);
return Ok(None);
}
};
let file_len = match file.metadata().await {
Ok(m) => m.len(),
Err(e) => {
log::debug!(
"body file stat failed for {cache_key}; self-healing: {e}"
);
self.self_heal(cache_key, &body_path).await;
return Ok(None);
}
};
if file_len != NONCE_LEN as u64 + metadata.body_size {
log::debug!(
"body-size mismatch for {cache_key} (file={file_len}, \
expected={}); self-healing",
NONCE_LEN as u64 + metadata.body_size
);
drop(file);
self.self_heal(cache_key, &body_path).await;
return Ok(None);
}
let mut nonce_buf = [0u8; NONCE_LEN];
if let Err(e) = file.read_exact(&mut nonce_buf).await {
log::debug!(
"body-file nonce read failed for {cache_key}; self-healing: {e}"
);
drop(file);
self.self_heal(cache_key, &body_path).await;
return Ok(None);
}
if nonce_buf != metadata.nonce {
log::debug!(
"nonce mismatch for {cache_key}; self-healing (overwrite-crash \
window or tampering)"
);
drop(file);
self.self_heal(cache_key, &body_path).await;
return Ok(None);
}
let response = self.build_response_from_parts(
cache_key, &body_hash, &body_path, &metadata, file,
)?;
Ok(Some((response, metadata.policy)))
}
async fn put<B>(
&self,
cache_key: String,
response: Response<B>,
policy: CachePolicy,
_request_url: Url,
user_metadata: Option<Vec<u8>>,
) -> Result<Response<Self::Body>>
where
B: Body + Send + 'static,
B::Data: Send,
B::Error: Into<StreamingError>,
<Self::Body as Body>::Data: Send,
<Self::Body as Body>::Error:
Into<StreamingError> + Send + Sync + 'static,
{
let (parts, body) = response.into_parts();
let mut inner: UnsyncBoxBody<Bytes, StreamingError> = body
.map_frame(|frame| {
frame.map_data(|mut d| d.copy_to_bytes(d.remaining()))
})
.map_err(Into::into)
.boxed_unsync();
let is_head = parts
.extensions
.get::<crate::CachedRequestMethod>()
.is_some_and(|m| m.0 == http::Method::HEAD);
let content_length = parse_content_length(&parts.headers);
if self.max_body_size == 0
|| (!is_head
&& content_length.is_some_and(|cl| cl > self.max_body_size))
{
return Ok(Response::from_parts(
parts,
StreamingBody::streaming(inner),
));
}
let body_hash = body_hash_for(&cache_key);
let tmp_suffix: u64 = rand::rng().random();
let tmp_path =
self.tmp_dir.join(format!("{body_hash}.{tmp_suffix:016x}.tmp"));
let final_dir = self.body_dir.join(&body_hash[0..2]);
if let Err(e) = tokio::fs::create_dir_all(&final_dir).await {
log::debug!(
"put: create body subdir failed; serving uncached: {e}"
);
return Ok(Response::from_parts(
parts,
StreamingBody::streaming(inner),
));
}
let final_path = final_dir.join(format!("{body_hash}.bin"));
let nonce: [u8; NONCE_LEN] = rand::rng().random();
let mut file = match tokio::fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&tmp_path)
.await
{
Ok(f) => f,
Err(e) => {
log::debug!("put: open tmp failed; serving uncached: {e}");
return Ok(Response::from_parts(
parts,
StreamingBody::streaming(inner),
));
}
};
let mut guard = TmpGuard::new(tmp_path.clone());
if let Err(e) = spool_write(&mut file, &nonce).await {
log::debug!("put: nonce write failed; serving uncached: {e}");
drop(file); return Ok(Response::from_parts(
parts,
StreamingBody::streaming(inner),
));
}
let mut hasher = blake3::Hasher::new();
let mut written: u64 = 0;
loop {
match inner.frame().await {
None => break,
Some(Err(e)) => {
drop(file);
return Err(Box::new(e));
}
Some(Ok(frame)) => {
let Ok(data) = frame.into_data() else {
continue;
};
if written + data.len() as u64 > self.max_body_size {
return serve_uncached_spooled(
parts,
file,
guard,
written,
Some(data),
Some(inner),
);
}
if let Err(e) = spool_write(&mut file, &data).await {
log::debug!(
"put: spool write failed; serving uncached: {e}"
);
return serve_uncached_spooled(
parts,
file,
guard,
written,
Some(data),
Some(inner),
);
}
hasher.update(&data);
written += data.len() as u64;
}
}
}
if !is_head {
if let Some(cl) = content_length {
if cl != written {
log::debug!(
"put: content-length {cl} != received {written}; \
serving uncached (incomplete response)"
);
return serve_uncached_spooled(
parts, file, guard, written, None, None,
);
}
}
}
if let Err(e) = file.sync_all().await {
log::debug!("put: fsync failed; serving uncached: {e}");
return serve_uncached_spooled(
parts, file, guard, written, None, None,
);
}
let metadata = CacheMetadata {
status: parts.status.as_u16(),
version: version_to_u8(parts.version),
headers: stored_headers(&parts.headers),
body_size: written,
nonce,
checksum: *hasher.finalize().as_bytes(),
policy,
user_metadata,
};
let checksum = metadata.checksum;
let serialized = match postcard::to_allocvec(&metadata) {
Ok(s) => s,
Err(e) => {
log::debug!(
"put: metadata serialization failed; serving uncached: {e}"
);
return serve_uncached_spooled(
parts, file, guard, written, None, None,
);
}
};
{
let _guard_lock = self.key_lock(&body_hash).write().await;
if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await {
log::debug!("put: rename failed; serving uncached: {e}");
return serve_uncached_spooled(
parts, file, guard, written, None, None,
);
}
guard.defuse();
let db = self.db.clone();
let key_for_redb = cache_key.clone();
let commit_result: Result<()> =
match tokio::task::spawn_blocking(move || -> Result<()> {
let write_txn = db.begin_write().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb begin_write (put) failed: {e}"
))
})?;
{
let mut table = write_txn
.open_table(METADATA_TABLE)
.map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb open_table (put) failed: {e}"
))
})?;
table
.insert(
key_for_redb.as_str(),
serialized.as_slice(),
)
.map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb insert (put) failed: {e}"
))
})?;
}
write_txn.commit().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb commit (put) failed: {e}"
))
})?;
Ok(())
})
.await
{
Ok(inner_result) => inner_result,
Err(e) => Err(Box::new(crate::HttpCacheError::cache(
format!("put join failed: {e}"),
))),
};
if let Err(e) = commit_result {
log::debug!("put: redb commit failed; serving uncached: {e}");
let _ = tokio::fs::remove_file(&final_path).await;
return serve_uncached_spooled(
parts, file, guard, written, None, None,
);
}
self.metadata.insert(cache_key.clone(), metadata).await;
}
if let Err(e) =
file.seek(std::io::SeekFrom::Start(NONCE_LEN as u64)).await
{
log::debug!("put: post-commit seek failed: {e}");
drop(file);
if let Some((resp, _)) = self.get(&cache_key).await? {
let (_, body) = resp.into_parts();
return Ok(Response::from_parts(parts, body));
}
return Err(crate::HttpCacheError::cache(format!(
"put: entry vanished after commit: {e}"
))
.into());
}
let heal = CorruptHeal {
db: Arc::downgrade(&self.db),
metadata: self.metadata.clone(),
key_locks: self.key_locks.clone(),
key: cache_key,
body_hash,
body_path: final_path,
nonce,
};
let body = StreamingBody::from_file_verified(
file,
written,
checksum,
move || {
tokio::spawn(heal.run());
},
);
Ok(Response::from_parts(parts, body))
}
async fn update_metadata(
&self,
cache_key: &str,
headers: &http::HeaderMap,
policy: CachePolicy,
user_metadata: Option<Vec<u8>>,
token: Option<&crate::CacheEntryToken>,
) -> Result<bool> {
let body_hash = body_hash_for(cache_key);
let _guard = self.key_lock(&body_hash).write().await;
let Some(mut metadata) = self.redb_get(cache_key).await? else {
self.metadata.invalidate(cache_key).await;
return Ok(false);
};
if let Some(t) = token {
if t.0.as_slice() != metadata.nonce {
return Ok(false);
}
}
metadata.headers = stored_headers(headers);
metadata.policy = policy;
metadata.user_metadata = user_metadata;
let serialized = postcard::to_allocvec(&metadata).map_err(|e| {
crate::HttpCacheError::cache(format!(
"Failed to serialize metadata: {e}"
))
})?;
let db = self.db.clone();
let key_for_redb = cache_key.to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let write_txn = db.begin_write().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb begin_write (update_metadata) failed: {e}"
))
})?;
{
let mut table =
write_txn.open_table(METADATA_TABLE).map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb open_table (update_metadata) failed: {e}"
))
})?;
table
.insert(key_for_redb.as_str(), serialized.as_slice())
.map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb insert (update_metadata) failed: {e}"
))
})?;
}
write_txn.commit().map_err(|e| {
crate::HttpCacheError::cache(format!(
"redb commit (update_metadata) failed: {e}"
))
})?;
Ok(())
})
.await
.map_err(|e| {
crate::HttpCacheError::cache(format!(
"update_metadata join failed: {e}"
))
})??;
self.metadata.insert(cache_key.to_string(), metadata).await;
Ok(true)
}
async fn convert_body<B>(
&self,
response: Response<B>,
) -> Result<Response<Self::Body>>
where
B: Body + Send + 'static,
B::Data: Send,
B::Error: Into<StreamingError>,
<Self::Body as Body>::Data: Send,
<Self::Body as Body>::Error:
Into<StreamingError> + Send + Sync + 'static,
{
Ok(response.map(|body| {
StreamingBody::streaming(
body.map_frame(|frame| {
frame.map_data(|mut d| d.copy_to_bytes(d.remaining()))
})
.map_err(Into::into)
.boxed_unsync(),
)
}))
}
async fn delete(&self, cache_key: &str) -> Result<()> {
let body_hash = body_hash_for(cache_key);
let body_path = body_path_for(&self.body_dir, &body_hash);
let _guard = self.key_lock(&body_hash).write().await;
self.self_heal(cache_key, &body_path).await;
Ok(())
}
fn empty_body(&self) -> Self::Body {
StreamingBody::buffered(Bytes::new())
}
fn body_to_bytes_stream(
body: Self::Body,
) -> impl futures_util::Stream<
Item = std::result::Result<
Bytes,
Box<dyn std::error::Error + Send + Sync>,
>,
> + Send
where
<Self::Body as Body>::Data: Send,
<Self::Body as Body>::Error: Send + Sync + 'static,
{
body.into_bytes_stream()
}
}
#[cfg(test)]
mod tests {
use super::*;
use http::StatusCode;
use http_body_util::Full;
use tempfile::TempDir;
fn sample_policy() -> CachePolicy {
CachePolicy::new(
&http::Request::builder()
.uri("https://example.com/test")
.body(())
.unwrap(),
&Response::builder()
.status(200)
.header("cache-control", "max-age=3600")
.body(())
.unwrap(),
)
}
fn test_url() -> Url {
"https://example.com/test".parse().unwrap()
}
fn response_with_body(bytes: Bytes) -> Response<Full<Bytes>> {
Response::builder()
.status(StatusCode::OK)
.header("content-type", "text/plain")
.body(Full::new(bytes))
.unwrap()
}
async fn read_body_bytes(resp: Response<ManagerBody>) -> Bytes {
resp.into_body().collect().await.unwrap().to_bytes()
}
#[tokio::test]
async fn test_convert_body_passes_through_without_buffering() {
let manager = StreamingManager::with_temp_dir(10).await.unwrap();
let resp = response_with_body(Bytes::from("pass-through"));
let converted = manager.convert_body(resp).await.unwrap();
assert!(
matches!(converted.body(), StreamingBody::Streaming { .. }),
"non-cacheable responses must not be buffered"
);
let b = converted.into_body().collect().await.unwrap().to_bytes();
assert_eq!(b, "pass-through");
}
#[tokio::test]
async fn test_streaming_manager_basic() {
let manager = StreamingManager::with_temp_dir(100).await.unwrap();
let response = response_with_body(Bytes::from("Hello, World!"));
let _stored = manager
.put("test-key".into(), response, sample_policy(), test_url(), None)
.await
.unwrap();
let (resp, _policy) = manager.get("test-key").await.unwrap().unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(read_body_bytes(resp).await, "Hello, World!");
}
#[tokio::test]
async fn test_streaming_manager_delete() {
let manager = StreamingManager::with_temp_dir(100).await.unwrap();
let response = response_with_body(Bytes::from("test"));
manager
.put(
"delete-test".into(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
assert!(manager.get("delete-test").await.unwrap().is_some());
manager.delete("delete-test").await.unwrap();
assert!(manager.get("delete-test").await.unwrap().is_none());
}
#[tokio::test]
async fn test_same_body_different_keys_both_readable() {
let manager = StreamingManager::with_temp_dir(100).await.unwrap();
let body = Bytes::from("Duplicate content");
for key in ["key1", "key2"] {
let response = response_with_body(body.clone());
manager
.put(key.into(), response, sample_policy(), test_url(), None)
.await
.unwrap();
}
for key in ["key1", "key2"] {
let (resp, _) = manager.get(key).await.unwrap().unwrap();
assert_eq!(read_body_bytes(resp).await, "Duplicate content");
}
}
#[tokio::test]
async fn test_recreate_dir_tolerates_already_exists() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("collide");
std::fs::write(&path, b"x").unwrap();
recreate_dir(&path).await.unwrap();
}
#[tokio::test]
async fn test_persistence_across_restart() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
{
let manager =
StreamingManager::new(path.clone(), 100).await.unwrap();
for (k, body) in [("a", "body-a"), ("b", "body-b"), ("c", "body-c")]
{
manager
.put(
k.into(),
response_with_body(Bytes::copy_from_slice(
body.as_bytes(),
)),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
}
drop(manager);
}
let manager = StreamingManager::new(path.clone(), 100).await.unwrap();
for (k, body) in [("a", "body-a"), ("b", "body-b"), ("c", "body-c")] {
let (resp, _) = manager.get(k).await.unwrap().unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(read_body_bytes(resp).await, body);
}
}
#[tokio::test]
async fn test_persistence_preserves_policy_and_user_metadata() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let user_meta = vec![1u8, 2, 3, 4, 5];
let policy = sample_policy();
{
let manager =
StreamingManager::new(path.clone(), 100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("body")),
policy.clone(),
test_url(),
Some(user_meta.clone()),
)
.await
.unwrap();
drop(manager);
}
let manager = StreamingManager::new(path, 100).await.unwrap();
let (resp, restored_policy) = manager.get("k").await.unwrap().unwrap();
let got = resp.extensions().get::<CachedUserMetadata>().unwrap();
assert_eq!(got.0.as_ref().unwrap(), &user_meta);
let now = std::time::SystemTime::now();
assert_eq!(restored_policy.time_to_live(now), policy.time_to_live(now));
}
#[tokio::test]
async fn test_update_metadata_leaves_body_file_untouched() {
let dir = TempDir::new().unwrap();
let manager =
StreamingManager::new(dir.path().to_path_buf(), 100).await.unwrap();
let body = Full::new(Bytes::from_static(b"immutable body"));
let response = Response::builder()
.status(StatusCode::OK)
.header("x-old", "1")
.body(body)
.unwrap();
let key = "GET:https://example.com/reval".to_string();
let _ = manager
.put(key.clone(), response, sample_policy(), test_url(), None)
.await
.unwrap();
let body_hash = body_hash_for(&key);
let body_path = body_path_for(&manager.body_dir, &body_hash);
let before = std::fs::read(&body_path).unwrap();
let (resp, _) = manager.get(&key).await.unwrap().unwrap();
let token = resp
.extensions()
.get::<crate::CacheEntryToken>()
.cloned()
.expect("get() must attach a CacheEntryToken");
let mut new_headers = http::HeaderMap::new();
new_headers.insert("x-new", "2".parse().unwrap());
let updated = manager
.update_metadata(
&key,
&new_headers,
sample_policy(),
None,
Some(&token),
)
.await
.unwrap();
assert!(updated);
let after = std::fs::read(&body_path).unwrap();
assert_eq!(before, after, "body file must be byte-identical");
let (resp, _) = manager.get(&key).await.unwrap().unwrap();
assert!(resp.headers().get("x-new").is_some());
assert!(
resp.headers().get("x-old").is_none(),
"header set is replaced, not merged (the orchestrator does the merge)"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"immutable body");
}
#[tokio::test]
async fn test_update_metadata_missing_entry_returns_false() {
let dir = TempDir::new().unwrap();
let manager =
StreamingManager::new(dir.path().to_path_buf(), 100).await.unwrap();
let updated = manager
.update_metadata(
"GET:https://example.com/absent",
&http::HeaderMap::new(),
sample_policy(),
None,
None,
)
.await
.unwrap();
assert!(!updated);
}
#[tokio::test]
async fn test_update_metadata_stale_token_returns_false() {
let dir = TempDir::new().unwrap();
let manager =
StreamingManager::new(dir.path().to_path_buf(), 100).await.unwrap();
let key = "GET:https://example.com/race".to_string();
let mk_body =
|s: &'static str| Full::new(Bytes::from_static(s.as_bytes()));
let response = Response::builder()
.status(StatusCode::OK)
.header("x-version", "v1")
.body(mk_body("v1"))
.unwrap();
let _ = manager
.put(key.clone(), response, sample_policy(), test_url(), None)
.await
.unwrap();
let (resp, _) = manager.get(&key).await.unwrap().unwrap();
let v1_token =
resp.extensions().get::<crate::CacheEntryToken>().cloned().unwrap();
let response = Response::builder()
.status(StatusCode::OK)
.header("x-version", "v2")
.body(mk_body("v2"))
.unwrap();
let _ = manager
.put(key.clone(), response, sample_policy(), test_url(), None)
.await
.unwrap();
let updated = manager
.update_metadata(
&key,
&http::HeaderMap::new(),
sample_policy(),
None,
Some(&v1_token),
)
.await
.unwrap();
assert!(!updated, "stale token must refuse the metadata update");
let (resp, _) = manager.get(&key).await.unwrap().unwrap();
assert_eq!(
resp.headers().get("x-version").unwrap(),
"v2",
"refused update must leave v2's stored headers unmodified"
);
let bytes = read_body_bytes(resp).await;
assert_eq!(
&bytes[..],
b"v2",
"refused update must leave v2's stored body unmodified"
);
}
#[tokio::test]
async fn test_delete_persists_across_restart() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
{
let manager =
StreamingManager::new(path.clone(), 100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("body")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
manager.delete("k").await.unwrap();
}
let manager = StreamingManager::new(path, 100).await.unwrap();
assert!(manager.get("k").await.unwrap().is_none());
}
#[tokio::test]
async fn test_overwrite_replaces_body() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let manager = StreamingManager::new(path.clone(), 100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("first")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("second-body")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let (resp, _) = manager.get("k").await.unwrap().unwrap();
assert_eq!(read_body_bytes(resp).await, "second-body");
drop(manager);
let manager = StreamingManager::new(path, 100).await.unwrap();
let (resp, _) = manager.get("k").await.unwrap().unwrap();
assert_eq!(read_body_bytes(resp).await, "second-body");
}
#[tokio::test]
async fn test_overwrite_does_not_leak_prior_content() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let manager = StreamingManager::new(path, 100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("first")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("second")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let body_hash = body_hash_for("k");
let prefix_dir = manager.body_dir.join(&body_hash[0..2]);
let mut rd = tokio::fs::read_dir(&prefix_dir).await.unwrap();
let mut count = 0usize;
while let Some(entry) = rd.next_entry().await.unwrap() {
if entry.path().extension().map(|s| s == "bin").unwrap_or(false) {
count += 1;
}
}
assert_eq!(count, 1, "expected exactly one body file for the key");
}
#[tokio::test]
async fn test_delete_removes_body_file() {
let manager = StreamingManager::with_temp_dir(100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("body")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
manager.delete("k").await.unwrap();
let body_hash = body_hash_for("k");
let body_path = body_path_for(&manager.body_dir, &body_hash);
assert!(!body_path.exists());
}
#[tokio::test]
async fn test_missing_body_self_heals_fast_path() {
let manager = StreamingManager::with_temp_dir(100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("body")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let body_path = body_path_for(&manager.body_dir, &body_hash_for("k"));
tokio::fs::remove_file(&body_path).await.unwrap();
assert!(manager.get("k").await.unwrap().is_none());
assert!(manager.redb_get("k").await.unwrap().is_none());
}
#[tokio::test]
async fn test_missing_body_self_heals_slow_path() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let manager = StreamingManager::new(path, 100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("body")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
manager.metadata.invalidate("k").await;
manager.metadata.run_pending_tasks().await;
let body_path = body_path_for(&manager.body_dir, &body_hash_for("k"));
tokio::fs::remove_file(&body_path).await.unwrap();
assert!(manager.get("k").await.unwrap().is_none());
assert!(manager.redb_get("k").await.unwrap().is_none());
}
#[tokio::test]
async fn test_corrupt_metadata_entry_is_skipped_and_removed() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
{
let manager =
StreamingManager::new(path.clone(), 100).await.unwrap();
manager
.put(
"good".into(),
response_with_body(Bytes::from("ok")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let db = manager.db.clone();
tokio::task::spawn_blocking(move || {
let write_txn = db.begin_write().unwrap();
{
let mut table =
write_txn.open_table(METADATA_TABLE).unwrap();
table.insert("bad", &vec![0xFFu8; 8][..]).unwrap();
}
write_txn.commit().unwrap();
})
.await
.unwrap();
}
let manager = StreamingManager::new(path, 100).await.unwrap();
assert!(manager.get("bad").await.unwrap().is_none());
assert!(manager.redb_get("bad").await.unwrap().is_none());
let (resp, _) = manager.get("good").await.unwrap().unwrap();
assert_eq!(read_body_bytes(resp).await, "ok");
}
#[tokio::test]
async fn test_startup_sweeps_tmp_dir() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
{
let _manager =
StreamingManager::new(path.clone(), 100).await.unwrap();
}
let tmp_dir = path.join("tmp");
tokio::fs::write(tmp_dir.join("stale.tmp"), b"stale").await.unwrap();
let _manager = StreamingManager::new(path, 100).await.unwrap();
let mut rd = tokio::fs::read_dir(&tmp_dir).await.unwrap();
assert!(rd.next_entry().await.unwrap().is_none());
}
#[tokio::test]
async fn test_lazy_load_on_capacity_overflow() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
{
let manager = StreamingManager::new(path.clone(), 2).await.unwrap();
for i in 0..5 {
manager
.put(
format!("k{i}"),
response_with_body(Bytes::from(format!("body{i}"))),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
manager.metadata.run_pending_tasks().await;
}
}
let manager = StreamingManager::new(path, 2).await.unwrap();
manager.metadata.run_pending_tasks().await;
assert!(manager.entry_count() <= 2);
for i in 0..5 {
let (resp, _) =
manager.get(&format!("k{i}")).await.unwrap().unwrap();
let body = read_body_bytes(resp).await;
assert_eq!(body, format!("body{i}"));
}
}
#[tokio::test]
async fn test_concurrent_put_different_keys() {
let manager =
Arc::new(StreamingManager::with_temp_dir(100).await.unwrap());
let mut tasks = Vec::new();
for i in 0..4 {
let m = manager.clone();
tasks.push(tokio::spawn(async move {
m.put(
format!("k{i}"),
response_with_body(Bytes::from(format!("body{i}"))),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
}));
}
for t in tasks {
t.await.unwrap();
}
for i in 0..4 {
let (resp, _) =
manager.get(&format!("k{i}")).await.unwrap().unwrap();
assert_eq!(read_body_bytes(resp).await, format!("body{i}"));
}
}
#[tokio::test]
async fn test_concurrent_put_same_key() {
let manager =
Arc::new(StreamingManager::with_temp_dir(100).await.unwrap());
let m1 = manager.clone();
let m2 = manager.clone();
let t1 = tokio::spawn(async move {
m1.put(
"k".into(),
response_with_body(Bytes::from("aaa")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
});
let t2 = tokio::spawn(async move {
m2.put(
"k".into(),
response_with_body(Bytes::from("bbb")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
});
t1.await.unwrap();
t2.await.unwrap();
let (resp, _) = manager
.get("k")
.await
.unwrap()
.expect("entry must survive concurrent puts");
let body = read_body_bytes(resp).await;
assert!(body == "aaa" || body == "bbb", "got {body:?}");
let prefix_dir = manager.body_dir.join(&body_hash_for("k")[0..2]);
let mut count = 0usize;
if prefix_dir.exists() {
let mut rd = tokio::fs::read_dir(&prefix_dir).await.unwrap();
while rd.next_entry().await.unwrap().is_some() {
count += 1;
}
}
assert_eq!(count, 1, "expected exactly one body file, got {count}");
}
#[tokio::test]
async fn test_concurrent_get_put_no_entry_loss() {
let manager =
Arc::new(StreamingManager::with_temp_dir(100).await.unwrap());
manager
.put(
"k".into(),
response_with_body(Bytes::from("seed")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let putter = {
let m = manager.clone();
tokio::spawn(async move {
for i in 0..50u32 {
m.put(
"k".into(),
response_with_body(Bytes::from(format!("body-{i}"))),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
}
})
};
let getter = {
let m = manager.clone();
tokio::spawn(async move {
for _ in 0..200 {
let (resp, _) = m
.get("k")
.await
.unwrap()
.expect("entry lost during concurrent get/put");
let b = read_body_bytes(resp).await;
assert!(
b == "seed" || b.starts_with(b"body-"),
"torn body {b:?}"
);
}
})
};
putter.await.unwrap();
getter.await.unwrap();
assert!(manager.get("k").await.unwrap().is_some());
}
#[tokio::test]
async fn test_max_body_size_declines() {
let tmp = TempDir::new().unwrap();
let manager = StreamingManager::with_max_body_size(
tmp.path().to_path_buf(),
100,
10,
)
.await
.unwrap();
let returned = manager
.put(
"k".into(),
response_with_body(Bytes::from("this body exceeds the limit")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let bytes = returned.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"this body exceeds the limit");
assert!(manager.redb_get("k").await.unwrap().is_none());
let body_path = body_path_for(&manager.body_dir, &body_hash_for("k"));
assert!(!body_path.exists());
}
#[tokio::test]
async fn test_clear_wipes_everything() {
let manager = StreamingManager::with_temp_dir(100).await.unwrap();
for i in 0..3 {
manager
.put(
format!("k{i}"),
response_with_body(Bytes::from(format!("body{i}"))),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
}
manager.clear().await.unwrap();
manager.run_pending_tasks().await;
for i in 0..3 {
assert!(manager.get(&format!("k{i}")).await.unwrap().is_none());
}
let mut rd = tokio::fs::read_dir(&manager.body_dir).await.unwrap();
while let Some(entry) = rd.next_entry().await.unwrap() {
if entry.file_type().await.unwrap().is_dir() {
let mut inner =
tokio::fs::read_dir(entry.path()).await.unwrap();
while let Some(e2) = inner.next_entry().await.unwrap() {
if e2
.path()
.extension()
.map(|s| s == "bin")
.unwrap_or(false)
{
panic!(
"unexpected body file after clear: {:?}",
e2.path()
);
}
}
}
}
}
#[tokio::test]
async fn test_streaming_body_is_backed_by_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
{
let manager =
StreamingManager::new(path.clone(), 100).await.unwrap();
let big = Bytes::from(vec![0u8; 1024 * 1024]);
manager
.put(
"big".into(),
response_with_body(big),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
}
let manager = StreamingManager::new(path, 100).await.unwrap();
let (resp, _) = manager.get("big").await.unwrap().unwrap();
match resp.into_body() {
StreamingBody::File { size, .. } => {
assert_eq!(size, 1024 * 1024);
}
other => {
panic!("expected StreamingBody::File, got {other:?}");
}
}
}
#[tokio::test]
async fn test_body_size_mismatch_self_heals() {
let manager = StreamingManager::with_temp_dir(100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("abcdef")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let body_path = body_path_for(&manager.body_dir, &body_hash_for("k"));
tokio::fs::write(&body_path, vec![0xAAu8; 100]).await.unwrap();
assert!(manager.get("k").await.unwrap().is_none());
assert!(manager.redb_get("k").await.unwrap().is_none());
}
#[tokio::test]
async fn test_nonce_mismatch_self_heals() {
let manager = StreamingManager::with_temp_dir(100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("abcdef")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let body_path = body_path_for(&manager.body_dir, &body_hash_for("k"));
let mut fake = vec![0x11u8; NONCE_LEN];
fake.extend_from_slice(b"abcdef");
tokio::fs::write(&body_path, &fake).await.unwrap();
assert!(manager.get("k").await.unwrap().is_none());
assert!(manager.redb_get("k").await.unwrap().is_none());
}
#[tokio::test]
async fn test_second_instance_fails_construction() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let first = StreamingManager::new(path.clone(), 100).await.unwrap();
let second = StreamingManager::new(path.clone(), 100).await;
assert!(
second.is_err(),
"second construction must fail while first is alive"
);
drop(first);
let _third = StreamingManager::new(path, 100).await.unwrap();
}
#[tokio::test]
async fn test_in_memory_variant_still_delegates_to_temp_dir() {
#[allow(deprecated)]
let manager = StreamingManager::in_memory(10).await.unwrap();
assert!(manager.cache_dir().exists());
assert!(manager.body_dir.exists());
assert!(manager.tmp_dir.exists());
}
#[tokio::test]
async fn test_corrupted_body_detected_and_healed() {
let manager =
Arc::new(StreamingManager::with_temp_dir(100).await.unwrap());
manager
.put(
"k".into(),
response_with_body(Bytes::from("hello corruption test")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let path = body_path_for(&manager.body_dir, &body_hash_for("k"));
let mut contents = tokio::fs::read(&path).await.unwrap();
let idx = contents.len() - 1;
contents[idx] ^= 0xFF;
tokio::fs::write(&path, &contents).await.unwrap();
let (resp, _) = manager.get("k").await.unwrap().unwrap();
let collected = resp.into_body().collect().await;
assert!(collected.is_err(), "corrupt body must fail the stream");
for _ in 0..1000 {
if manager.get("k").await.unwrap().is_none() {
return;
}
tokio::task::yield_now().await;
}
panic!("corrupt entry was not self-healed");
}
#[tokio::test]
async fn test_corrupt_heal_spares_fresh_entry() {
let manager =
Arc::new(StreamingManager::with_temp_dir(100).await.unwrap());
manager
.put(
"k".into(),
response_with_body(Bytes::from("stale entry body")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let path = body_path_for(&manager.body_dir, &body_hash_for("k"));
let mut contents = tokio::fs::read(&path).await.unwrap();
let idx = contents.len() - 1;
contents[idx] ^= 0xFF;
tokio::fs::write(&path, &contents).await.unwrap();
let (resp, _) = manager.get("k").await.unwrap().unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("fresh entry body")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
assert!(resp.into_body().collect().await.is_err());
for _ in 0..1000 {
tokio::task::yield_now().await;
}
let (resp, _) = manager
.get("k")
.await
.unwrap()
.expect("fresh entry must survive the deferred heal");
assert_eq!(read_body_bytes(resp).await, "fresh entry body");
}
#[tokio::test]
async fn test_body_does_not_hold_redb_lock() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let manager = StreamingManager::new(path.clone(), 100).await.unwrap();
manager
.put(
"k".into(),
response_with_body(Bytes::from("body outlives manager")),
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let (resp, _) = manager.get("k").await.unwrap().unwrap();
drop(manager);
let manager = StreamingManager::new(path, 100).await.unwrap();
assert_eq!(read_body_bytes(resp).await, "body outlives manager");
assert!(manager.get("k").await.unwrap().is_some());
}
#[tokio::test]
async fn test_put_returns_file_variant_and_commits_before_return() {
let dir = TempDir::new().unwrap();
let manager =
StreamingManager::new(dir.path().to_path_buf(), 100).await.unwrap();
let body = Full::new(Bytes::from_static(b"hello streaming world"));
let response = Response::builder()
.status(StatusCode::OK)
.header("content-type", "text/plain")
.body(body)
.unwrap();
let returned = manager
.put(
"GET:https://example.com/test".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let got = manager.get("GET:https://example.com/test").await.unwrap();
assert!(got.is_some(), "entry must be visible when put() returns");
let body = returned.into_body();
assert!(
matches!(body, StreamingBody::File { .. }),
"put must return the File variant, got {body:?}"
);
let bytes = body.collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"hello streaming world");
let (cached, _policy) = got.unwrap();
let cached_bytes =
cached.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&cached_bytes[..], b"hello streaming world");
}
#[tokio::test]
async fn test_put_spools_frames_to_disk_incrementally() {
use std::pin::Pin;
use std::task::{Context, Poll};
const FRAME: usize = 64 * 1024;
const NFRAMES: u64 = 8;
struct AssertSpooled {
tmp_dir: PathBuf,
yielded: u64,
}
impl Body for AssertSpooled {
type Data = Bytes;
type Error = StreamingError;
fn poll_frame(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<
Option<
std::result::Result<
http_body::Frame<Bytes>,
StreamingError,
>,
>,
> {
if self.yielded > 0 {
let spooled: u64 = std::fs::read_dir(&self.tmp_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum();
let expected =
NONCE_LEN as u64 + (self.yielded - 1) * FRAME as u64;
assert!(
spooled >= expected,
"frame {} pulled but only {spooled} bytes spooled \
(expected >= {expected}); put() is buffering",
self.yielded
);
}
if self.yielded == NFRAMES {
return Poll::Ready(None);
}
self.yielded += 1;
Poll::Ready(Some(Ok(http_body::Frame::data(Bytes::from(
vec![0xAB; FRAME],
)))))
}
}
let dir = TempDir::new().unwrap();
let manager =
StreamingManager::new(dir.path().to_path_buf(), 100).await.unwrap();
let body =
AssertSpooled { tmp_dir: dir.path().join("tmp"), yielded: 0 };
let response =
Response::builder().status(StatusCode::OK).body(body).unwrap();
let returned = manager
.put(
"GET:https://example.com/incremental".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let bytes = returned.into_body().collect().await.unwrap().to_bytes();
assert_eq!(bytes.len() as u64, NFRAMES * FRAME as u64);
}
struct OneFrameBody {
frame: &'static [u8],
sent: bool,
}
impl Body for OneFrameBody {
type Data = Bytes;
type Error = StreamingError;
fn poll_frame(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<
Option<
std::result::Result<http_body::Frame<Bytes>, StreamingError>,
>,
> {
if self.sent {
std::task::Poll::Ready(None)
} else {
self.sent = true;
std::task::Poll::Ready(Some(Ok(http_body::Frame::data(
Bytes::from_static(self.frame),
))))
}
}
}
#[tokio::test]
async fn test_put_declines_oversized_content_length_up_front() {
let dir = TempDir::new().unwrap();
let manager = StreamingManager::with_max_body_size(
dir.path().to_path_buf(),
100,
1024, )
.await
.unwrap();
let response = Response::builder()
.status(StatusCode::OK)
.header("content-length", "1048576") .body(OneFrameBody { frame: b"served-after-decline", sent: false })
.unwrap();
let returned = manager
.put(
"GET:https://example.com/big".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
assert!(manager
.get("GET:https://example.com/big")
.await
.unwrap()
.is_none());
let tmp_entries = std::fs::read_dir(dir.path().join("tmp"))
.map(|rd| rd.count())
.unwrap_or(0);
assert_eq!(tmp_entries, 0, "decline must not touch the spool dir");
let bytes = returned.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"served-after-decline");
}
#[tokio::test]
async fn test_put_head_response_with_entity_content_length_is_cached() {
use http_body_util::Empty;
let dir = TempDir::new().unwrap();
let manager = StreamingManager::with_max_body_size(
dir.path().to_path_buf(),
100,
1024,
)
.await
.unwrap();
let mut response = Response::builder()
.status(StatusCode::OK)
.header("content-length", "1048576") .body(Empty::<Bytes>::new())
.unwrap();
response
.extensions_mut()
.insert(crate::CachedRequestMethod(http::Method::HEAD));
let _ = manager
.put(
"HEAD:https://example.com/test".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
assert!(
manager
.get("HEAD:https://example.com/test")
.await
.unwrap()
.is_some(),
"HEAD response must be cached despite entity Content-Length"
);
}
#[tokio::test]
async fn test_put_unknown_length_overflow_serves_full_body_uncached() {
use http_body_util::StreamBody;
let dir = TempDir::new().unwrap();
let manager = StreamingManager::with_max_body_size(
dir.path().to_path_buf(),
100,
1024,
)
.await
.unwrap();
let frames = (0..4).map(|i| {
Ok::<_, StreamingError>(http_body::Frame::data(Bytes::from(
vec![i as u8; 512],
)))
});
let body = StreamBody::new(futures_util::stream::iter(frames));
let response =
Response::builder().status(StatusCode::OK).body(body).unwrap();
let returned = manager
.put(
"GET:https://example.com/overflow".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let bytes = returned.into_body().collect().await.unwrap().to_bytes();
assert_eq!(bytes.len(), 2048, "caller must receive every byte");
assert_eq!(&bytes[..512], &[0u8; 512][..]);
assert_eq!(&bytes[1536..], &[3u8; 512][..]);
assert!(
manager
.get("GET:https://example.com/overflow")
.await
.unwrap()
.is_none(),
"overflowing entry must not be cached"
);
let tmp_entries =
std::fs::read_dir(dir.path().join("tmp")).unwrap().count();
assert_eq!(tmp_entries, 0);
}
#[tokio::test]
async fn test_put_exactly_max_body_size_is_cached() {
let dir = TempDir::new().unwrap();
let manager = StreamingManager::with_max_body_size(
dir.path().to_path_buf(),
100,
1024,
)
.await
.unwrap();
let body = Full::new(Bytes::from(vec![7u8; 1024]));
let response =
Response::builder().status(StatusCode::OK).body(body).unwrap();
let _ = manager
.put(
"GET:https://example.com/exact".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
assert!(manager
.get("GET:https://example.com/exact")
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn test_put_content_length_mismatch_declines_commit() {
let dir = TempDir::new().unwrap();
let manager =
StreamingManager::new(dir.path().to_path_buf(), 100).await.unwrap();
let body = Full::new(Bytes::from_static(b"short"));
let response = Response::builder()
.status(StatusCode::OK)
.header("content-length", "100")
.body(body)
.unwrap();
let returned = manager
.put(
"GET:https://example.com/truncated".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let bytes = returned.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"short");
assert!(manager
.get("GET:https://example.com/truncated")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn test_put_upstream_error_fails_and_cleans_tmp() {
use http_body_util::StreamBody;
let dir = TempDir::new().unwrap();
let manager =
StreamingManager::new(dir.path().to_path_buf(), 100).await.unwrap();
let frames = vec![
Ok(http_body::Frame::data(Bytes::from_static(b"good"))),
Err(StreamingError::new(Box::new(std::io::Error::other(
"upstream reset",
)))),
];
let body = StreamBody::new(futures_util::stream::iter(frames));
let response =
Response::builder().status(StatusCode::OK).body(body).unwrap();
let result = manager
.put(
"GET:https://example.com/reset".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await;
assert!(result.is_err(), "upstream error must propagate");
assert!(manager
.get("GET:https://example.com/reset")
.await
.unwrap()
.is_none());
let tmp_entries =
std::fs::read_dir(dir.path().join("tmp")).unwrap().count();
assert_eq!(tmp_entries, 0, "tmp must be cleaned on upstream error");
}
#[tokio::test]
async fn test_put_empty_body_round_trips() {
use http_body_util::Empty;
let dir = TempDir::new().unwrap();
let manager =
StreamingManager::new(dir.path().to_path_buf(), 100).await.unwrap();
let body = Empty::<Bytes>::new();
let response = Response::builder()
.status(StatusCode::NO_CONTENT)
.body(body)
.unwrap();
let returned = manager
.put(
"GET:https://example.com/empty".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let bytes = returned.into_body().collect().await.unwrap().to_bytes();
assert!(bytes.is_empty());
let (cached, _) = manager
.get("GET:https://example.com/empty")
.await
.unwrap()
.unwrap();
let cached_bytes =
cached.into_body().collect().await.unwrap().to_bytes();
assert!(cached_bytes.is_empty());
}
#[tokio::test]
async fn test_put_preserves_extensions_on_success_and_decline() {
#[derive(Clone, PartialEq, Debug)]
struct Marker(u32);
let dir = TempDir::new().unwrap();
let manager = StreamingManager::with_max_body_size(
dir.path().to_path_buf(),
100,
1024,
)
.await
.unwrap();
let mut response = Response::builder()
.status(StatusCode::OK)
.body(Full::new(Bytes::from_static(b"ok")))
.unwrap();
response.extensions_mut().insert(Marker(1));
let returned = manager
.put(
"GET:https://example.com/ext1".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
assert_eq!(returned.extensions().get::<Marker>(), Some(&Marker(1)));
let mut response = Response::builder()
.status(StatusCode::OK)
.header("content-length", "1048576")
.body(Full::new(Bytes::from(vec![0u8; 16])))
.unwrap();
response.extensions_mut().insert(Marker(2));
let returned = manager
.put(
"GET:https://example.com/ext2".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
assert_eq!(returned.extensions().get::<Marker>(), Some(&Marker(2)));
}
#[tokio::test]
async fn test_put_zero_max_body_size_always_declines() {
let dir = TempDir::new().unwrap();
let manager = StreamingManager::with_max_body_size(
dir.path().to_path_buf(),
100,
0,
)
.await
.unwrap();
let response = Response::builder()
.status(StatusCode::OK)
.body(Full::new(Bytes::from_static(b"never cached")))
.unwrap();
let returned = manager
.put(
"GET:https://example.com/zero".to_string(),
response,
sample_policy(),
test_url(),
None,
)
.await
.unwrap();
let bytes = returned.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"never cached");
assert!(manager
.get("GET:https://example.com/zero")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn test_put_decline_path_streams_first_frame_before_eof() {
use std::pin::Pin;
use std::task::{Context, Poll};
struct FirstFrameThenForeverPending {
sent: bool,
}
impl Body for FirstFrameThenForeverPending {
type Data = Bytes;
type Error = StreamingError;
fn poll_frame(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<
Option<
std::result::Result<
http_body::Frame<Bytes>,
StreamingError,
>,
>,
> {
if self.sent {
Poll::Pending } else {
self.sent = true;
Poll::Ready(Some(Ok(http_body::Frame::data(
Bytes::from_static(b"first"),
))))
}
}
}
let dir = TempDir::new().unwrap();
let manager = StreamingManager::with_max_body_size(
dir.path().to_path_buf(),
100,
1024,
)
.await
.unwrap();
let response = Response::builder()
.status(StatusCode::OK)
.header("content-length", "1048576") .body(FirstFrameThenForeverPending { sent: false })
.unwrap();
let returned = tokio::time::timeout(
std::time::Duration::from_secs(10),
manager.put(
"GET:https://example.com/ttfb".to_string(),
response,
sample_policy(),
test_url(),
None,
),
)
.await
.expect("put() must return without consuming the stalled upstream")
.unwrap();
let mut body = returned.into_body();
let first = tokio::time::timeout(
std::time::Duration::from_secs(5),
body.frame(),
)
.await
.expect("first frame must arrive without waiting for upstream EOF")
.unwrap()
.unwrap();
assert_eq!(first.into_data().unwrap(), Bytes::from_static(b"first"));
}
#[tokio::test]
async fn test_spool_write_surfaces_deferred_write_error() {
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
struct DeferredErrorWriter;
impl AsyncWrite for DeferredErrorWriter {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::io::Result<()>> {
Poll::Ready(Err(std::io::Error::other("disk full")))
}
fn poll_shutdown(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
let err = spool_write(&mut DeferredErrorWriter, b"frame")
.await
.expect_err("a flush-time failure must be reported");
assert_eq!(err.to_string(), "disk full");
}
#[test]
fn test_tmp_guard_unlinks_unless_defused() {
let dir = TempDir::new().unwrap();
let armed = dir.path().join("armed.tmp");
let defused = dir.path().join("defused.tmp");
std::fs::write(&armed, b"x").unwrap();
std::fs::write(&defused, b"x").unwrap();
{
let _g = TmpGuard::new(armed.clone());
}
{
let mut g = TmpGuard::new(defused.clone());
g.defuse();
}
assert!(!armed.exists(), "armed guard must unlink on drop");
assert!(defused.exists(), "defused guard must leave the file");
}
#[test]
fn test_parse_content_length() {
let mut h = http::HeaderMap::new();
assert_eq!(parse_content_length(&h), None, "absent -> None");
h.insert(http::header::CONTENT_LENGTH, "1234".parse().unwrap());
assert_eq!(parse_content_length(&h), Some(1234));
h.append(http::header::CONTENT_LENGTH, "1234".parse().unwrap());
assert_eq!(parse_content_length(&h), Some(1234), "agreeing dupes ok");
h.append(http::header::CONTENT_LENGTH, "999".parse().unwrap());
assert_eq!(parse_content_length(&h), None, "disagreeing dupes -> None");
let mut bad = http::HeaderMap::new();
bad.insert(http::header::CONTENT_LENGTH, "12x4".parse().unwrap());
assert_eq!(parse_content_length(&bad), None, "unparseable -> None");
}
fn header_values(h: &HttpHeaders, name: &str) -> Vec<String> {
h.iter()
.filter(|(k, _)| k.as_str() == name)
.map(|(_, v)| v.clone())
.collect()
}
#[test]
fn test_stored_headers_strips_hop_by_hop_keeps_multi_valued() {
let mut h = http::HeaderMap::new();
h.insert("transfer-encoding", "chunked".parse().unwrap());
h.insert("connection", "keep-alive, x-tracing-id".parse().unwrap());
h.insert("x-tracing-id", "abc".parse().unwrap()); h.insert("proxy-connection", "keep-alive".parse().unwrap());
h.insert("te", "trailers".parse().unwrap());
h.insert("content-type", "text/plain".parse().unwrap());
h.append("set-cookie", "a=1".parse().unwrap());
h.append("set-cookie", "b=2".parse().unwrap());
let stored = stored_headers(&h);
assert!(header_values(&stored, "transfer-encoding").is_empty());
assert!(header_values(&stored, "connection").is_empty());
assert!(header_values(&stored, "proxy-connection").is_empty());
assert!(header_values(&stored, "te").is_empty());
assert!(
header_values(&stored, "x-tracing-id").is_empty(),
"Connection-nominated fields must be stripped (RFC 9111 ยง3.1)"
);
assert_eq!(header_values(&stored, "content-type"), vec!["text/plain"]);
assert_eq!(header_values(&stored, "set-cookie"), vec!["a=1", "b=2"]);
}
#[tokio::test]
async fn test_serve_uncached_spooled_chains_prefix_pending_rest_and_unlinks(
) {
let dir = TempDir::new().unwrap();
let tmp = dir.path().join("spool.tmp");
let mut f = tokio::fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&tmp)
.await
.unwrap();
f.write_all(&[0u8; NONCE_LEN]).await.unwrap();
f.write_all(b"prefix12").await.unwrap();
f.write_all(b"J").await.unwrap(); f.flush().await.unwrap();
let guard = TmpGuard::new(tmp.clone());
let pending = Some(Bytes::from_static(b"PENDING!"));
let rest: Option<UnsyncBoxBody<Bytes, StreamingError>> = Some(
Full::new(Bytes::from_static(b"rest-of-upstream"))
.map_err(|never| match never {})
.boxed_unsync(),
);
let parts =
Response::builder().status(200).body(()).unwrap().into_parts().0;
let resp =
serve_uncached_spooled(parts, f, guard, 8, pending, rest).unwrap();
let collected = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&collected[..], b"prefix12PENDING!rest-of-upstream");
assert!(!tmp.exists(), "tmp file must be unlinked after body drop");
}
#[tokio::test]
async fn test_serve_uncached_spooled_prefix_only() {
use http_body_util::BodyExt;
let dir = TempDir::new().unwrap();
let tmp = dir.path().join("spool2.tmp");
let mut f = tokio::fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(&tmp)
.await
.unwrap();
f.write_all(&[0u8; NONCE_LEN]).await.unwrap();
f.write_all(b"only-prefix").await.unwrap();
f.flush().await.unwrap();
let guard = TmpGuard::new(tmp.clone());
let parts =
Response::builder().status(200).body(()).unwrap().into_parts().0;
let resp =
serve_uncached_spooled(parts, f, guard, 11, None, None).unwrap();
let collected = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&collected[..], b"only-prefix");
assert!(!tmp.exists());
}
}