disarm

One name, three platforms

Sanitize a filename

Save a file called Tiếng Việt.doc on a Mac and the name occupies 22 bytes. Save the same file on Windows and the name occupies 18. The two names render identically and are canonically equivalent, but they are not equal — and on a Linux filesystem both can sit in one directory. Enter a name to see both forms codepoint by codepoint, and get one name that is safe and identical everywhere.

The tool


        

Enter a filename to check it.

Nothing is uploaded. The engine runs inside this page.

Loading the engine…

Why the pipeline order is the whole answer

The tool above needs JavaScript. This is the same transformation, written out, so the result is legible without running anything.

disarm's documented pipeline runs in a fixed order: transliterate, strip illegal characters, replace them with the separator, collapse repeats, handle reserved names, truncate, then trim the ends. Two of those orderings do real work.

Transliteration comes first, which is what makes the forms converge. Truncation counts the bytes of the name, and the decomposed spelling of a name is longer than the composed one — a Korean name can be more than twice as long. Had truncation run against the raw input, the same filename would have been cut at two different points depending on which machine it arrived from. Because both forms are reduced to the same ASCII string first, truncation never sees the difference.

FilenameName, NFC Name, NFDSanitized, from either
café.pdf910cafe.pdf
Ärger.txt1011Arger.txt
Łódź.txt1113Lodz.txt
Ελλάδα.txt1618Ellada.txt
Tiếng Việt.doc1822Tieng_Viet.doc
한글.txt1022han_geul.txt

Measured against disarm 0.14.1, universal platform, default transliteration profile. All four normalization forms — NFC, NFD, NFKC and NFKD — produced identical output for every name tested. Note the Korean row: the decomposed form is more than twice the size of the composed one, because each syllable splits into its component jamo.

Transliteration can invent a reserved name

Reserved-name detection comes after transliteration, and that ordering is load-bearing too. áux.txt is not a Windows device name. Fold the accent and it becomes aux.txt, which is. disarm returns _aux.txt; a sanitizer that checked its reserved list against the original input would have shipped a name that Windows refuses to create.

InputUniversalPOSIXWhy
áux.txt_aux.txtaux.txtReserved only after the accent is folded.
çon.txt_con.txtcon.txtThe same, via a cedilla.
cöm1.txt_com1.txtcom1.txtDevice names are numbered too.
CON.txt_CON.txtCON.txtPOSIX has no reserved names at all.
my:file?.txtmy_file.txtmy:file?.txtColon and question mark are legal on POSIX.
../../../etc/passwd_.etcpasswd_.etcpasswdSeparators go on every platform.

Choose universal unless you know the file will never leave one platform. It applies both rule sets, so the name it returns can be written anywhere. posix is the permissive one: it forbids only the forward slash and NUL, which is why the colon and question mark survive it.

One property worth knowing: sanitizing is not idempotent for names that were rewritten to start with a separator. ../../../etc/passwd becomes _.etcpasswd, and sanitizing that gives etcpasswd, because the last pipeline step trims leading separators and dots. Both results are safe — no separator survives either pass — but sanitize once, on the way in, rather than repeatedly.

The same thing in your own code

Each code block has been compiled and verified. Provided under the MIT license to illustrate disarm. There is no C here: the C ABI exposes no sanitize_filename, so there is nothing to call. disarm on GitHub →

# One safe filename from a name that three platforms store differently.
#   pip install disarm
import unicodedata
from disarm import sanitize_filename

# macOS stores filenames decomposed; Windows and Linux store what they are
# given, which is usually composed. Same name on screen, different bytes.
name = "Tiếng Việt.doc"
nfc = unicodedata.normalize("NFC", name)   # Windows, Linux
nfd = unicodedata.normalize("NFD", name)   # macOS

assert nfc != nfd, "expected the two forms to differ"
assert len(nfc.encode()) == 18 and len(nfd.encode()) == 22

# Both must sanitize to one name, or a file saved on a Mac and the same file
# saved on Windows become two rows in your database.
assert sanitize_filename(nfc) == sanitize_filename(nfd) == "Tieng_Viet.doc"

# Transliteration runs before reserved-name detection, so a name that is not a
# Windows device becomes one once its accent is folded. disarm catches that.
assert sanitize_filename("áux.txt") == "_aux.txt"

print(f"ok: {len(nfc.encode())}B and {len(nfd.encode())}B converge on one name")

What each platform forbids

PlatformIllegal charactersReserved names
universal The union of the two below CON PRN AUX NUL COM1–9 LPT1–9
posix / and NUL None
windows < > : " / \ | ? * and the control characters CON PRN AUX NUL COM1–9 LPT1–9

The full parameter list — separator, max_length, platform, lang and preserve_extension — is in the filename guide, whose examples run in disarm's own CI.

Related tools