#!/usr/bin/env python3 """Report paths a FAT32 device will not accept, before copying rather than during. Run this against the mirror before an rsync to a Rockbox iPod. rsync will report the failures too, but scattered through a run of fifty thousand files, where they are easy to lose. Checks the four ways a name fails on FAT32: reserved characters, trailing dots or spaces that FAT silently eats, components longer than 255 characters, and names that differ only in case -- two files here, one file there, and the second silently overwrites the first. """ import argparse import os import re import sys import unicodedata from collections import defaultdict from pathlib import Path RESERVED = re.compile(r'[<>:"\\|?*\x00-\x1f]') COMPONENT_LIMIT = 255 # Rockbox builds paths into a fixed buffer; long trees fail on the device even # when every individual component is legal. PATH_LIMIT = 260 def problems_with(relative): """Return every reason this relative path is unfit for FAT32.""" found = [] for part in relative.parts: if RESERVED.search(part): found.append(f"reserved character in {part!r}") if part != part.rstrip(". "): found.append(f"trailing dot or space in {part!r}") if len(part) > COMPONENT_LIMIT: found.append(f"component of {len(part)} characters") if len(str(relative)) > PATH_LIMIT: found.append(f"path of {len(str(relative))} characters") return found def walk(root): """Yield every file below a root, as a path relative to it.""" for base, _, names in os.walk(root): for name in names: yield Path(os.path.join(base, name)).relative_to(root) def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("root", help="directory to check, e.g. the mirror") parser.add_argument("--limit", type=int, default=0, help="show at most this many") args = parser.parse_args(argv) root = Path(args.root) if not root.is_dir(): print(f"{root} is not a directory", file=sys.stderr) return 2 faults = [] by_case = defaultdict(list) total = 0 for relative in walk(root): total += 1 # NFC first: the same name written by two systems is otherwise two # different strings, and the collision check would miss it. key = unicodedata.normalize("NFC", str(relative)).casefold() by_case[key].append(relative) for problem in problems_with(relative): faults.append((relative, problem)) for relative, group in sorted(by_case.items()): if len(group) > 1: names = ", ".join(str(path) for path in sorted(group)) faults.append((group[0], f"collides case-insensitively with: {names}")) for relative, problem in faults[: args.limit or None]: print(f"{relative}\t{problem}") print(f"\n{len(faults)} problems across {total} files", file=sys.stderr) if faults: print( "Run music-mirror with --fat32-safe to have the mirror named" " acceptably in the first place.", file=sys.stderr, ) return 1 if faults else 0 if __name__ == "__main__": sys.exit(main())