tuwunel_core/utils/
content_disposition.rs1use ruma::http_headers::{ContentDisposition, ContentDispositionType};
7
8use crate::debug_info;
9
10const ALLOWED_INLINE_CONTENT_TYPES: [&str; 26] = [
12 "application/json",
14 "application/ld+json",
15 "audio/aac",
16 "audio/flac",
17 "audio/mp4",
18 "audio/mpeg",
19 "audio/ogg",
20 "audio/wav",
21 "audio/wave",
22 "audio/webm",
23 "audio/x-flac",
24 "audio/x-pn-wav",
25 "audio/x-wav",
26 "image/apng",
27 "image/avif",
28 "image/gif",
29 "image/jpeg",
30 "image/png",
31 "image/webp",
32 "text/css",
33 "text/csv",
34 "text/plain",
35 "video/mp4",
36 "video/ogg",
37 "video/quicktime",
38 "video/webm",
39];
40
41#[must_use]
45pub fn content_disposition_type(content_type: Option<&str>) -> ContentDispositionType {
46 let Some(content_type) = content_type else {
47 debug_info!("No Content-Type was given, assuming attachment for Content-Disposition");
48 return ContentDispositionType::Attachment;
49 };
50
51 debug_assert!(
52 ALLOWED_INLINE_CONTENT_TYPES.is_sorted(),
53 "ALLOWED_INLINE_CONTENT_TYPES is not sorted"
54 );
55
56 let essence = content_type_essence(content_type);
57
58 let allowed = ALLOWED_INLINE_CONTENT_TYPES
61 .binary_search_by(|allowed| {
62 allowed.bytes().cmp(
63 essence
64 .bytes()
65 .map(|byte| byte.to_ascii_lowercase()),
66 )
67 })
68 .is_ok();
69
70 if allowed {
71 ContentDispositionType::Inline
72 } else {
73 ContentDispositionType::Attachment
74 }
75}
76
77#[inline]
82#[must_use]
83pub fn content_type_is(content_type: Option<&str>, essence: &str) -> bool {
84 content_type.is_some_and(|content_type| {
85 content_type_essence(content_type).eq_ignore_ascii_case(essence)
86 })
87}
88
89#[inline]
95#[must_use]
96pub fn content_type_essence(content_type: &str) -> &str {
97 content_type
98 .split(';')
99 .next()
100 .unwrap_or(content_type)
101 .trim()
102}
103
104#[tracing::instrument(level = "debug")]
107pub fn sanitise_filename(filename: &str) -> String {
108 sanitize_filename::sanitize_with_options(filename, sanitize_filename::Options {
109 truncate: false,
110 ..Default::default()
111 })
112}
113
114pub fn make_content_disposition(
123 content_disposition: Option<&ContentDisposition>,
124 content_type: Option<&str>,
125 filename: Option<&str>,
126) -> ContentDisposition {
127 ContentDisposition::new(content_disposition_type(content_type)).with_filename(
128 filename
129 .or_else(|| {
130 content_disposition
131 .and_then(|content_disposition| content_disposition.filename.as_deref())
132 })
133 .map(sanitise_filename),
134 )
135}
136
137#[cfg(test)]
138mod tests {
139 #[test]
140 fn string_sanitisation() {
141 const SAMPLE: &str = "🏳️⚧️this\\r\\n įs \r\\n ä \\r\nstrïng 🥴that\n\r \
142 ../../../../../../../may be\r\n malicious🏳️⚧️";
143 const SANITISED: &str = "🏳️⚧️thisrn įs n ä rstrïng 🥴that ..............may be malicious🏳️⚧️";
144
145 let options = sanitize_filename::Options {
146 windows: true,
147 truncate: true,
148 replacement: "",
149 };
150
151 println!("{SAMPLE}");
153 println!("{}", sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
154 println!("{SAMPLE:?}");
155 println!("{:?}", sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
156
157 assert_eq!(SANITISED, sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
158 }
159
160 #[test]
161 fn empty_sanitisation() {
162 use crate::utils::string::EMPTY;
163
164 let result =
165 sanitize_filename::sanitize_with_options(EMPTY, sanitize_filename::Options {
166 windows: true,
167 truncate: true,
168 replacement: "",
169 });
170
171 assert_eq!(EMPTY, result);
172 }
173
174 #[test]
175 fn content_type_essence_drops_parameters() {
176 use super::content_type_essence;
177
178 assert_eq!(content_type_essence("text/html; charset=utf-8"), "text/html");
179 assert_eq!(content_type_essence(" text/html "), "text/html");
180 assert_eq!(content_type_essence("text/html"), "text/html");
181 }
182
183 #[test]
184 fn content_type_is_matches_any_case() {
185 use super::content_type_is;
186
187 for content_type in ["text/html", "Text/HTML", "TEXT/HTML", "text/HTML; charset=utf-8"] {
188 assert!(content_type_is(Some(content_type), "text/html"), "{content_type} is html");
189 }
190 }
191
192 #[test]
193 fn content_type_is_rejects_other_types() {
194 use super::content_type_is;
195
196 for content_type in
197 ["application/json; x=text/html", "text/plain", "application/xhtml+xml"]
198 {
199 assert!(
200 !content_type_is(Some(content_type), "text/html"),
201 "{content_type} is not html"
202 );
203 }
204
205 assert!(!content_type_is(None, "text/html"), "an absent content type is not html");
206 }
207
208 #[test]
209 fn inline_disposition_ignores_case_and_parameters() {
210 use ruma::http_headers::ContentDispositionType;
211
212 use super::content_disposition_type;
213
214 for content_type in
215 ["image/png", "IMAGE/PNG", "Image/Png; charset=binary", " text/plain "]
216 {
217 assert!(
218 matches!(
219 content_disposition_type(Some(content_type)),
220 ContentDispositionType::Inline
221 ),
222 "{content_type} is safe to inline"
223 );
224 }
225 }
226
227 #[test]
228 fn everything_else_is_an_attachment() {
229 use ruma::http_headers::ContentDispositionType;
230
231 use super::content_disposition_type;
232
233 for content_type in [Some("text/html"), Some("application/octet-stream"), Some(""), None]
234 {
235 assert!(
236 matches!(
237 content_disposition_type(content_type),
238 ContentDispositionType::Attachment
239 ),
240 "{content_type:?} is not safe to inline"
241 );
242 }
243 }
244}