Skip to main content

mdcast_client/
artifact.rs

1//! What a successful render hands back: the bytes, plus the two things the
2//! response headers say about them.
3
4use bytes::Bytes;
5use reqwest::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap};
6
7/// A rendered document.
8///
9/// `filename` and `content_type` come from `Content-Disposition` and
10/// `Content-Type`. Both are always present on an `mdcast-server` response; the
11/// fallbacks exist for an intermediary that strips them, and are derived (the
12/// target's own extension, HTTP's default media type) rather than invented.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Artifact {
15    pub bytes: Bytes,
16    pub filename: String,
17    pub content_type: String,
18}
19
20/// HTTP's own default when a response carries no `Content-Type`.
21const 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
37/// Pull the filename out of `attachment; filename="output.pdf"`.
38///
39/// A response filename is data from the network, and callers will write it to
40/// disk — so anything with a path separator in it, or a bare `.`/`..`, is
41/// discarded in favour of the derived fallback rather than sanitised into
42/// something the caller did not ask for. RFC 5987's `filename*=` is not
43/// handled: mdcast-server always mints a plain ASCII `output.<ext>`.
44fn 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}