diff options
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common/paths.py')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/paths.py | 150 |
1 files changed, 150 insertions, 0 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/paths.py b/packages/meshbay-common/src/meshbay_common/paths.py new file mode 100644 index 0000000..45bf34e --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/paths.py @@ -0,0 +1,150 @@ +""" +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" |