Skip to main content

aster_forge_validation/
filename.rs

1//! File and folder name validation helpers.
2//!
3//! This module validates names against cross-platform filesystem constraints, normalizes Unicode to
4//! NFC, and generates collision-copy names that respect byte limits. It also includes blob-key
5//! sharding helpers used when mapping logical file identifiers to storage paths.
6
7use crate::{Result, ValidationError};
8use unicode_normalization::UnicodeNormalization;
9
10/// Maximum filename length in UTF-8 bytes.
11///
12/// This is intentionally byte-based rather than scalar-count-based. It is more
13/// conservative than NTFS/APFS "255 characters" and remains compatible with the
14/// common ext4 255-byte component limit.
15pub const MAX_FILENAME_LEN: usize = 255;
16const COPY_FALLBACK_STEM: &str = "copy";
17
18const FORBIDDEN_CHARS: &[char] = &['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|'];
19
20const WINDOWS_RESERVED_BASENAMES: &[&str] = &[
21    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
22    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
23];
24
25/// Parsed pieces of a filename used when generating copy names.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CopyNameTemplate {
28    /// Base filename without the extension and generated copy suffix.
29    pub base_name: String,
30    /// Extension including the leading dot, when the name has one.
31    pub ext: Option<String>,
32    /// Copy number inferred from the input name.
33    pub next_copy_number: u32,
34}
35
36/// Normalizes a name to Unicode NFC.
37pub fn normalize_name(name: &str) -> String {
38    name.nfc().collect()
39}
40
41/// Counts Unicode scalar values in a string.
42pub fn char_count(value: &str) -> usize {
43    value.chars().count()
44}
45
46/// Normalizes a file or folder name and validates the normalized result.
47pub fn normalize_validate_name(name: &str) -> Result<String> {
48    let normalized = normalize_name(name);
49    validate_normalized_name(&normalized)?;
50    Ok(normalized)
51}
52
53/// Validates a file or folder name after Unicode normalization.
54pub fn validate_name(name: &str) -> Result<()> {
55    let normalized = normalize_name(name);
56    validate_normalized_name(&normalized)
57}
58
59fn validate_normalized_name(name: &str) -> Result<()> {
60    if name.is_empty() {
61        return Err(ValidationError::new("name cannot be empty"));
62    }
63    if name.len() > MAX_FILENAME_LEN {
64        return Err(ValidationError::new(format!(
65            "name too long (max {MAX_FILENAME_LEN} bytes)"
66        )));
67    }
68    if name == "." || name == ".." {
69        return Err(ValidationError::new("invalid name"));
70    }
71    if is_windows_reserved_name(name) {
72        return Err(ValidationError::new(
73            "name cannot use a Windows reserved device name",
74        ));
75    }
76    if let Some(c) = name.chars().find(|c| FORBIDDEN_CHARS.contains(c)) {
77        return Err(ValidationError::new(format!(
78            "name contains forbidden character '{c}'"
79        )));
80    }
81    if name.chars().any(|c| c.is_ascii_control()) {
82        return Err(ValidationError::new("name contains control characters"));
83    }
84    if name != name.trim() || name.ends_with('.') {
85        return Err(ValidationError::new(
86            "name cannot start/end with spaces or end with a dot",
87        ));
88    }
89    Ok(())
90}
91
92fn is_windows_reserved_name(name: &str) -> bool {
93    let stem = name.split('.').next().unwrap_or(name);
94    let upper = stem.to_ascii_uppercase();
95    WINDOWS_RESERVED_BASENAMES.contains(&upper.as_str())
96}
97
98/// Builds a two-level sharded storage path from a blob key.
99///
100/// The key must be long enough to supply the two shard segments and must be an ASCII storage token,
101/// not a path supplied by a caller. This keeps the helper from panicking on short or non-UTF-8
102/// boundary inputs and prevents accidental nested paths from bypassing the intended sharding
103/// layout.
104pub fn storage_path_from_blob_key(blob_key: &str) -> Result<String> {
105    validate_blob_key_for_storage_path(blob_key)?;
106
107    Ok(format!(
108        "{}/{}/{}",
109        &blob_key[..2],
110        &blob_key[2..4],
111        blob_key
112    ))
113}
114
115fn validate_blob_key_for_storage_path(blob_key: &str) -> Result<()> {
116    if blob_key.len() < 4 {
117        return Err(ValidationError::new(
118            "blob key must contain at least 4 ASCII characters",
119        ));
120    }
121    if !blob_key.is_ascii()
122        || blob_key
123            .bytes()
124            .any(|byte| byte.is_ascii_control() || matches!(byte, b'/' | b'\\'))
125    {
126        return Err(ValidationError::new(
127            "blob key must be an ASCII token without path separators",
128        ));
129    }
130    Ok(())
131}
132
133/// Parses a name into the template used to generate copy names.
134pub fn copy_name_template(name: &str) -> CopyNameTemplate {
135    let (stem, ext) = match name.rfind('.') {
136        Some(dot) if dot > 0 => (&name[..dot], Some(name[dot..].to_string())),
137        _ => (name, None),
138    };
139
140    let (base_name, next_copy_number) = if let Some(paren_start) = stem.rfind(" (") {
141        let after_paren = &stem[paren_start + 2..];
142        if let Some(num_str) = after_paren.strip_suffix(')') {
143            if let Ok(n) = num_str.parse::<u32>() {
144                match n.checked_add(1) {
145                    Some(next) => (stem[..paren_start].to_string(), next),
146                    // The copy-number space is exhausted. Keep the full stem and start a
147                    // fresh " (1)" layer on top of it, exactly like the unparseable-suffix
148                    // branches: falling back to "file (1)" would produce a name that very
149                    // likely already exists and could be overwritten by one-shot callers.
150                    None => (stem.to_string(), 1),
151                }
152            } else {
153                (stem.to_string(), 1)
154            }
155        } else {
156            (stem.to_string(), 1)
157        }
158    } else {
159        (stem.to_string(), 1)
160    };
161
162    CopyNameTemplate {
163        base_name,
164        ext,
165        next_copy_number,
166    }
167}
168
169/// Formats a copy name using the default filename length limit.
170pub fn format_copy_name(template: &CopyNameTemplate, copy_number: u32) -> String {
171    format_copy_name_with_limit(template, copy_number, MAX_FILENAME_LEN)
172}
173
174/// Formats a copy name while keeping the result within `max_len` UTF-8 bytes.
175pub fn format_copy_name_with_limit(
176    template: &CopyNameTemplate,
177    copy_number: u32,
178    max_len: usize,
179) -> String {
180    let suffix = format!(" ({copy_number})");
181    let ext = template.ext.as_deref().unwrap_or("");
182    let ext = bounded_copy_extension(ext, suffix.len(), max_len);
183    let max_base_len = max_len.saturating_sub(suffix.len() + ext.len());
184    let mut base = truncate_utf8_to_max_bytes(&template.base_name, max_base_len);
185    if base.is_empty() {
186        base = truncate_utf8_to_max_bytes(COPY_FALLBACK_STEM, max_base_len);
187    }
188
189    format!("{base}{suffix}{ext}")
190}
191
192/// Truncates a string to at most `max_len` bytes without splitting a UTF-8 code point.
193pub fn truncate_utf8_to_max_bytes(value: &str, max_len: usize) -> String {
194    if value.len() <= max_len {
195        return value.to_string();
196    }
197
198    let mut end = max_len;
199    while end > 0 && !value.is_char_boundary(end) {
200        end -= 1;
201    }
202    value[..end].to_string()
203}
204
205fn bounded_copy_extension(ext: &str, suffix_len: usize, max_len: usize) -> String {
206    if ext.is_empty() {
207        return String::new();
208    }
209
210    let max_ext_len = max_len
211        .saturating_sub(COPY_FALLBACK_STEM.len())
212        .saturating_sub(suffix_len);
213    if max_ext_len < 2 {
214        return String::new();
215    }
216
217    let mut candidate = truncate_utf8_to_max_bytes(ext, max_ext_len);
218    while candidate.ends_with('.') || candidate.ends_with(' ') {
219        candidate.pop();
220    }
221    if candidate.len() < 2 || !candidate.starts_with('.') {
222        String::new()
223    } else {
224        candidate
225    }
226}
227
228/// Returns the next copy name for a file or folder.
229pub fn next_copy_name(name: &str) -> String {
230    let template = copy_name_template(name);
231    format_copy_name(&template, template.next_copy_number)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn validate_name_accepts_and_rejects_expected_values() {
240        assert!(validate_name("hello.txt").is_ok());
241        assert!(validate_name(".gitignore").is_ok());
242        assert!(validate_name("file (1).txt").is_ok());
243        assert!(validate_name("cafe\u{0301}.txt").is_ok());
244
245        assert!(validate_name("").is_err());
246        assert!(validate_name("a/b").is_err());
247        assert!(validate_name("a\\b").is_err());
248        assert!(validate_name("a:b").is_err());
249        assert!(validate_name("a*b").is_err());
250        assert!(validate_name("a?b").is_err());
251        assert!(validate_name("a\"b").is_err());
252        assert!(validate_name("a<b").is_err());
253        assert!(validate_name("a>b").is_err());
254        assert!(validate_name("a|b").is_err());
255        assert!(validate_name(".").is_err());
256        assert!(validate_name("..").is_err());
257        assert!(validate_name("a\x01b").is_err());
258        assert!(validate_name("a\nb").is_err());
259        assert!(validate_name("a\tb").is_err());
260        assert!(validate_name(" leading").is_err());
261        assert!(validate_name("trailing ").is_err());
262        assert!(validate_name("ends.").is_err());
263
264        assert!(validate_name(&"a".repeat(256)).is_err());
265        assert!(validate_name(&"a".repeat(255)).is_ok());
266    }
267
268    #[test]
269    fn normalize_validate_name_normalizes_nfd_to_nfc() {
270        let normalized = normalize_validate_name("cafe\u{0301}.txt").unwrap();
271        assert_eq!(normalized, "caf\u{00e9}.txt");
272    }
273
274    #[test]
275    fn validate_name_rejects_windows_reserved_names() {
276        for name in [
277            "CON", "con", "PRN.txt", "aux", "NUL.log", "COM1", "com9.txt", "LPT1", "lpt9.prn",
278        ] {
279            assert!(validate_name(name).is_err(), "{name} should be rejected");
280        }
281
282        assert!(validate_name("console.txt").is_ok());
283        assert!(validate_name("LPT10.txt").is_ok());
284    }
285
286    #[test]
287    fn next_copy_name_matches_platform_copy_pattern() {
288        assert_eq!(next_copy_name("test.txt"), "test (1).txt");
289        assert_eq!(next_copy_name("test (1).txt"), "test (2).txt");
290        assert_eq!(next_copy_name("test (99).txt"), "test (100).txt");
291        assert_eq!(next_copy_name("folder"), "folder (1)");
292        assert_eq!(next_copy_name("folder (3)"), "folder (4)");
293        assert_eq!(next_copy_name("my.file.tar.gz"), "my.file.tar (1).gz");
294        assert_eq!(next_copy_name("photo (1).jpg"), "photo (2).jpg");
295        assert_eq!(next_copy_name(".hidden"), ".hidden (1)");
296    }
297
298    #[test]
299    fn next_copy_name_keeps_result_within_filename_limit() {
300        let candidate = next_copy_name(&"a".repeat(MAX_FILENAME_LEN));
301        assert!(candidate.ends_with(" (1)"));
302        assert!(candidate.len() <= MAX_FILENAME_LEN);
303        assert!(validate_name(&candidate).is_ok());
304
305        let candidate = next_copy_name(&format!("{}.txt", "a".repeat(MAX_FILENAME_LEN - 4)));
306        assert!(candidate.ends_with(" (1).txt"));
307        assert!(candidate.len() <= MAX_FILENAME_LEN);
308        assert!(validate_name(&candidate).is_ok());
309    }
310
311    #[test]
312    fn next_copy_name_truncates_on_utf8_boundary() {
313        let candidate = next_copy_name(&format!("{}.txt", "猫".repeat(90)));
314        assert!(candidate.ends_with(" (1).txt"));
315        assert!(candidate.len() <= MAX_FILENAME_LEN);
316        assert!(candidate.is_char_boundary(candidate.len()));
317        assert!(validate_name(&candidate).is_ok());
318    }
319
320    #[test]
321    fn format_copy_name_handles_tiny_limits_and_bad_extensions() {
322        let template = CopyNameTemplate {
323            base_name: "abcdef".to_string(),
324            ext: Some(".txt".to_string()),
325            next_copy_number: 1,
326        };
327
328        assert_eq!(format_copy_name_with_limit(&template, 1, 4), " (1)");
329        assert_eq!(format_copy_name_with_limit(&template, 1, 8), "abcd (1)");
330
331        let template = CopyNameTemplate {
332            base_name: String::new(),
333            ext: Some(". ".to_string()),
334            next_copy_number: 1,
335        };
336        assert_eq!(format_copy_name_with_limit(&template, 1, 12), "copy (1)");
337    }
338
339    #[test]
340    fn truncate_utf8_to_max_bytes_handles_zero_and_multibyte_boundaries() {
341        assert_eq!(truncate_utf8_to_max_bytes("abc", 0), "");
342        assert_eq!(truncate_utf8_to_max_bytes("猫猫", 1), "");
343        assert_eq!(truncate_utf8_to_max_bytes("猫猫", 3), "猫");
344        assert_eq!(truncate_utf8_to_max_bytes("猫猫", 4), "猫");
345    }
346
347    #[test]
348    fn copy_name_template_parses_existing_suffix() {
349        let template = copy_name_template("photo (41).jpg");
350        assert_eq!(template.base_name, "photo");
351        assert_eq!(template.ext.as_deref(), Some(".jpg"));
352        assert_eq!(template.next_copy_number, 42);
353        assert_eq!(
354            format_copy_name(&template, template.next_copy_number),
355            "photo (42).jpg"
356        );
357    }
358
359    #[test]
360    fn copy_name_template_starts_fresh_layer_when_copy_number_space_is_exhausted() {
361        // u32::MAX + 1 must not panic (debug) or wrap to 0 (release). It also must not
362        // fall back to "file (1)", which almost certainly exists; the whole stem is kept
363        // and a fresh copy layer starts on top of it.
364        let template = copy_name_template("file (4294967295).txt");
365        assert_eq!(template.base_name, "file (4294967295)");
366        assert_eq!(template.next_copy_number, 1);
367        assert_eq!(
368            next_copy_name("file (4294967295).txt"),
369            "file (4294967295) (1).txt"
370        );
371
372        // The boundary just below still increments normally.
373        let template = copy_name_template("file (4294967294).txt");
374        assert_eq!(template.base_name, "file");
375        assert_eq!(template.next_copy_number, u32::MAX);
376        assert_eq!(
377            next_copy_name("file (4294967294).txt"),
378            "file (4294967295).txt"
379        );
380    }
381
382    #[test]
383    fn storage_path_from_blob_key_uses_two_level_sharding() {
384        let hash = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
385        assert_eq!(
386            storage_path_from_blob_key(hash).unwrap(),
387            format!("ab/cd/{hash}")
388        );
389    }
390
391    #[test]
392    fn storage_path_from_blob_key_rejects_values_that_cannot_be_safely_sharded() {
393        assert!(storage_path_from_blob_key("abc").is_err());
394        assert!(storage_path_from_blob_key("ab/cd").is_err());
395        assert!(storage_path_from_blob_key("ab\\cd").is_err());
396        assert!(storage_path_from_blob_key("猫猫猫猫").is_err());
397    }
398}