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