mdcast_client/
artifact.rs1use bytes::Bytes;
5use reqwest::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Artifact {
15 pub bytes: Bytes,
16 pub filename: String,
17 pub content_type: String,
18}
19
20const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
22
23pub(crate) fn from_response(headers: &HeaderMap, bytes: Bytes, fallback_name: &str) -> Artifact {
24 let header = |name: &_| headers.get(name).and_then(|v| v.to_str().ok());
25
26 Artifact {
27 bytes,
28 filename: header(&CONTENT_DISPOSITION)
29 .and_then(filename_from)
30 .unwrap_or_else(|| fallback_name.to_string()),
31 content_type: header(&CONTENT_TYPE)
32 .unwrap_or(DEFAULT_CONTENT_TYPE)
33 .to_string(),
34 }
35}
36
37fn filename_from(header: &str) -> Option<String> {
45 let (_, rest) = header.split_once("filename=")?;
46 let rest = rest.trim_start();
47 let name = match rest.strip_prefix('"') {
48 Some(quoted) => quoted.split('"').next().unwrap_or(quoted),
49 None => rest.split(';').next().unwrap_or(rest),
50 };
51
52 let name = name.trim();
53 let usable = !name.is_empty()
54 && name != "."
55 && name != ".."
56 && !name.contains('/')
57 && !name.contains('\\');
58 usable.then(|| name.to_string())
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64 use reqwest::header::HeaderValue;
65
66 fn artifact(pairs: &[(&'static str, &str)]) -> Artifact {
67 let mut headers = HeaderMap::new();
68 for (name, value) in pairs {
69 let value = HeaderValue::from_str(value).expect("test header is valid ASCII");
70 headers.insert(*name, value);
71 }
72 from_response(&headers, Bytes::from_static(b"%PDF"), "output.pdf")
73 }
74
75 #[test]
76 fn takes_the_filename_and_content_type_from_the_headers() {
77 let artifact = artifact(&[
78 ("content-type", "application/pdf"),
79 ("content-disposition", "attachment; filename=\"deck.pdf\""),
80 ]);
81
82 assert_eq!(artifact.filename, "deck.pdf");
83 assert_eq!(artifact.content_type, "application/pdf");
84 assert_eq!(&artifact.bytes[..], b"%PDF");
85 }
86
87 #[test]
88 fn accepts_an_unquoted_filename() {
89 assert_eq!(
90 artifact(&[("content-disposition", "attachment; filename=deck.pdf")]).filename,
91 "deck.pdf"
92 );
93 assert_eq!(
94 artifact(&[(
95 "content-disposition",
96 "attachment; filename=deck.pdf; charset=utf-8"
97 )])
98 .filename,
99 "deck.pdf"
100 );
101 }
102
103 #[test]
104 fn falls_back_when_the_headers_say_nothing() {
105 let artifact = artifact(&[]);
106
107 assert_eq!(artifact.filename, "output.pdf");
108 assert_eq!(artifact.content_type, DEFAULT_CONTENT_TYPE);
109 }
110
111 #[test]
112 fn a_filename_with_a_path_in_it_is_discarded() {
113 for header in [
114 "attachment; filename=\"../../etc/passwd\"",
115 "attachment; filename=/etc/passwd",
116 "attachment; filename=\"..\\\\windows\\\\system32\"",
117 "attachment; filename=\"..\"",
118 "attachment; filename=\"\"",
119 "attachment",
120 ] {
121 assert_eq!(
122 artifact(&[("content-disposition", header)]).filename,
123 "output.pdf",
124 "accepted a filename from {header:?}"
125 );
126 }
127 }
128}