use std::io;
use std::path::Path;
use std::sync::Arc;
use bytes::Bytes;
pub use futures_util::future::BoxFuture;
#[allow(clippy::len_without_is_empty)]
pub trait Backend: Send + Sync + 'static {
fn len(&self) -> BoxFuture<'_, io::Result<u64>>;
fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>>;
}
pub struct MemBackend(Bytes);
impl From<Vec<u8>> for MemBackend {
fn from(data: Vec<u8>) -> MemBackend {
MemBackend(Bytes::from(data))
}
}
impl From<Bytes> for MemBackend {
fn from(data: Bytes) -> MemBackend {
MemBackend(data)
}
}
impl Backend for MemBackend {
fn len(&self) -> BoxFuture<'_, io::Result<u64>> {
let total = self.0.len() as u64;
Box::pin(async move { Ok(total) })
}
fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>> {
Box::pin(async move {
let data = &self.0;
let start = usize::try_from(offset)
.unwrap_or(usize::MAX)
.min(data.len());
let count = buf.len().min(data.len() - start);
buf[..count].copy_from_slice(&data[start..start + count]);
Ok(count)
})
}
}
pub struct FileBackend {
file: Arc<std::fs::File>,
len: u64,
}
impl FileBackend {
pub async fn open(path: impl AsRef<Path>) -> io::Result<FileBackend> {
let path = path.as_ref().to_owned();
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
Ok(FileBackend {
file: Arc::new(file),
len,
})
})
.await
.map_err(io::Error::other)?
}
}
fn positioned_read(file: &std::fs::File, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
#[cfg(unix)]
{
use std::os::unix::fs::FileExt;
file.read_at(buf, offset)
}
#[cfg(windows)]
{
use std::os::windows::fs::FileExt;
file.seek_read(buf, offset)
}
}
fn read_at_fully(file: &std::fs::File, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
let mut filled = 0;
while filled < buf.len() {
let count = positioned_read(file, offset + filled as u64, &mut buf[filled..])?;
if count == 0 {
break;
}
filled += count;
}
Ok(filled)
}
impl Backend for FileBackend {
fn len(&self) -> BoxFuture<'_, io::Result<u64>> {
let total = self.len;
Box::pin(async move { Ok(total) })
}
fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>> {
let file = Arc::clone(&self.file);
let wanted = buf.len();
Box::pin(async move {
let chunk = tokio::task::spawn_blocking(move || {
let mut scratch = vec![0u8; wanted];
let count = read_at_fully(&file, offset, &mut scratch)?;
scratch.truncate(count);
Ok::<Vec<u8>, io::Error>(scratch)
})
.await
.map_err(io::Error::other)??;
buf[..chunk.len()].copy_from_slice(&chunk);
Ok(chunk.len())
})
}
}
#[cfg(feature = "http")]
pub struct HttpBackend {
client: reqwest::Client,
url: reqwest::Url,
len: u64,
}
#[cfg(feature = "http")]
impl HttpBackend {
pub async fn new(url: impl reqwest::IntoUrl) -> crate::Result<HttpBackend> {
let url = url.into_url().map_err(|err| crate::Error::Http {
status: None,
msg: err.to_string(),
})?;
let client = reqwest::Client::new();
let response = client
.head(url.clone())
.send()
.await
.map_err(|err| crate::Error::Http {
status: err.status().map(|status| status.as_u16()),
msg: err.to_string(),
})?;
if !response.status().is_success() {
return Err(crate::Error::Http {
status: Some(response.status().as_u16()),
msg: format!("HEAD {url} failed"),
});
}
let len = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.ok_or_else(|| crate::Error::Http {
status: Some(response.status().as_u16()),
msg: format!("HEAD {url}: missing or malformed Content-Length"),
})?;
Ok(HttpBackend { client, url, len })
}
}
#[cfg(feature = "http")]
fn http_io_error(marker: crate::error::TransportMarker) -> io::Error {
io::Error::other(marker)
}
#[cfg(feature = "http")]
impl Backend for HttpBackend {
fn len(&self) -> BoxFuture<'_, io::Result<u64>> {
let total = self.len;
Box::pin(async move { Ok(total) })
}
fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>> {
Box::pin(async move {
if offset >= self.len || buf.is_empty() {
return Ok(0);
}
let last = (offset + buf.len() as u64 - 1).min(self.len - 1);
let response = self
.client
.get(self.url.clone())
.header(reqwest::header::RANGE, format!("bytes={offset}-{last}"))
.send()
.await
.map_err(|err| {
http_io_error(crate::error::TransportMarker::Http {
status: err.status().map(|status| status.as_u16()),
msg: format!("GET {} range {offset}-{last}: {err}", self.url),
})
})?;
match response.status().as_u16() {
206 => {}
200 => {
return Err(http_io_error(
crate::error::TransportMarker::RangeUnsupported,
));
}
status => {
return Err(http_io_error(crate::error::TransportMarker::Http {
status: Some(status),
msg: format!("GET {} range {offset}-{last} failed", self.url),
}));
}
}
use futures_util::StreamExt;
let mut chunks = response.bytes_stream();
let mut filled = 0usize;
while filled < buf.len() {
let chunk = match chunks.next().await {
Some(Ok(chunk)) => chunk,
Some(Err(err)) => {
return Err(http_io_error(crate::error::TransportMarker::Http {
status: None,
msg: format!("GET {} range {offset}-{last}: {err}", self.url),
}))
}
None => break, };
let take = (buf.len() - filled).min(chunk.len());
buf[filled..filled + take].copy_from_slice(&chunk[..take]);
filled += take;
}
Ok(filled)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn mem_backend_reads_and_reports_length() {
let backend = MemBackend::from(b"hello world".to_vec());
assert_eq!(backend.len().await.unwrap(), 11);
let mut buf = [0u8; 5];
assert_eq!(backend.read_at(6, &mut buf).await.unwrap(), 5);
assert_eq!(&buf, b"world");
}
#[tokio::test]
async fn mem_backend_short_reads_only_at_eof() {
let backend = MemBackend::from(bytes::Bytes::from_static(b"abcdef"));
let mut buf = [0u8; 10];
assert_eq!(backend.read_at(4, &mut buf).await.unwrap(), 2);
assert_eq!(&buf[..2], b"ef");
assert_eq!(backend.read_at(6, &mut buf).await.unwrap(), 0);
assert_eq!(backend.read_at(999, &mut buf).await.unwrap(), 0);
}
#[tokio::test]
async fn backend_is_object_safe() {
let boxed: std::sync::Arc<dyn Backend> =
std::sync::Arc::new(MemBackend::from(b"xyz".to_vec()));
assert_eq!(boxed.len().await.unwrap(), 3);
}
#[tokio::test]
async fn file_backend_positioned_reads() {
let path = std::env::temp_dir().join(format!(
"pdfboss-aio-backend-test-{}.bin",
std::process::id()
));
std::fs::write(&path, b"0123456789abcdef").unwrap();
let backend = FileBackend::open(&path).await.unwrap();
assert_eq!(backend.len().await.unwrap(), 16);
let mut buf = [0u8; 4];
assert_eq!(backend.read_at(10, &mut buf).await.unwrap(), 4);
assert_eq!(&buf, b"abcd");
assert_eq!(backend.read_at(0, &mut buf).await.unwrap(), 4);
assert_eq!(&buf, b"0123");
let mut long = [0u8; 32];
assert_eq!(backend.read_at(12, &mut long).await.unwrap(), 4);
assert_eq!(&long[..4], b"cdef");
std::fs::remove_file(&path).ok();
}
#[tokio::test]
async fn file_backend_open_missing_file_errors() {
let missing = std::env::temp_dir().join("pdfboss-aio-backend-test-missing.bin");
assert!(FileBackend::open(&missing).await.is_err());
}
#[cfg(feature = "http")]
#[test]
fn range_refusal_marker_round_trips_through_io_error() {
let refused = http_io_error(crate::error::TransportMarker::RangeUnsupported);
assert!(matches!(
crate::Error::from(refused),
crate::Error::RangeUnsupported
));
let failed = http_io_error(crate::error::TransportMarker::Http {
status: Some(503),
msg: "unavailable".to_string(),
});
assert!(matches!(
crate::Error::from(failed),
crate::Error::Http {
status: Some(503),
..
}
));
}
}