Skip to main content

xmtp_attachments/
sanitize.rs

1/// Apply the file name table to an optional remote name.
2pub fn local_file_name(filename: Option<&str>) -> String {
3    filename.map_or_else(|| "attachment".to_owned(), sanitize_path_component)
4}
5
6/// Apply steps 2 to 8 of the file name table to a path component.
7pub fn sanitize_path_component(value: &str) -> String {
8    sanitize_path_component_with_limit(value, 255)
9}
10
11/// Apply the file name table with a caller-selected UTF-8 byte limit.
12pub fn sanitize_path_component_with_limit(value: &str, limit: usize) -> String {
13    let part = value.rsplit(['/', '\\']).next().unwrap_or_default();
14    let clean_ascii = part.bytes().all(|byte| {
15        matches!(byte, 0x20..=0x7e)
16            && !matches!(byte, b'<' | b'>' | b':' | b'"' | b'|' | b'?' | b'*')
17    });
18    let mut name: String = if clean_ascii {
19        part.to_owned()
20    } else {
21        part.chars()
22            .filter(|&c| {
23                !matches!(c, '\u{0000}'..='\u{001f}' | '\u{007f}'..='\u{009f}'
24                    | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
25                    | '<' | '>' | ':' | '"' | '|' | '?' | '*')
26            })
27            .collect()
28    };
29    if name.starts_with(['.', ' ']) || name.ends_with(['.', ' ']) {
30        name = name.trim_matches(['.', ' ']).to_owned();
31    }
32    if is_reserved_device_name(&name) {
33        name.insert(0, '_');
34    }
35    if name.len() > limit {
36        name = match name.rfind('.') {
37            Some(dot) if dot > 0 => {
38                let suffix = &name[dot..];
39                if suffix.len() >= limit {
40                    truncate_bytes(suffix, limit).to_owned()
41                } else {
42                    let stem = truncate_bytes(&name[..dot], limit - suffix.len());
43                    format!("{stem}{suffix}")
44                }
45            }
46            _ => truncate_bytes(&name, limit).to_owned(),
47        };
48    }
49    let name = name.trim_matches(['.', ' ']);
50    if name.is_empty() {
51        "attachment".to_owned()
52    } else {
53        name.to_owned()
54    }
55}
56
57pub(crate) fn is_reserved_device_name(name: &str) -> bool {
58    let stem = if name.is_ascii() {
59        name[..name.len().min(8)]
60            .split('.')
61            .next()
62            .unwrap_or_default()
63    } else {
64        name.split('.').next().unwrap_or_default()
65    };
66    if stem.len() <= 7 {
67        let upper = stem.to_ascii_uppercase();
68        matches!(
69            upper.as_str(),
70            "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
71        ) || (upper.starts_with("COM") || upper.starts_with("LPT"))
72            && upper
73                .chars()
74                .nth(3)
75                .is_some_and(|c| matches!(c, '1'..='9' | '¹' | '²' | '³'))
76            && upper.chars().count() == 4
77    } else {
78        false
79    }
80}
81
82/// Keep the longest UTF-8 prefix within a byte limit.
83fn truncate_bytes(value: &str, limit: usize) -> &str {
84    let mut end = value.len().min(limit);
85    while !value.is_char_boundary(end) {
86        end -= 1;
87    }
88    &value[..end]
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    // verifies: ATCH-042
96    #[xmtp_common::test(unwrap_try = true)]
97    async fn file_name_table() {
98        assert_eq!(local_file_name(None), "attachment");
99        let cases = [
100            ("a/b\\c.txt", "c.txt"),
101            (
102                "a\u{0000}b\u{007f}c\u{009f}d\u{202e}e\u{2066}f.txt",
103                "abcdef.txt",
104            ),
105            (" <bad>:name|?*.txt ", "badname.txt"),
106            (" ..hello.. ", "hello"),
107            ("..", "attachment"),
108            ("\\", "attachment"),
109            ("CON.txt", "_CON.txt"),
110            ("conin$.txt", "_conin$.txt"),
111            ("CONOUT$.txt", "_CONOUT$.txt"),
112            ("conout$", "_conout$"),
113            ("CONOUT$X.txt", "CONOUT$X.txt"),
114            ("CONOUT$1", "CONOUT$1"),
115            ("COM¹.txt", "_COM¹.txt"),
116            ("lpt³", "_lpt³"),
117            ("COM0.txt", "COM0.txt"),
118            ("normal.txt", "normal.txt"),
119        ];
120        for (input, expected) in cases {
121            assert_eq!(local_file_name(Some(input)), expected, "{input:?}");
122        }
123        let long = format!("{}.pdf", "é".repeat(130));
124        let actual = local_file_name(Some(&long));
125        assert_eq!(actual.len(), 254);
126        assert!(actual.ends_with(".pdf"));
127        assert_eq!(actual.chars().filter(|&c| c == 'é').count(), 125);
128
129        let no_dot = "a".repeat(256);
130        assert_eq!(local_file_name(Some(&no_dot)), "a".repeat(255));
131        let after_truncation = format!("{} bb", "a".repeat(254));
132        assert_eq!(local_file_name(Some(&after_truncation)), "a".repeat(254));
133        let long_extension = format!("x.{}", "a".repeat(300));
134        assert_eq!(local_file_name(Some(&long_extension)), "a".repeat(254));
135    }
136
137    // verifies: ATCH-042
138    #[xmtp_common::test(unwrap_try = true)]
139    async fn huge_file_name_linear() {
140        let no_dot = "a".repeat(4 * 1024 * 1024);
141        assert_eq!(local_file_name(Some(&no_dot)), "a".repeat(255));
142
143        let long_extension = format!("x.{}", "a".repeat(4 * 1024 * 1024));
144        assert_eq!(local_file_name(Some(&long_extension)), "a".repeat(254));
145    }
146}