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
36pub fn normalize_name(name: &str) -> String {
38 name.nfc().collect()
39}
40
41pub fn char_count(value: &str) -> usize {
43 value.chars().count()
44}
45
46pub fn normalize_validate_name(name: &str) -> Result<String> {
48 let normalized = normalize_name(name);
49 validate_normalized_name(&normalized)?;
50 Ok(normalized)
51}
52
53pub 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
98pub 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
133pub 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 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
169pub fn format_copy_name(template: &CopyNameTemplate, copy_number: u32) -> String {
171 format_copy_name_with_limit(template, copy_number, MAX_FILENAME_LEN)
172}
173
174pub 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
192pub 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
228pub 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 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 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}