xmtp_attachments/
sanitize.rs1pub fn local_file_name(filename: Option<&str>) -> String {
3 filename.map_or_else(|| "attachment".to_owned(), sanitize_path_component)
4}
5
6pub fn sanitize_path_component(value: &str) -> String {
8 sanitize_path_component_with_limit(value, 255)
9}
10
11pub 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
82fn 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 #[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 #[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}