Files
music-mirror/tools/check_fat32.py
T
Emma Thorpe ece79515c0
Build and publish container / build (pull_request) Successful in 3m54s
fix: cost the device prefix exactly rather than approximately
The budget subtracted the prefix length plus two, on the assumption of a
leading and a trailing slash. That is right for /Music and wrong for an empty
prefix, where there is only one slash -- losing a character at the card root,
which is exactly where the longest paths sit.

Computed from the prefix as it will actually appear instead: /Music/ costs
seven characters and gives a mirror-relative budget of 253, the root costs one
and gives 259.

Worth being exact about because the reverse error is worse. A checker
comparing mirror-relative paths against the flat 260 passes everything between
253 and 260, and those are precisely the paths closest to the edge.
2026-08-25 11:52:03 +01:00

122 lines
4.3 KiB
Python
Executable File

#!/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'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.
PATH_LIMIT = 260
DEVICE_PREFIX = "/Music"
def device_prefix_length(prefix):
"""Return the on-device prefix as it will actually appear, with slashes.
"/Music" costs seven characters -- the leading slash, the name, and the
separator before the mirror's own path -- while an empty prefix costs one.
Approximating that loses a character at the root, which is precisely where
the longest paths are.
"""
cleaned = prefix.strip("/")
return f"/{cleaned}/" if cleaned else "/"
def problems_with(relative, budget=PATH_LIMIT):
"""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)) > budget:
found.append(f"path of {len(str(relative))} characters, over a budget of {budget}")
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")
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})",
)
args = parser.parse_args(argv)
budget = max(0, args.max_path - len(device_prefix_length(args.device_prefix)))
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, budget):
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; it shortens over-long paths as well.",
file=sys.stderr,
)
return 1 if faults else 0
if __name__ == "__main__":
sys.exit(main())