Skip to main content

teamcraft/
util.rs

1//! Small string helpers without external dependencies.
2
3/// Percent-encodes everything except RFC 3986 unreserved characters.
4pub 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
16/// Reduces an uploaded file name to a safe base name.
17pub 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
35/// `Content-Disposition` value that forces a download and keeps non-ASCII
36/// file names intact (RFC 6266 / RFC 5987).
37pub 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
54/// Escapes a CSV field (RFC 4180). Fields starting with characters that
55/// spreadsheet programs interpret as formulas are prefixed with `'`.
56pub 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
69/// Formats a number with up to `decimals` decimals, trimming trailing zeros.
70pub 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
79/// Human readable byte size (binary units).
80pub 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
95/// Only accept local, absolute paths as redirect targets (prevents open
96/// redirects via `?next=https://evil.example`).
97pub 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    /// TiB is the largest unit: bigger sizes stay in TiB instead of running
149    /// off the end of the unit table (found by cargo-mutants: a loop bound
150    /// of `<=` or `len() + 1` went unnoticed, and would have panicked).
151    #[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}