""" Name rules that have to give the same answer everywhere. Most people share from an external drive formatted exFAT or NTFS, on Windows. Those filesystems are case-insensitive and case-preserving, store no POSIX permissions and no symlinks, and Windows refuses a set of names outright. So two names that are plainly different on ext4 can be the same file somewhere else, or no file at all — and an index built on one machine is read on another. "Are these two names the same?" therefore has one answer, and it lives here. What this module deliberately does **not** do: rewrite names. A name is stored as the filesystem gave it, because that is the string that opens the file. Folding and normalization exist for *comparison*, never for storage. """ from __future__ import annotations import os import re import unicodedata from pathlib import Path, PurePath # Windows refuses these as a basename, with or without an extension: `AUX.txt` # is as impossible as `AUX`. A group indexed on Linux can hold them, and a # Windows client then cannot write the file it just downloaded. WINDOWS_RESERVED = frozenset({ "CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10)), }) # Reserved on Windows; `/` is reserved everywhere. Control characters go too. _RESERVED_CHARS = set('<>:"/\\|?*') | {chr(c) for c in range(32)} # Windows without long-path support. A deep media library reaches this. MAX_PATH_WINDOWS = 260 def nfc(text: str) -> str: """ Canonical form for comparison. `Café.mkv` written on macOS (NFD: `e` + combining acute) and on Windows (NFC: precomposed `é`) are different byte strings that name the same file to a human, and to most filesystems. For French names this is routine. """ return unicodedata.normalize("NFC", text) def fold(text: str) -> str: """ The form in which two names are "the same file". NFC first, then `casefold` — which is not `lower()`: it handles the cases `lower()` misses, and those are the ones that show up as a bug report rather than a test failure. """ return nfc(text).casefold() def fold_path(rel: str) -> str: """`fold` applied per segment, so separators survive.""" return "/".join(fold(seg) for seg in rel.split("/")) def portable_name_problem(name: str) -> str | None: """ Why `name` cannot be written on some supported platform, or None. Used to warn, not to refuse: a file already on the operator's disk is a fact, and the answer is to tell whoever downloads it that it was renamed — not to pretend it is not there. """ if not name: return "empty name" if name in (".", ".."): return "reserved name" bad = sorted(set(name) & _RESERVED_CHARS) if bad: printable = "".join(c if c.isprintable() else "?" for c in bad) return f"contains reserved characters: {printable}" # Windows strips these silently, so `file .` and `file` become the same # thing after a round trip. if name[-1] in " .": return "ends with a space or a dot" stem = name.split(".", 1)[0] if stem.upper() in WINDOWS_RESERVED: return f"reserved on Windows ({stem.upper()})" return None def is_portable_name(name: str) -> bool: return portable_name_problem(name) is None def long_path(path: Path | str) -> str: """ A path string safe to hand to the OS. On Windows, prefix with `\\\\?\\` so `MAX_PATH` does not truncate a deep library. The prefix requires an absolute, already-normalized path, and it is a no-op everywhere else. """ text = str(path) if os.name != "nt" or text.startswith("\\\\?\\"): return text resolved = str(PurePath(text)) if len(resolved) < MAX_PATH_WINDOWS and not text.startswith("\\\\"): return text if text.startswith("\\\\"): # UNC: \\server\share → \\?\UNC\server\share return "\\\\?\\UNC" + text[1:] return "\\\\?\\" + text def find_fold_collisions(names: list[str]) -> dict[str, list[str]]: """ Names that collide once folded, keyed by the folded form. Two entries that fold together cannot both exist on a case-insensitive filesystem. On ext4 they can, which is how an index becomes unrepresentable for the person who downloads it — so this is reported to the operator rather than resolved silently: only they know which file they meant. """ seen: dict[str, list[str]] = {} for name in names: seen.setdefault(fold(name), []).append(name) return {k: v for k, v in seen.items() if len(v) > 1} _TRAILING = re.compile(r"[ .]+$") def sanitize_for_download(name: str, *, replacement: str = "_") -> str: """ A name that can be written on the running platform, from one that may not be. For the client saving a file, never for the node storing one. Returns the name unchanged when it is already portable, so the common case is identity and the caller can tell whether it renamed anything by comparing. """ if is_portable_name(name): return name out = "".join(replacement if c in _RESERVED_CHARS else c for c in name) out = _TRAILING.sub("", out) stem, dot, ext = out.partition(".") if stem.upper() in WINDOWS_RESERVED: stem = f"{stem}{replacement}" out = f"{stem}{dot}{ext}" return out or "unnamed"