1use crate::{Result, ValidationError};
8use unicode_normalization::UnicodeNormalization;
9
10pub 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CopyNameTemplate {
28 pub base_name: String,
30 pub ext: Option<String>,
32 pub next_copy_number: u32,
34}
35
36#[must_use]
38pub fn normalize_name(name: &str) -> String {
39 name.nfc().collect()
40}
41
42#[must_use]
44pub fn char_count(value: &str) -> usize {
45 value.chars().count()
46}
47
48pub fn normalize_validate_name(name: &str) -> Result<String> {
54 let normalized = normalize_name(name);
55 validate_normalized_name(&normalized)?;
56 Ok(normalized)
57}
58
59pub 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
109pub 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#[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 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#[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#[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#[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#[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 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 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}