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

Tieng_Viet.doc

Both forms sanitize to the same name.

The name differs by 4 bytes between macOS and Windows. The output does not differ at all, so a file saved from either machine lands on the same name.

The same name, stored two ways

Both rows are your filename. They render identically, because that is what canonical equivalence means. Highlighted chips are the non-ASCII characters, which are the only ones that differ between the forms.

Composed NFC Windows, Linux14 characters, 18-byte name
TU+0054iU+0069ếU+1EBFnU+006EgU+0067 U+0020VU+0056iU+0069ệU+1EC7tU+0074.U+002EdU+0064oU+006FcU+0063
Decomposed NFD macOS18 characters, 22-byte name
TU+0054iU+0069eU+0065◌̂U+0302◌́U+0301nU+006EgU+0067 U+0020VU+0056iU+0069eU+0065◌̣U+0323◌̂U+0302tU+0074.U+002EdU+0064oU+006FcU+0063

The same name, 4 bytes apart in the name itself. Windows and Linux store what they are given, which is usually the composed form. macOS stores names decomposed. Neither string equals the other, and on a Linux filesystem both can sit in one directory looking identical in ls.

CheckResultWhat it means
Composed and decomposed agree after sanitizingyesTransliteration runs first in the pipeline, so both forms reach ASCII before any other rule applies. The byte difference is gone by then.
Transliterated to ASCIIyesNon-ASCII characters were romanised rather than deleted, so the name stays readable. The lang profile decides how: German turns ä into ae, the default profile into a.

Running disarm 0.14.1, compiled to WebAssembly. Your text is never uploaded — the engine is loaded into this page and runs on your machine.

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 in CI. 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.

Found a string this gets wrong? The confusables table grew out of exactly that kind of report. Open an issue with it.

Related tools