a3s_boot/http/
streamable_file.rs1use crate::{BootError, Result};
2use futures_core::Stream;
3use std::fmt;
4use std::pin::Pin;
5
6pub type StreamableFileStream = Pin<Box<dyn Stream<Item = Result<Vec<u8>>> + Send + 'static>>;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct StreamableFileOptions {
11 content_type: Option<String>,
12 content_disposition: Option<String>,
13 content_length: Option<u64>,
14}
15
16impl StreamableFileOptions {
17 pub fn new() -> Self {
18 Self::default()
19 }
20
21 pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
22 self.content_type = Some(content_type.into());
23 self
24 }
25
26 pub fn with_content_disposition(mut self, content_disposition: impl Into<String>) -> Self {
27 self.content_disposition = Some(content_disposition.into());
28 self
29 }
30
31 pub fn with_attachment(self, file_name: impl AsRef<str>) -> Result<Self> {
32 Ok(self.with_content_disposition(content_disposition("attachment", file_name.as_ref())?))
33 }
34
35 pub fn with_inline(self, file_name: impl AsRef<str>) -> Result<Self> {
36 Ok(self.with_content_disposition(content_disposition("inline", file_name.as_ref())?))
37 }
38
39 pub fn with_content_length(mut self, content_length: u64) -> Self {
40 self.content_length = Some(content_length);
41 self
42 }
43
44 pub fn content_type(&self) -> Option<&str> {
45 self.content_type.as_deref()
46 }
47
48 pub fn content_disposition(&self) -> Option<&str> {
49 self.content_disposition.as_deref()
50 }
51
52 pub fn content_length(&self) -> Option<u64> {
53 self.content_length
54 }
55}
56
57pub struct StreamableFile {
59 body: StreamableFileBody,
60 options: StreamableFileOptions,
61}
62
63impl StreamableFile {
64 pub fn bytes(body: impl Into<Vec<u8>>) -> Self {
65 Self {
66 body: StreamableFileBody::Bytes(body.into()),
67 options: StreamableFileOptions::default(),
68 }
69 }
70
71 pub fn stream<S>(stream: S) -> Self
72 where
73 S: Stream<Item = Result<Vec<u8>>> + Send + 'static,
74 {
75 Self {
76 body: StreamableFileBody::Stream(Box::pin(stream)),
77 options: StreamableFileOptions::default(),
78 }
79 }
80
81 pub fn with_options(mut self, options: StreamableFileOptions) -> Self {
82 self.options = options;
83 self
84 }
85
86 pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
87 self.options = self.options.with_content_type(content_type);
88 self
89 }
90
91 pub fn with_content_disposition(mut self, content_disposition: impl Into<String>) -> Self {
92 self.options = self.options.with_content_disposition(content_disposition);
93 self
94 }
95
96 pub fn with_attachment(mut self, file_name: impl AsRef<str>) -> Result<Self> {
97 self.options = self.options.with_attachment(file_name)?;
98 Ok(self)
99 }
100
101 pub fn with_inline(mut self, file_name: impl AsRef<str>) -> Result<Self> {
102 self.options = self.options.with_inline(file_name)?;
103 Ok(self)
104 }
105
106 pub fn with_content_length(mut self, content_length: u64) -> Self {
107 self.options = self.options.with_content_length(content_length);
108 self
109 }
110
111 pub fn options(&self) -> &StreamableFileOptions {
112 &self.options
113 }
114
115 pub(crate) fn into_parts(self) -> (StreamableFileBody, StreamableFileOptions) {
116 (self.body, self.options)
117 }
118}
119
120impl fmt::Debug for StreamableFile {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 let body = match &self.body {
123 StreamableFileBody::Bytes(bytes) => {
124 return f
125 .debug_struct("StreamableFile")
126 .field("body", &format_args!("{} bytes", bytes.len()))
127 .field("options", &self.options)
128 .finish();
129 }
130 StreamableFileBody::Stream(_) => "stream",
131 };
132
133 f.debug_struct("StreamableFile")
134 .field("body", &body)
135 .field("options", &self.options)
136 .finish()
137 }
138}
139
140pub(crate) enum StreamableFileBody {
141 Bytes(Vec<u8>),
142 Stream(StreamableFileStream),
143}
144
145fn content_disposition(disposition_type: &str, file_name: &str) -> Result<String> {
146 validate_file_name(file_name)?;
147 let quoted = quote_file_name(&ascii_file_name_fallback(file_name));
148 let mut value = format!("{disposition_type}; filename=\"{quoted}\"");
149 if !file_name.is_ascii() || quoted != file_name {
150 value.push_str("; filename*=UTF-8''");
151 value.push_str(&percent_encode_attr(file_name));
152 }
153 Ok(value)
154}
155
156fn validate_file_name(file_name: &str) -> Result<()> {
157 if file_name.is_empty() {
158 return Err(BootError::Internal("file name cannot be empty".to_string()));
159 }
160
161 if file_name
162 .bytes()
163 .any(|byte| byte == b'\r' || byte == b'\n' || byte == 0)
164 {
165 return Err(BootError::Internal(
166 "file name contains invalid characters".to_string(),
167 ));
168 }
169
170 Ok(())
171}
172
173fn ascii_file_name_fallback(file_name: &str) -> String {
174 let value = file_name
175 .chars()
176 .map(|character| {
177 if character.is_ascii()
178 && !character.is_ascii_control()
179 && !matches!(character, '"' | '\\' | '/' | ';')
180 {
181 character
182 } else {
183 '_'
184 }
185 })
186 .collect::<String>()
187 .trim_matches(|character: char| character == '_' || character.is_ascii_whitespace())
188 .to_string();
189
190 if value.is_empty() {
191 "download".to_string()
192 } else {
193 value
194 }
195}
196
197fn quote_file_name(file_name: &str) -> String {
198 file_name
199 .replace('\\', "\\\\")
200 .replace('"', "\\\"")
201 .to_string()
202}
203
204fn percent_encode_attr(value: &str) -> String {
205 let mut encoded = String::new();
206 for byte in value.bytes() {
207 if is_attr_char(byte) {
208 encoded.push(char::from(byte));
209 } else {
210 encoded.push_str(&format!("%{byte:02X}"));
211 }
212 }
213 encoded
214}
215
216fn is_attr_char(byte: u8) -> bool {
217 matches!(
218 byte,
219 b'A'..=b'Z'
220 | b'a'..=b'z'
221 | b'0'..=b'9'
222 | b'!'
223 | b'#'
224 | b'$'
225 | b'&'
226 | b'+'
227 | b'-'
228 | b'.'
229 | b'^'
230 | b'_'
231 | b'`'
232 | b'|'
233 | b'~'
234 )
235}