264 lines
8.8 KiB
Rust
264 lines
8.8 KiB
Rust
//! File-backed peer responses with bounded buffers and private payment snapshots.
|
|||
|
|
//! Snapshot construction finishes before bearer ecash is redeemed. Anonymous
|
||
|
|
//! temporary files are removed automatically when the response is dropped.
|
||
|
|
use anyhow::{Context, Result};
|
||
|
|
use hyper::{Body, Response, StatusCode};
|
||
|
|
use std::path::Path;
|
||
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
||
|
|
use tokio::fs::File;
|
||
|
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||
|
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||
|
|
|
||
|
|
const CHUNK: usize = 64 * 1024;
|
||
|
|
const DISK_RESERVE: u64 = 256 * 1024 * 1024;
|
||
|
|
static RESERVED: Mutex<u64> = Mutex::new(0);
|
||
|
|
static SLOTS: OnceLock<Arc<Semaphore>> = OnceLock::new();
|
||
|
|
|
||
|
|
struct Reservation {
|
||
|
|
bytes: u64,
|
||
|
|
_slot: OwnedSemaphorePermit,
|
||
|
|
}
|
||
|
|
impl Drop for Reservation {
|
||
|
|
fn drop(&mut self) {
|
||
|
|
let mut reserved = RESERVED.lock().unwrap_or_else(|e| e.into_inner());
|
||
|
|
*reserved = reserved.saturating_sub(self.bytes);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn reserve(file: &File, length: u64) -> Result<Reservation> {
|
||
|
|
use std::os::fd::AsRawFd;
|
||
|
|
let slot = SLOTS
|
||
|
|
.get_or_init(|| Arc::new(Semaphore::new(4)))
|
||
|
|
.clone()
|
||
|
|
.try_acquire_owned()
|
||
|
|
.context("Content preparation is busy")?;
|
||
|
|
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
|
||
|
|
// The live descriptor supplies the filesystem; no attacker-controlled C path.
|
||
|
|
if unsafe { libc::fstatvfs(file.as_raw_fd(), stat.as_mut_ptr()) } != 0 {
|
||
|
|
return Err(std::io::Error::last_os_error()).context("Checking content staging space");
|
||
|
|
}
|
||
|
|
let stat = unsafe { stat.assume_init() };
|
||
|
|
let available = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64);
|
||
|
|
let mut reserved = RESERVED.lock().unwrap_or_else(|e| e.into_inner());
|
||
|
|
let next = reserved
|
||
|
|
.checked_add(length)
|
||
|
|
.context("Content size overflow")?;
|
||
|
|
anyhow::ensure!(
|
||
|
|
next.checked_add(DISK_RESERVE.max(available / 20))
|
||
|
|
.is_some_and(|n| n <= available),
|
||
|
|
"Insufficient private staging space; no payment was redeemed"
|
||
|
|
);
|
||
|
|
*reserved = next;
|
||
|
|
Ok(Reservation {
|
||
|
|
bytes: length,
|
||
|
|
_slot: slot,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub struct PreparedMedia {
|
||
|
|
file: File,
|
||
|
|
length: u64,
|
||
|
|
mime: String,
|
||
|
|
range: Option<(u64, u64, u64)>,
|
||
|
|
reservation: Option<Reservation>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl PreparedMedia {
|
||
|
|
pub async fn direct(
|
||
|
|
mut file: File,
|
||
|
|
start: u64,
|
||
|
|
length: u64,
|
||
|
|
mime: String,
|
||
|
|
range: Option<(u64, u64, u64)>,
|
||
|
|
) -> Result<Self> {
|
||
|
|
anyhow::ensure!(
|
||
|
|
file.metadata().await?.is_file(),
|
||
|
|
"Content is not a regular file"
|
||
|
|
);
|
||
|
|
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||
|
|
Ok(Self {
|
||
|
|
file,
|
||
|
|
length,
|
||
|
|
mime,
|
||
|
|
range,
|
||
|
|
reservation: None,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn snapshot<R: AsyncRead + Unpin>(
|
||
|
|
data_dir: &Path,
|
||
|
|
mut source: R,
|
||
|
|
length: u64,
|
||
|
|
mime: String,
|
||
|
|
range: Option<(u64, u64, u64)>,
|
||
|
|
) -> Result<Self> {
|
||
|
|
let dir = data_dir.join("content-staging");
|
||
|
|
tokio::fs::create_dir_all(&dir).await?;
|
||
|
|
let temporary = tokio::task::spawn_blocking(move || tempfile::tempfile_in(dir)).await??;
|
||
|
|
let mut file = File::from_std(temporary);
|
||
|
|
let reservation = reserve(&file, length)?;
|
||
|
|
let mut left = length;
|
||
|
|
let mut buffer = vec![0; CHUNK];
|
||
|
|
while left > 0 {
|
||
|
|
let limit = left.min(CHUNK as u64) as usize;
|
||
|
|
let count = source.read(&mut buffer[..limit]).await?;
|
||
|
|
anyhow::ensure!(
|
||
|
|
count > 0,
|
||
|
|
"Content changed while preparing payment response"
|
||
|
|
);
|
||
|
|
file.write_all(&buffer[..count]).await?;
|
||
|
|
left -= count as u64;
|
||
|
|
}
|
||
|
|
// Detect writeback errors before the caller attempts bearer redemption.
|
||
|
|
file.flush().await?;
|
||
|
|
file.sync_data().await?;
|
||
|
|
file.seek(std::io::SeekFrom::Start(0)).await?;
|
||
|
|
Ok(Self {
|
||
|
|
file,
|
||
|
|
length,
|
||
|
|
mime,
|
||
|
|
range,
|
||
|
|
reservation: Some(reservation),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn into_response(self) -> Result<Response<Body>> {
|
||
|
|
let Self {
|
||
|
|
file,
|
||
|
|
length,
|
||
|
|
mime,
|
||
|
|
range,
|
||
|
|
reservation,
|
||
|
|
} = self;
|
||
|
|
let chunks = futures_util::stream::try_unfold(
|
||
|
|
(file, length, reservation),
|
||
|
|
|(mut file, left, reservation)| async move {
|
||
|
|
if left == 0 {
|
||
|
|
return Ok::<_, std::io::Error>(None);
|
||
|
|
}
|
||
|
|
let mut buffer = vec![0; left.min(CHUNK as u64) as usize];
|
||
|
|
let count = file.read(&mut buffer).await?;
|
||
|
|
if count == 0 {
|
||
|
|
return Err(std::io::Error::new(
|
||
|
|
std::io::ErrorKind::UnexpectedEof,
|
||
|
|
"Content changed during transfer",
|
||
|
|
));
|
||
|
|
}
|
||
|
|
buffer.truncate(count);
|
||
|
|
Ok(Some((buffer, (file, left - count as u64, reservation))))
|
||
|
|
},
|
||
|
|
);
|
||
|
|
let mut response = Response::builder()
|
||
|
|
.status(if range.is_some() {
|
||
|
|
StatusCode::PARTIAL_CONTENT
|
||
|
|
} else {
|
||
|
|
StatusCode::OK
|
||
|
|
})
|
||
|
|
.header("Content-Type", mime)
|
||
|
|
.header("Content-Length", length)
|
||
|
|
.header("Accept-Ranges", "bytes")
|
||
|
|
.header("X-Content-Type-Options", "nosniff")
|
||
|
|
.header("Cache-Control", "private, no-store");
|
||
|
|
if let Some((start, end, total)) = range {
|
||
|
|
response = response.header("Content-Range", format!("bytes {start}-{end}/{total}"));
|
||
|
|
}
|
||
|
|
Ok(response.body(Body::wrap_stream(chunks))?)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
use hyper::body::HttpBody;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn direct_large_sparse_file_does_not_read_ahead_and_short_reads_fail() {
|
||
|
|
let dir = tempfile::tempdir().unwrap();
|
||
|
|
let path = dir.path().join("film");
|
||
|
|
let write = File::create(&path).await.unwrap();
|
||
|
|
write.set_len(4 * 1024 * 1024 * 1024).await.unwrap();
|
||
|
|
let mut response = PreparedMedia::direct(
|
||
|
|
File::open(&path).await.unwrap(),
|
||
|
|
0,
|
||
|
|
4 * 1024 * 1024 * 1024,
|
||
|
|
"video/mp4".into(),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap()
|
||
|
|
.into_response()
|
||
|
|
.unwrap();
|
||
|
|
assert_eq!(response.headers()["content-length"], "4294967296");
|
||
|
|
assert_eq!(
|
||
|
|
response.body_mut().data().await.unwrap().unwrap().len(),
|
||
|
|
CHUNK
|
||
|
|
);
|
||
|
|
write.set_len(0).await.unwrap();
|
||
|
|
assert!(response.body_mut().data().await.unwrap().is_err());
|
||
|
|
drop(response);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn snapshot_survives_original_removal_and_has_no_named_temporary_file() {
|
||
|
|
let dir = tempfile::tempdir().unwrap();
|
||
|
|
let path = dir.path().join("original");
|
||
|
|
let bytes = vec![73; 3 * 1024 * 1024];
|
||
|
|
tokio::fs::write(&path, &bytes).await.unwrap();
|
||
|
|
let prepared = PreparedMedia::snapshot(
|
||
|
|
dir.path(),
|
||
|
|
File::open(&path).await.unwrap(),
|
||
|
|
bytes.len() as u64,
|
||
|
|
"application/octet-stream".into(),
|
||
|
|
None,
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
tokio::fs::remove_file(path).await.unwrap();
|
||
|
|
assert_eq!(
|
||
|
|
std::fs::read_dir(dir.path().join("content-staging"))
|
||
|
|
.unwrap()
|
||
|
|
.count(),
|
||
|
|
0
|
||
|
|
);
|
||
|
|
let mut response = prepared.into_response().unwrap();
|
||
|
|
let mut received = Vec::new();
|
||
|
|
while let Some(chunk) = response.body_mut().data().await {
|
||
|
|
let chunk = chunk.unwrap();
|
||
|
|
assert!(chunk.len() <= CHUNK);
|
||
|
|
received.extend_from_slice(&chunk);
|
||
|
|
}
|
||
|
|
assert_eq!(received, bytes);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn incomplete_snapshot_fails_before_a_payment_can_be_attempted() {
|
||
|
|
let dir = tempfile::tempdir().unwrap();
|
||
|
|
assert!(
|
||
|
|
PreparedMedia::snapshot(dir.path(), &b"short"[..], 100, "x".into(), None)
|
||
|
|
.await
|
||
|
|
.is_err()
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
std::fs::read_dir(dir.path().join("content-staging"))
|
||
|
|
.unwrap()
|
||
|
|
.count(),
|
||
|
|
0
|
||
|
|
);
|
||
|
|
// A subsequent snapshot still works; the failed preparation releases its slot.
|
||
|
|
let body = PreparedMedia::snapshot(
|
||
|
|
dir.path(),
|
||
|
|
&b"ok"[..],
|
||
|
|
2,
|
||
|
|
"text/plain".into(),
|
||
|
|
Some((4, 5, 10)),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
.unwrap()
|
||
|
|
.into_response()
|
||
|
|
.unwrap();
|
||
|
|
assert_eq!(body.status(), StatusCode::PARTIAL_CONTENT);
|
||
|
|
assert_eq!(body.headers()["content-range"], "bytes 4-5/10");
|
||
|
|
assert_eq!(hyper::body::to_bytes(body.into_body()).await.unwrap(), "ok");
|
||
|
|
}
|
||
|
|
}
|