Skip to main content

webx_core/form/
spool.rs

1//! Upload spool directory management and client filename sanitisation.
2
3use std::path::PathBuf;
4use std::sync::RwLock;
5
6/// Prefix given to every temporary file the framework writes for an upload.
7pub const SPOOL_FILE_PREFIX: &str = "webx-upload-";
8
9/// Longest filename [`sanitize_file_name`] will produce, in bytes.
10const MAX_FILE_NAME_LEN: usize = 200;
11
12static SPOOL_ROOT: RwLock<Option<PathBuf>> = RwLock::new(None);
13
14/// Point spooling at `path`.
15///
16/// The host calls this from `Host::build()` with `Form:TempDir`. When unset,
17/// spooled uploads go to `webx-uploads` inside the system temporary directory.
18pub fn set_spool_root(path: impl Into<PathBuf>) {
19    let mut guard = SPOOL_ROOT
20        .write()
21        .unwrap_or_else(|poisoned| poisoned.into_inner());
22    *guard = Some(path.into());
23}
24
25/// The directory used for spooled uploads.
26pub fn spool_root() -> PathBuf {
27    let guard = SPOOL_ROOT
28        .read()
29        .unwrap_or_else(|poisoned| poisoned.into_inner());
30    if let Some(root) = guard.as_ref() {
31        return root.clone();
32    }
33    std::env::temp_dir().join("webx-uploads")
34}
35
36/// Reduce a client-supplied filename to a safe basename.
37///
38/// The result never contains a path separator, never contains control
39/// characters, and is never `.`/`..`, so it can be joined onto a directory
40/// without escaping it. Windows-reserved characters are replaced with `_`.
41///
42/// Returns `"file"` when nothing usable is left.
43pub fn sanitize_file_name(raw: &str) -> String {
44    // Keep only the last path component, whichever separator was used.
45    let base = raw.rsplit(['/', '\\']).next().unwrap_or("");
46
47    let mut cleaned = String::with_capacity(base.len());
48    for ch in base.chars() {
49        if ch.is_control() {
50            continue;
51        }
52        match ch {
53            '<' | '>' | ':' | '"' | '|' | '?' | '*' => cleaned.push('_'),
54            _ => cleaned.push(ch),
55        }
56    }
57
58    // Windows rejects trailing dots and spaces; "." and ".." are traversal.
59    let trimmed = cleaned.trim().trim_end_matches(['.', ' ']);
60    let candidate = if trimmed.is_empty() { "file" } else { trimmed };
61
62    truncate_preserving_extension(candidate, MAX_FILE_NAME_LEN)
63}
64
65fn truncate_preserving_extension(name: &str, max: usize) -> String {
66    if name.len() <= max {
67        return name.to_string();
68    }
69    // Preserve a short extension so `SaveAs` still picks a sensible type.
70    match name.rfind('.') {
71        Some(idx) if idx > 0 && name.len() - idx <= 16 => {
72            let ext = &name[idx..];
73            let stem = truncate_chars(&name[..idx], max.saturating_sub(ext.len()));
74            format!("{stem}{ext}")
75        }
76        _ => truncate_chars(name, max),
77    }
78}
79
80fn truncate_chars(s: &str, max: usize) -> String {
81    if s.len() <= max {
82        return s.to_string();
83    }
84    let mut end = max;
85    while end > 0 && !s.is_char_boundary(end) {
86        end -= 1;
87    }
88    s[..end].to_string()
89}