Skip to main content

rama_http/service/web/endpoint/response/
octet_stream.rs

1use super::{Headers, IntoResponse};
2use crate::headers::ContentType;
3use crate::{Body, Response, StatusCode};
4use rama_core::bytes::Bytes;
5use rama_core::error::BoxError;
6use rama_core::futures::TryStream;
7use rama_core::stream::io::ReaderStream;
8use rama_http_headers::{ContentDisposition, ContentLength, ContentRange, Error, HeaderMapExt};
9use std::ops::RangeBounds;
10use std::path::Path;
11use tokio::fs::File;
12
13/// An octet-stream response for serving arbitrary binary data.
14///
15/// Will automatically get `Content-Type: application/octet-stream`.
16///
17/// This is commonly used for:
18/// - Serving unknown or arbitrary binary files
19/// - Downloadable content that doesn't fit other MIME types
20/// - Raw binary data streams
21///
22/// User Agents often treat it as if the `Content-Disposition` header was set to attachment,
23/// and propose a "Save As" dialog.
24///
25/// # Examples
26///
27/// ## Basic binary response
28///
29/// ```
30/// use rama_http::service::web::response::{IntoResponse, OctetStream};
31/// use rama_core::stream::io::ReaderStream;
32///
33/// async fn handler() -> impl IntoResponse {
34///     let data = b"Hello";
35///     let cursor = std::io::Cursor::new(data);
36///     let stream = ReaderStream::new(cursor);
37///     OctetStream::new(stream)
38/// }
39/// ```
40///
41/// ## Download with filename and size
42///
43/// ```
44/// use rama_http::service::web::response::{IntoResponse, OctetStream};
45/// use rama_core::stream::io::ReaderStream;
46///
47/// async fn download() -> impl IntoResponse {
48///     let data = b"file contents";
49///     let size = data.len() as u64;
50///     let cursor = std::io::Cursor::new(data);
51///     let stream = ReaderStream::new(cursor);
52///     OctetStream::new(stream)
53///         .with_file_name("data.bin".to_owned())
54///         .with_content_size(size)
55/// }
56/// ```
57///
58/// ## Partial content (range request)
59///
60/// ```
61/// use rama_http::service::web::response::OctetStream;
62/// use rama_core::stream::io::ReaderStream;
63///
64/// # fn example() -> Result<(), rama_http_headers::Error> {
65/// // Serving first 5 bytes of "Hello, World!" (13 bytes total)
66/// let partial_data = b"Hello";
67/// let cursor = std::io::Cursor::new(partial_data);
68/// let stream = ReaderStream::new(cursor);
69/// let response = OctetStream::new(stream)
70///     .with_content_size(13) // Total size of "Hello, World!"
71///     .try_into_range_response(0..5)?; // Serving bytes 0-4
72/// # Ok(())
73/// # }
74/// ```
75#[derive(Debug, Clone)]
76pub struct OctetStream<S> {
77    stream: S,
78    filename: Option<String>,
79    content_size: Option<u64>,
80}
81
82impl<S> OctetStream<S> {
83    /// Create a new `OctetStream` response.
84    pub fn new(stream: S) -> Self {
85        Self {
86            stream,
87            filename: None,
88            content_size: None,
89        }
90    }
91
92    rama_utils::macros::generate_set_and_with! {
93        /// Set the filename for `Content-Disposition: attachment` header.
94        ///
95        /// This will trigger a file download in browsers with the specified filename.
96        pub fn file_name(mut self, filename: String) -> Self {
97            self.filename = Some(filename);
98            self
99        }
100    }
101
102    rama_utils::macros::generate_set_and_with! {
103        /// Set the content size for `Content-Length`- or as part of the `Content-Range` header.
104        ///
105        /// This indicates the total size of the resource in bytes. When set, it will
106        /// be included as `Content-Length` header in normal responses, or used as the
107        /// complete length in `Content-Range` header for partial content responses.
108        pub fn content_size(mut self, content_size: u64) -> Self {
109            self.content_size = Some(content_size);
110            self
111        }
112    }
113
114    /// Convert into a partial content (HTTP 206) range response.
115    ///
116    /// This method consumes the `OctetStream` and converts it into a Response
117    /// with HTTP 206 status and appropriate `Content-Range` header.
118    /// The `content_size` field must be set before calling this method to indicate
119    /// the total size of the complete resource.
120    ///
121    /// # Arguments
122    ///
123    /// * `range` - The byte range being served (e.g., `0..500` for bytes 0-499)
124    ///
125    /// # Note
126    ///
127    /// It is the responsibility of the caller to ensure that the stream contains
128    /// the correct data matching the specified range. This method does not validate
129    /// the stream contents against the provided range by design, to avoid performance
130    /// overhead of reading the entire stream.
131    ///
132    /// # Returns
133    ///
134    /// Returns `Ok(Response)` with status 206 (Partial Content) if successful,
135    /// or `Err(Error)` if the range is invalid or `content_size` is not set.
136    ///
137    /// # Example
138    ///
139    /// ```
140    /// use rama_http::service::web::response::OctetStream;
141    /// use rama_core::stream::io::ReaderStream;
142    ///
143    /// # fn example() -> Result<(), rama_http_headers::Error> {
144    /// // Serving first 5 bytes of "Hello, World!" (13 bytes total)
145    /// let partial_data = b"Hello";
146    /// let cursor = std::io::Cursor::new(partial_data);
147    /// let stream = ReaderStream::new(cursor);
148    ///
149    /// let response = OctetStream::new(stream)
150    ///     .with_content_size(13) // Total size of "Hello, World!"
151    ///     .try_into_range_response(0..5)?; // Serving bytes 0-4
152    /// # Ok(())
153    /// # }
154    /// ```
155    pub fn try_into_range_response(self, range: impl RangeBounds<u64>) -> Result<Response, Error>
156    where
157        S: TryStream<Ok: Into<Bytes>, Error: Into<BoxError>> + Send + 'static,
158    {
159        let body = Body::from_stream(self.stream);
160        let content_range =
161            ContentRange::bytes(range, self.content_size).map_err(|_e| Error::invalid())?;
162
163        let mut response = (
164            StatusCode::PARTIAL_CONTENT,
165            Headers((ContentType::octet_stream(), content_range)),
166            body,
167        )
168            .into_response();
169
170        // Add Content-Disposition if filename is provided
171        if let Some(filename) = self.filename {
172            response
173                .headers_mut()
174                .typed_insert(ContentDisposition::attachment(filename.as_str()));
175        }
176
177        Ok(response)
178    }
179
180    /// Helper function to open file and extract metadata.
181    /// Returns (file, content_size, filename).
182    async fn open_file_with_metadata(
183        path: &Path,
184    ) -> std::io::Result<(File, Option<u64>, Option<String>)> {
185        // Reject path-traversal (`..`) and other unsafe paths: this is reachable
186        // with request-derived input, so guard it at the source.
187        let file = rama_utils::fs::safe_open(path).await?;
188
189        let metadata = file.metadata().await.ok();
190        let content_size = metadata.as_ref().map(|m| m.len());
191
192        let filename = path
193            .file_name()
194            .and_then(|name| name.to_str())
195            .map(|s| s.to_owned());
196
197        Ok((file, content_size, filename))
198    }
199
200    /// Create an `OctetStream` from a file path.
201    ///
202    /// This constructor opens the file and automatically extracts metadata when available:
203    /// - File size is set as `content_size` if the metadata can be read
204    /// - Filename is extracted from the path if it can be converted to a valid UTF-8 string
205    ///
206    /// Both operations are graceful - if metadata cannot be read or the filename cannot
207    /// be extracted, the corresponding field will be `None`.
208    ///
209    /// The file is opened with `rama_utils::fs::safe_open`, which rejects
210    /// path-traversal (`..`) and other unsafe paths.
211    ///
212    /// # Arguments
213    ///
214    /// * `path` - Path to the file to serve
215    ///
216    /// # Returns
217    ///
218    /// Returns `Ok(OctetStream)` if the file can be opened, or `Err(io::Error)` if the
219    /// file cannot be accessed.
220    ///
221    /// # Example
222    ///
223    /// ```no_run
224    /// use rama_http::service::web::response::OctetStream;
225    /// use rama_core::stream::io::ReaderStream;
226    /// use tokio::fs::File;
227    ///
228    /// # async fn example() -> std::io::Result<()> {
229    /// // Opens file and automatically sets filename and content_size
230    /// let response = OctetStream::<ReaderStream<File>>::try_from_path("/path/to/file.bin").await?;
231    /// # Ok(())
232    /// # }
233    /// ```
234    pub async fn try_from_path(
235        path: impl AsRef<Path>,
236    ) -> std::io::Result<OctetStream<ReaderStream<File>>> {
237        let (file, content_size, filename) = Self::open_file_with_metadata(path.as_ref()).await?;
238        let stream = ReaderStream::new(file);
239
240        Ok(OctetStream {
241            stream,
242            filename,
243            content_size,
244        })
245    }
246
247    /// Create a range response directly from a file path.
248    ///
249    /// This is a convenience method that combines file opening, seeking to the range start,
250    /// and creating a partial content (HTTP 206) response in one step. It automatically
251    /// extracts the filename from the path and uses the file size as the complete length.
252    ///
253    /// The file is opened with `rama_utils::fs::safe_open`, which rejects
254    /// path-traversal (`..`) and other unsafe paths.
255    ///
256    /// # Arguments
257    ///
258    /// * `path` - Path to the file to serve
259    /// * `start` - Start byte position (inclusive)
260    /// * `end` - End byte position (exclusive, following Rust range convention)
261    ///
262    /// # Returns
263    ///
264    /// Returns `Ok(Response)` with status 206 (Partial Content) if successful,
265    /// or `Err(io::Error)` if the file cannot be accessed, or if the range is invalid.
266    ///
267    /// # Example
268    ///
269    /// ```no_run
270    /// use rama_http::service::web::response::OctetStream;
271    /// use rama_core::stream::io::ReaderStream;
272    /// use tokio::fs::File;
273    ///
274    /// # async fn example() -> std::io::Result<()> {
275    /// // Serve bytes 0-499 of a file
276    /// let response = OctetStream::<ReaderStream<File>>::try_range_response_from_path("/path/to/file.bin", 0, 500).await?;
277    /// # Ok(())
278    /// # }
279    /// ```
280    pub async fn try_range_response_from_path(
281        path: impl AsRef<Path>,
282        start: u64,
283        end: u64,
284    ) -> std::io::Result<Response> {
285        use tokio::io::{AsyncReadExt, AsyncSeekExt};
286
287        let (mut file, content_size, filename) =
288            Self::open_file_with_metadata(path.as_ref()).await?;
289
290        // Take only the requested range
291        file.seek(std::io::SeekFrom::Start(start)).await?;
292        let stream = ReaderStream::new(file.take(end - start));
293
294        let octet_stream = OctetStream {
295            stream,
296            filename,
297            content_size,
298        };
299
300        octet_stream
301            .try_into_range_response(start..end)
302            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
303    }
304}
305
306impl<S> IntoResponse for OctetStream<S>
307where
308    S: TryStream<Ok: Into<Bytes>, Error: Into<BoxError>> + Send + 'static,
309{
310    fn into_response(self) -> Response {
311        let body = Body::from_stream(self.stream);
312        let mut response = (Headers::single(ContentType::octet_stream()), body).into_response();
313
314        // Add Content-Disposition if filename is provided
315        if let Some(filename) = self.filename {
316            response
317                .headers_mut()
318                .typed_insert(ContentDisposition::attachment(filename.as_str()));
319        }
320
321        // Add Content-Length if content_size is provided
322        if let Some(size) = self.content_size {
323            response.headers_mut().typed_insert(ContentLength(size));
324        }
325
326        response
327    }
328}
329
330impl<S> From<S> for OctetStream<S> {
331    fn from(stream: S) -> Self {
332        Self::new(stream)
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_new_octet_stream() {
342        let data = vec![1u8, 2, 3, 4, 5];
343        let stream = OctetStream::new(data.clone());
344
345        assert_eq!(stream.stream, data);
346        assert_eq!(stream.filename, None);
347        assert_eq!(stream.content_size, None);
348    }
349
350    #[test]
351    fn test_with_file_name() {
352        let data = vec![1u8, 2, 3];
353        let stream = OctetStream::new(data).with_file_name("test.bin".to_owned());
354
355        assert_eq!(stream.filename, Some("test.bin".to_owned()));
356    }
357
358    #[test]
359    fn test_with_content_size() {
360        let data = vec![1u8, 2, 3];
361        let stream = OctetStream::new(data).with_content_size(1024);
362
363        assert_eq!(stream.content_size, Some(1024));
364    }
365
366    #[test]
367    fn test_chained_setters() {
368        let data = vec![1u8, 2, 3];
369        let stream = OctetStream::new(data)
370            .with_file_name("test.bin".to_owned())
371            .with_content_size(1024);
372
373        assert_eq!(stream.filename, Some("test.bin".to_owned()));
374        assert_eq!(stream.content_size, Some(1024));
375    }
376
377    #[tokio::test]
378    async fn test_into_response() {
379        use crate::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE};
380
381        let cursor = std::io::Cursor::new(b"hello");
382        let stream = ReaderStream::new(cursor);
383        let octet_stream = OctetStream::new(stream)
384            .with_file_name("test.bin".to_owned())
385            .with_content_size(5);
386        let response = octet_stream.into_response();
387
388        assert_eq!(response.status(), StatusCode::OK);
389        assert_eq!(
390            response.headers().get(CONTENT_TYPE).unwrap(),
391            "application/octet-stream"
392        );
393        assert_eq!(response.headers().get(CONTENT_LENGTH).unwrap(), "5");
394        assert_eq!(
395            response.headers().get(CONTENT_DISPOSITION).unwrap(),
396            "attachment; filename=test.bin"
397        );
398    }
399
400    #[tokio::test]
401    async fn test_try_into_range_response() {
402        use crate::header::{CONTENT_DISPOSITION, CONTENT_RANGE, CONTENT_TYPE};
403
404        let cursor = std::io::Cursor::new(b"hello");
405        let stream = ReaderStream::new(cursor);
406        let octet_stream = OctetStream::new(stream)
407            .with_file_name("test.bin".to_owned())
408            .with_content_size(13);
409        let response = octet_stream.try_into_range_response(0..5).unwrap();
410
411        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
412        assert_eq!(
413            response.headers().get(CONTENT_TYPE).unwrap(),
414            "application/octet-stream"
415        );
416        assert_eq!(
417            response.headers().get(CONTENT_RANGE).unwrap(),
418            "bytes 0-4/13"
419        );
420        assert_eq!(
421            response.headers().get(CONTENT_DISPOSITION).unwrap(),
422            "attachment; filename=test.bin"
423        );
424    }
425
426    #[tokio::test]
427    async fn test_try_from_path() {
428        // Canonicalize so the path carries no `..` traversal component, which
429        // `safe_open` rejects by design.
430        let file_path = std::fs::canonicalize("../test-files/hello.txt").unwrap();
431        let stream = OctetStream::<ReaderStream<File>>::try_from_path(&file_path)
432            .await
433            .unwrap();
434
435        assert_eq!(stream.filename, Some("hello.txt".to_owned()));
436
437        #[cfg(target_os = "windows")]
438        assert_eq!(stream.content_size, Some(15)); // "Hello, World!\r\n" is 15 bytes
439        #[cfg(not(target_os = "windows"))]
440        assert_eq!(stream.content_size, Some(14)); // "Hello, World!\n" is 14 bytes
441    }
442
443    #[tokio::test]
444    async fn test_try_range_response_from_path() {
445        use crate::header::{CONTENT_DISPOSITION, CONTENT_RANGE};
446
447        let file_path = std::fs::canonicalize("../test-files/hello.txt").unwrap();
448        let response =
449            OctetStream::<ReaderStream<File>>::try_range_response_from_path(&file_path, 0, 5)
450                .await
451                .unwrap();
452
453        assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
454
455        // Verify Content-Range header
456        let value = response.headers().get(CONTENT_RANGE).unwrap();
457        #[cfg(target_os = "windows")]
458        assert_eq!(value, "bytes 0-4/15"); // "Hello, World!\r\n" is 15 bytes
459        #[cfg(not(target_os = "windows"))]
460        assert_eq!(value, "bytes 0-4/14"); // "Hello, World!\n" is 14 bytes
461
462        // Verify Content-Disposition header with filename
463        assert_eq!(
464            response.headers().get(CONTENT_DISPOSITION).unwrap(),
465            "attachment; filename=hello.txt"
466        );
467    }
468}