Skip to main content

tuwunel_core/utils/
content_disposition.rs

1use std::borrow::Cow;
2
3use ruma::http_headers::{ContentDisposition, ContentDispositionType};
4
5use crate::debug_info;
6
7/// as defined by MSC2702
8const ALLOWED_INLINE_CONTENT_TYPES: [&str; 26] = [
9	// keep sorted
10	"application/json",
11	"application/ld+json",
12	"audio/aac",
13	"audio/flac",
14	"audio/mp4",
15	"audio/mpeg",
16	"audio/ogg",
17	"audio/wav",
18	"audio/wave",
19	"audio/webm",
20	"audio/x-flac",
21	"audio/x-pn-wav",
22	"audio/x-wav",
23	"image/apng",
24	"image/avif",
25	"image/gif",
26	"image/jpeg",
27	"image/png",
28	"image/webp",
29	"text/css",
30	"text/csv",
31	"text/plain",
32	"video/mp4",
33	"video/ogg",
34	"video/quicktime",
35	"video/webm",
36];
37
38/// Returns a Content-Disposition of `attachment` or `inline`, depending on the
39/// Content-Type against MSC2702 list of safe inline Content-Types
40/// (`ALLOWED_INLINE_CONTENT_TYPES`)
41#[must_use]
42pub fn content_disposition_type(content_type: Option<&str>) -> ContentDispositionType {
43	let Some(content_type) = content_type else {
44		debug_info!("No Content-Type was given, assuming attachment for Content-Disposition");
45		return ContentDispositionType::Attachment;
46	};
47
48	debug_assert!(
49		ALLOWED_INLINE_CONTENT_TYPES.is_sorted(),
50		"ALLOWED_INLINE_CONTENT_TYPES is not sorted"
51	);
52
53	let content_type: Cow<'_, str> = content_type
54		.split(';')
55		.next()
56		.unwrap_or(content_type)
57		.to_ascii_lowercase()
58		.into();
59
60	if ALLOWED_INLINE_CONTENT_TYPES
61		.binary_search(&content_type.as_ref())
62		.is_ok()
63	{
64		ContentDispositionType::Inline
65	} else {
66		ContentDispositionType::Attachment
67	}
68}
69
70/// sanitises the file name for the Content-Disposition using
71/// `sanitize_filename` crate
72#[tracing::instrument(level = "debug")]
73pub fn sanitise_filename(filename: &str) -> String {
74	sanitize_filename::sanitize_with_options(filename, sanitize_filename::Options {
75		truncate: false,
76		..Default::default()
77	})
78}
79
80/// creates the final Content-Disposition based on whether the filename exists
81/// or not, or if a requested filename was specified (media download with
82/// filename)
83///
84/// if filename exists:
85/// `Content-Disposition: attachment/inline; filename=filename.ext`
86///
87/// else: `Content-Disposition: attachment/inline`
88pub fn make_content_disposition(
89	content_disposition: Option<&ContentDisposition>,
90	content_type: Option<&str>,
91	filename: Option<&str>,
92) -> ContentDisposition {
93	ContentDisposition::new(content_disposition_type(content_type)).with_filename(
94		filename
95			.or_else(|| {
96				content_disposition
97					.and_then(|content_disposition| content_disposition.filename.as_deref())
98			})
99			.map(sanitise_filename),
100	)
101}
102
103#[cfg(test)]
104mod tests {
105	#[test]
106	fn string_sanitisation() {
107		const SAMPLE: &str = "🏳️‍⚧️this\\r\\n įs \r\\n ä \\r\nstrïng 🥴that\n\r \
108		                      ../../../../../../../may be\r\n malicious🏳️‍⚧️";
109		const SANITISED: &str = "🏳️‍⚧️thisrn įs n ä rstrïng 🥴that ..............may be malicious🏳️‍⚧️";
110
111		let options = sanitize_filename::Options {
112			windows: true,
113			truncate: true,
114			replacement: "",
115		};
116
117		// cargo test -- --nocapture
118		println!("{SAMPLE}");
119		println!("{}", sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
120		println!("{SAMPLE:?}");
121		println!("{:?}", sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
122
123		assert_eq!(SANITISED, sanitize_filename::sanitize_with_options(SAMPLE, options.clone()));
124	}
125
126	#[test]
127	fn empty_sanitisation() {
128		use crate::utils::string::EMPTY;
129
130		let result =
131			sanitize_filename::sanitize_with_options(EMPTY, sanitize_filename::Options {
132				windows: true,
133				truncate: true,
134				replacement: "",
135			});
136
137		assert_eq!(EMPTY, result);
138	}
139}