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}