Skip to main content

pdfboss_aio/
backend.rs

1//! Random-access byte sources: in-memory bytes, positioned file reads on a
2//! blocking thread, and (behind the `http` feature) remote HTTP range
3//! requests with a one-time full-download fallback for range-less servers.
4//! The trait is object-safe — futures are boxed — so documents can hold
5//! `Arc<dyn Backend>`.
6
7use std::io;
8use std::path::Path;
9use std::sync::Arc;
10
11use bytes::Bytes;
12pub use futures_util::future::BoxFuture;
13
14/// Random-access byte source. Object-safe: futures are boxed.
15#[allow(clippy::len_without_is_empty)]
16pub trait Backend: Send + Sync + 'static {
17    /// Total length of the underlying byte source.
18    fn len(&self) -> BoxFuture<'_, io::Result<u64>>;
19
20    /// Reads up to `buf.len()` bytes at `offset` into `buf`, returning the
21    /// number of bytes read. Implementations may only return a short count
22    /// at end of input; anywhere else they must fill the buffer.
23    fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>>;
24}
25
26/// A byte source fully resident in memory. Used directly (uncached) by
27/// [`crate::document::AsyncDocument::from_bytes`].
28pub struct MemBackend(Bytes);
29
30impl From<Vec<u8>> for MemBackend {
31    fn from(data: Vec<u8>) -> MemBackend {
32        MemBackend(Bytes::from(data))
33    }
34}
35
36impl From<Bytes> for MemBackend {
37    fn from(data: Bytes) -> MemBackend {
38        MemBackend(data)
39    }
40}
41
42/// One bounded read from an in-memory byte source: offsets at or past the
43/// end read zero bytes, everything else fills as much of `buf` as the data
44/// allows.
45fn read_from_bytes(data: &[u8], offset: u64, buf: &mut [u8]) -> usize {
46    let start = usize::try_from(offset)
47        .unwrap_or(usize::MAX)
48        .min(data.len());
49    let count = buf.len().min(data.len() - start);
50    buf[..count].copy_from_slice(&data[start..start + count]);
51    count
52}
53
54impl Backend for MemBackend {
55    fn len(&self) -> BoxFuture<'_, io::Result<u64>> {
56        let total = self.0.len() as u64;
57        Box::pin(async move { Ok(total) })
58    }
59
60    fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>> {
61        Box::pin(async move { Ok(read_from_bytes(&self.0, offset, buf)) })
62    }
63}
64
65/// A byte source backed by a file. Reads run as positioned reads on a
66/// blocking thread pool so the async runtime is never stalled by disk I/O;
67/// the length is captured once at open (the file is treated as immutable
68/// while the backend lives).
69pub struct FileBackend {
70    file: Arc<std::fs::File>,
71    len: u64,
72}
73
74impl FileBackend {
75    /// Opens `path` and records its current length.
76    pub async fn open(path: impl AsRef<Path>) -> io::Result<FileBackend> {
77        let path = path.as_ref().to_owned();
78        tokio::task::spawn_blocking(move || {
79            let file = std::fs::File::open(path)?;
80            let len = file.metadata()?.len();
81            Ok(FileBackend {
82                file: Arc::new(file),
83                len,
84            })
85        })
86        .await
87        .map_err(io::Error::other)?
88    }
89}
90
91/// One positioned read at `offset` (no shared cursor).
92fn positioned_read(file: &std::fs::File, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
93    #[cfg(unix)]
94    {
95        use std::os::unix::fs::FileExt;
96        file.read_at(buf, offset)
97    }
98    #[cfg(windows)]
99    {
100        use std::os::windows::fs::FileExt;
101        file.seek_read(buf, offset)
102    }
103}
104
105/// Loops positioned reads over short counts so callers only ever see a
106/// short total at end of file.
107fn read_at_fully(file: &std::fs::File, offset: u64, buf: &mut [u8]) -> io::Result<usize> {
108    let mut filled = 0;
109    while filled < buf.len() {
110        let count = positioned_read(file, offset + filled as u64, &mut buf[filled..])?;
111        if count == 0 {
112            break;
113        }
114        filled += count;
115    }
116    Ok(filled)
117}
118
119impl Backend for FileBackend {
120    fn len(&self) -> BoxFuture<'_, io::Result<u64>> {
121        let total = self.len;
122        Box::pin(async move { Ok(total) })
123    }
124
125    fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>> {
126        let file = Arc::clone(&self.file);
127        let wanted = buf.len();
128        Box::pin(async move {
129            let chunk = tokio::task::spawn_blocking(move || {
130                let mut scratch = vec![0u8; wanted];
131                let count = read_at_fully(&file, offset, &mut scratch)?;
132                scratch.truncate(count);
133                Ok::<Vec<u8>, io::Error>(scratch)
134            })
135            .await
136            .map_err(io::Error::other)??;
137            buf[..chunk.len()].copy_from_slice(&chunk);
138            Ok(chunk.len())
139        })
140    }
141}
142
143/// A byte source over HTTP: length via `HEAD`/`Content-Length`, reads via
144/// `Range: bytes=` requests. A server that ignores Range (answers 200 with
145/// the full body instead of 206, like `python3 -m http.server`) triggers a
146/// one-time fallback: that very response body is the whole resource, so it
147/// is collected into memory (capped at the declared length) and every read
148/// is served from it. Range-less servers cost one full download held
149/// resident instead of failing.
150#[cfg(feature = "http")]
151pub struct HttpBackend {
152    client: reqwest::Client,
153    url: reqwest::Url,
154    len: u64,
155    full: std::sync::OnceLock<Bytes>,
156    progress: Option<Arc<dyn Fn(u64, u64) + Send + Sync>>,
157}
158
159#[cfg(feature = "http")]
160impl HttpBackend {
161    /// Issues a `HEAD` request to learn the resource length.
162    pub async fn new(url: impl reqwest::IntoUrl) -> crate::Result<HttpBackend> {
163        let url = url.into_url().map_err(|err| crate::Error::Http {
164            status: None,
165            msg: err.to_string(),
166        })?;
167        let client = reqwest::Client::new();
168        let response = client
169            .head(url.clone())
170            .send()
171            .await
172            .map_err(|err| crate::Error::Http {
173                status: err.status().map(|status| status.as_u16()),
174                msg: err.to_string(),
175            })?;
176        if !response.status().is_success() {
177            return Err(crate::Error::Http {
178                status: Some(response.status().as_u16()),
179                msg: format!("HEAD {url} failed"),
180            });
181        }
182        let len = response
183            .headers()
184            .get(reqwest::header::CONTENT_LENGTH)
185            .and_then(|value| value.to_str().ok())
186            .and_then(|value| value.parse().ok())
187            .ok_or_else(|| crate::Error::Http {
188                status: Some(response.status().as_u16()),
189                msg: format!("HEAD {url}: missing or malformed Content-Length"),
190            })?;
191        Ok(HttpBackend {
192            client,
193            url,
194            len,
195            full: std::sync::OnceLock::new(),
196            progress: None,
197        })
198    }
199
200    /// Registers a fallback-download observer: when a range-ignoring server
201    /// forces the one-time full download, `progress(collected, declared)` is
202    /// called once before the first byte (`collected == 0`) and after every
203    /// received chunk, with `declared` the HEAD-declared total. Ranged reads
204    /// against a range-honoring server never call it. The callback runs on
205    /// the async runtime, so it must not block.
206    pub fn on_fallback_progress(
207        mut self,
208        progress: impl Fn(u64, u64) + Send + Sync + 'static,
209    ) -> HttpBackend {
210        self.progress = Some(Arc::new(progress));
211        self
212    }
213
214    /// Collects a 200 response body (the whole resource) capped at the
215    /// declared length, so a hostile (or merely buggy) server whose body
216    /// is larger, or never finishes, cannot grow memory past `len` or
217    /// stall the read; a body that ends short of `len` is kept as-is and
218    /// later reads past it come back short.
219    async fn collect_full_body(&self, response: reqwest::Response) -> io::Result<Bytes> {
220        use futures_util::StreamExt;
221        let cap = usize::try_from(self.len).unwrap_or(usize::MAX);
222        let mut collected = Vec::new();
223        let mut chunks = response.bytes_stream();
224        if let Some(progress) = &self.progress {
225            progress(0, self.len);
226        }
227        while collected.len() < cap {
228            let chunk = match chunks.next().await {
229                Some(Ok(chunk)) => chunk,
230                Some(Err(err)) => {
231                    return Err(http_io_error(crate::error::TransportMarker {
232                        status: None,
233                        msg: format!("GET {}: {err}", self.url),
234                    }))
235                }
236                None => break, // body ended short of the declared length
237            };
238            let take = (cap - collected.len()).min(chunk.len());
239            collected.extend_from_slice(&chunk[..take]);
240            if let Some(progress) = &self.progress {
241                progress(collected.len() as u64, self.len);
242            }
243        }
244        Ok(Bytes::from(collected))
245    }
246}
247
248/// Wraps a transport marker into `io::Error` so it can cross the
249/// `io::Result` boundary of the [`Backend`] trait; recovered by
250/// `From<std::io::Error> for crate::Error`.
251#[cfg(feature = "http")]
252fn http_io_error(marker: crate::error::TransportMarker) -> io::Error {
253    io::Error::other(marker)
254}
255
256#[cfg(feature = "http")]
257impl Backend for HttpBackend {
258    fn len(&self) -> BoxFuture<'_, io::Result<u64>> {
259        let total = self.len;
260        Box::pin(async move { Ok(total) })
261    }
262
263    fn read_at<'a>(&'a self, offset: u64, buf: &'a mut [u8]) -> BoxFuture<'a, io::Result<usize>> {
264        Box::pin(async move {
265            if offset >= self.len || buf.is_empty() {
266                return Ok(0);
267            }
268            if let Some(body) = self.full.get() {
269                return Ok(read_from_bytes(body, offset, buf));
270            }
271            let last = (offset + buf.len() as u64 - 1).min(self.len - 1);
272            // Servers buckle intermittently under sustained ranged reads
273            // (observed live: a lone 500 mid-walk from a healthy host), and
274            // the lenient walks above this layer would silently skip what a
275            // transient failure hides. Retry 5xx answers twice with a short
276            // backoff before letting the failure surface.
277            let mut attempt = 0;
278            let response = loop {
279                let response = self
280                    .client
281                    .get(self.url.clone())
282                    .header(reqwest::header::RANGE, format!("bytes={offset}-{last}"))
283                    .send()
284                    .await
285                    .map_err(|err| {
286                        http_io_error(crate::error::TransportMarker {
287                            status: err.status().map(|status| status.as_u16()),
288                            msg: format!("GET {} range {offset}-{last}: {err}", self.url),
289                        })
290                    })?;
291                if !response.status().is_server_error() || attempt == 2 {
292                    break response;
293                }
294                attempt += 1;
295                tokio::time::sleep(std::time::Duration::from_millis(250 * (1 << attempt))).await;
296            };
297            match response.status().as_u16() {
298                206 => {}
299                200 => {
300                    // The server ignored the Range header and answered with
301                    // the whole resource: keep it and serve every read,
302                    // this one included, from memory. Losing the OnceLock
303                    // race to a concurrent read just drops a redundant
304                    // body, the same stance CachedBackend documents for
305                    // concurrent chunk misses.
306                    let collected = self.collect_full_body(response).await?;
307                    let body = self.full.get_or_init(|| collected);
308                    return Ok(read_from_bytes(body, offset, buf));
309                }
310                status => {
311                    return Err(http_io_error(crate::error::TransportMarker {
312                        status: Some(status),
313                        msg: format!("GET {} range {offset}-{last} failed", self.url),
314                    }));
315                }
316            }
317            // Collect at most `buf.len()` bytes of the body: a hostile (or
318            // merely buggy) server may answer a small Range with an
319            // arbitrarily large — or never-finishing — body, so the
320            // response is read chunk by chunk and collection stops the
321            // moment `buf` is full. The remaining body (and its
322            // connection) is simply dropped, never buffered; a body
323            // shorter than `buf` yields a short read (handled by the
324            // caller, `Fetcher::read_range`, exactly like any other
325            // short read).
326            use futures_util::StreamExt;
327            let mut chunks = response.bytes_stream();
328            let mut filled = 0usize;
329            while filled < buf.len() {
330                let chunk = match chunks.next().await {
331                    Some(Ok(chunk)) => chunk,
332                    Some(Err(err)) => {
333                        return Err(http_io_error(crate::error::TransportMarker {
334                            status: None,
335                            msg: format!("GET {} range {offset}-{last}: {err}", self.url),
336                        }))
337                    }
338                    None => break, // body ended short of the requested range
339                };
340                let take = (buf.len() - filled).min(chunk.len());
341                buf[filled..filled + take].copy_from_slice(&chunk[..take]);
342                filled += take;
343            }
344            Ok(filled)
345        })
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[tokio::test]
354    async fn mem_backend_reads_and_reports_length() {
355        let backend = MemBackend::from(b"hello world".to_vec());
356        assert_eq!(backend.len().await.unwrap(), 11);
357        let mut buf = [0u8; 5];
358        assert_eq!(backend.read_at(6, &mut buf).await.unwrap(), 5);
359        assert_eq!(&buf, b"world");
360    }
361
362    #[tokio::test]
363    async fn mem_backend_short_reads_only_at_eof() {
364        let backend = MemBackend::from(bytes::Bytes::from_static(b"abcdef"));
365        let mut buf = [0u8; 10];
366        assert_eq!(backend.read_at(4, &mut buf).await.unwrap(), 2);
367        assert_eq!(&buf[..2], b"ef");
368        assert_eq!(backend.read_at(6, &mut buf).await.unwrap(), 0);
369        assert_eq!(backend.read_at(999, &mut buf).await.unwrap(), 0);
370    }
371
372    #[tokio::test]
373    async fn backend_is_object_safe() {
374        let boxed: std::sync::Arc<dyn Backend> =
375            std::sync::Arc::new(MemBackend::from(b"xyz".to_vec()));
376        assert_eq!(boxed.len().await.unwrap(), 3);
377    }
378
379    #[tokio::test]
380    async fn file_backend_positioned_reads() {
381        let path = std::env::temp_dir().join(format!(
382            "pdfboss-aio-backend-test-{}.bin",
383            std::process::id()
384        ));
385        std::fs::write(&path, b"0123456789abcdef").unwrap();
386        let backend = FileBackend::open(&path).await.unwrap();
387        assert_eq!(backend.len().await.unwrap(), 16);
388        let mut buf = [0u8; 4];
389        assert_eq!(backend.read_at(10, &mut buf).await.unwrap(), 4);
390        assert_eq!(&buf, b"abcd");
391        // Reads are positioned, not cursor-based: an earlier offset after a
392        // later one must still return the right bytes.
393        assert_eq!(backend.read_at(0, &mut buf).await.unwrap(), 4);
394        assert_eq!(&buf, b"0123");
395        // Short read only at end of file.
396        let mut long = [0u8; 32];
397        assert_eq!(backend.read_at(12, &mut long).await.unwrap(), 4);
398        assert_eq!(&long[..4], b"cdef");
399        std::fs::remove_file(&path).ok();
400    }
401
402    #[tokio::test]
403    async fn file_backend_open_missing_file_errors() {
404        let missing = std::env::temp_dir().join("pdfboss-aio-backend-test-missing.bin");
405        assert!(FileBackend::open(&missing).await.is_err());
406    }
407
408    #[cfg(feature = "http")]
409    #[test]
410    fn transport_marker_round_trips_through_io_error() {
411        let failed = http_io_error(crate::error::TransportMarker {
412            status: Some(503),
413            msg: "unavailable".to_string(),
414        });
415        assert!(matches!(
416            crate::Error::from(failed),
417            crate::Error::Http {
418                status: Some(503),
419                ..
420            }
421        ));
422    }
423}