2026-08-25 10:29:55 +01:00
|
|
|
#!/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
|
2026-08-25 11:45:27 +01:00
|
|
|
# Rockbox's MAX_PATH, from firmware/include/fs_defines.h. It bounds the path as
|
|
|
|
|
# the device sees it, so the directory the mirror is copied into comes out of
|
|
|
|
|
# the same budget.
|
2026-08-25 10:29:55 +01:00
|
|
|
PATH_LIMIT = 260
|
2026-08-25 11:45:27 +01:00
|
|
|
DEVICE_PREFIX = "/Music"
|
2026-08-25 10:29:55 +01:00
|
|
|
|
|
|
|
|
|
2026-08-25 11:45:27 +01:00
|
|
|
def problems_with(relative, budget=PATH_LIMIT):
|
2026-08-25 10:29:55 +01:00
|
|
|
"""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")
|
2026-08-25 11:45:27 +01:00
|
|
|
if len(str(relative)) > budget:
|
|
|
|
|
found.append(f"path of {len(str(relative))} characters, over a budget of {budget}")
|
2026-08-25 10:29:55 +01:00
|
|
|
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")
|
2026-08-25 11:45:27 +01:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--max-path",
|
|
|
|
|
type=int,
|
|
|
|
|
default=PATH_LIMIT,
|
|
|
|
|
help=f"longest path the device will take, from its root (default {PATH_LIMIT},"
|
|
|
|
|
" Rockbox's MAX_PATH)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--device-prefix",
|
|
|
|
|
default=DEVICE_PREFIX,
|
|
|
|
|
help="directory the mirror is copied into on the device; its length comes out"
|
|
|
|
|
f" of the budget (default {DEVICE_PREFIX})",
|
|
|
|
|
)
|
2026-08-25 10:29:55 +01:00
|
|
|
args = parser.parse_args(argv)
|
2026-08-25 11:45:27 +01:00
|
|
|
budget = max(0, args.max_path - len(args.device_prefix.strip("/")) - 2)
|
2026-08-25 10:29:55 +01:00
|
|
|
|
|
|
|
|
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)
|
2026-08-25 11:45:27 +01:00
|
|
|
for problem in problems_with(relative, budget):
|
2026-08-25 10:29:55 +01:00
|
|
|
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(
|
2026-08-25 11:45:27 +01:00
|
|
|
"Run music-mirror with --fat32-safe to have the mirror named acceptably"
|
|
|
|
|
" in the first place; it shortens over-long paths as well.",
|
2026-08-25 10:29:55 +01:00
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
|
|
|
|
return 1 if faults else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|