1pub fn pct_encode(input: &str) -> String {
5 let mut out = String::with_capacity(input.len());
6 for b in input.bytes() {
7 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
8 out.push(b as char);
9 } else {
10 out.push_str(&format!("%{b:02X}"));
11 }
12 }
13 out
14}
15
16pub fn sanitize_filename(raw: &str) -> String {
18 let base = raw.rsplit(['/', '\\']).next().unwrap_or(raw);
19 let cleaned: String = base
20 .chars()
21 .filter(|c| !c.is_control())
22 .collect::<String>()
23 .trim()
24 .trim_start_matches('.')
25 .chars()
26 .take(200)
27 .collect();
28 if cleaned.is_empty() {
29 "file".to_string()
30 } else {
31 cleaned
32 }
33}
34
35pub fn content_disposition(filename: &str) -> String {
38 let fallback: String = filename
39 .chars()
40 .map(|c| {
41 if c.is_ascii() && !c.is_ascii_control() && c != '"' && c != '\\' {
42 c
43 } else {
44 '_'
45 }
46 })
47 .collect();
48 format!(
49 "attachment; filename=\"{fallback}\"; filename*=UTF-8''{}",
50 pct_encode(filename)
51 )
52}
53
54pub fn csv_field(value: &str) -> String {
57 let value = if value.starts_with(['=', '+', '-', '@']) {
58 format!("'{value}")
59 } else {
60 value.to_string()
61 };
62 if value.contains([',', '"', '\n', '\r', ';']) {
63 format!("\"{}\"", value.replace('"', "\"\""))
64 } else {
65 value
66 }
67}
68
69pub fn format_number(value: f64, decimals: usize) -> String {
71 let s = format!("{value:.decimals$}");
72 if s.contains('.') {
73 s.trim_end_matches('0').trim_end_matches('.').to_string()
74 } else {
75 s
76 }
77}
78
79pub fn format_bytes(bytes: i64) -> String {
81 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
82 let mut value = bytes.max(0) as f64;
83 let mut unit = 0;
84 while value >= 1024.0 && unit < UNITS.len() - 1 {
85 value /= 1024.0;
86 unit += 1;
87 }
88 if unit == 0 {
89 format!("{bytes} B")
90 } else {
91 format!("{} {}", format_number(value, 1), UNITS[unit])
92 }
93}
94
95pub fn safe_redirect_target(next: Option<&str>) -> String {
98 match next {
99 Some(n) if n.starts_with('/') && !n.starts_with("//") && !n.contains('\\') => n.to_string(),
100 _ => "/".to_string(),
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn percent_encoding() {
110 assert_eq!(pct_encode("abc-._~"), "abc-._~");
111 assert_eq!(pct_encode("a b/ä"), "a%20b%2F%C3%A4");
112 }
113
114 #[test]
115 fn filenames() {
116 assert_eq!(
117 sanitize_filename("C:\\Users\\x\\drawing.pdf"),
118 "drawing.pdf"
119 );
120 assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
121 assert_eq!(sanitize_filename(" .hidden "), "hidden");
122 assert_eq!(sanitize_filename("a\u{0}b.txt"), "ab.txt");
123 assert_eq!(sanitize_filename(""), "file");
124 assert_eq!(
125 content_disposition("Düse \"v2\".step"),
126 "attachment; filename=\"D_se _v2_.step\"; filename*=UTF-8''D%C3%BCse%20%22v2%22.step"
127 );
128 }
129
130 #[test]
131 fn csv_escaping() {
132 assert_eq!(csv_field("plain"), "plain");
133 assert_eq!(csv_field("a,b"), "\"a,b\"");
134 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
135 assert_eq!(csv_field("=SUM(A1)"), "'=SUM(A1)");
136 }
137
138 #[test]
139 fn number_formatting() {
140 assert_eq!(format_number(1.5, 3), "1.5");
141 assert_eq!(format_number(2.0, 3), "2");
142 assert_eq!(format_number(0.12345, 3), "0.123");
143 assert_eq!(format_bytes(512), "512 B");
144 assert_eq!(format_bytes(1536), "1.5 KiB");
145 assert_eq!(format_bytes(5 * 1024 * 1024), "5 MiB");
146 }
147
148 #[test]
152 fn byte_sizes_stop_at_tib() {
153 const TIB: i64 = 1 << 40;
154 assert_eq!(format_bytes(TIB), "1 TiB");
155 assert_eq!(format_bytes(2048 * TIB), "2048 TiB");
156 assert_eq!(format_bytes(i64::MAX), "8388608 TiB");
157 assert_eq!(format_bytes(-5), "-5 B", "negative sizes are not scaled");
158 }
159
160 #[test]
161 fn redirect_targets() {
162 assert_eq!(safe_redirect_target(Some("/items?q=1")), "/items?q=1");
163 assert_eq!(safe_redirect_target(Some("//evil.example")), "/");
164 assert_eq!(safe_redirect_target(Some("https://evil.example")), "/");
165 assert_eq!(safe_redirect_target(Some("/\\evil.example")), "/");
166 assert_eq!(safe_redirect_target(None), "/");
167 }
168}