main
py 49 lines 1.64 KB
Raw
1 import re
2 import unicodedata
3 from pathlib import Path
4 from typing import Final, Optional
5
6 # Forbidden characters:
7 # Linux/Unix: / and NULL byte
8 # Windows: < > : " / \ | ? * and ASCII control characters (0-31)
9 # Shell-sensitive: ~ to prevent accidental home directory access
10 FORBIDDEN_CHARS_RE: Final = re.compile(r'[<>:"|?*~/\\\x00-\x1f\x7f]')
11
12 # Windows reserved filenames
13 WINDOWS_RESERVED: Final = frozenset({
14 "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
15 "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
16 "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
17 })
18
19 FILENAME_MAX_LENGTH: Final = 255
20
21 def safe_filename(filename: str) -> Optional[str]:
22 # Normalize Unicode (NFC)
23 filename = unicodedata.normalize("NFC", str(filename))
24 # Replace forbidden chars
25 filename = FORBIDDEN_CHARS_RE.sub("_", filename)
26 # Remove leading/trailing spaces and trailing dots
27 filename = filename.lstrip(" ").rstrip(". ")
28
29 path = Path(filename)
30 suffixes = ''.join(path.suffixes)
31 stem = path.name[:-len(suffixes)] if suffixes else path.name
32
33 # Check Windows reserved names
34 if stem.upper() in WINDOWS_RESERVED:
35 filename = f"{stem}-{suffixes}"
36
37 # Truncate if too long
38 if len(filename) > FILENAME_MAX_LENGTH:
39 max_stem_len = FILENAME_MAX_LENGTH - len(suffixes)
40 if max_stem_len > 0:
41 # Truncate filename
42 stem = stem[:max_stem_len]
43 filename = stem + suffixes
44 else:
45 # Extension is too long, truncate everything
46 filename = filename[:FILENAME_MAX_LENGTH]
47 if not filename:
48 return None
49 return filename