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.
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.
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.
| Filename | Name, NFC | Name, NFD | Sanitized, from either |
|---|---|---|---|
| café.pdf | 9 | 10 | cafe.pdf |
| Ärger.txt | 10 | 11 | Arger.txt |
| Łódź.txt | 11 | 13 | Lodz.txt |
| Ελλάδα.txt | 16 | 18 | Ellada.txt |
| Tiếng Việt.doc | 18 | 22 | Tieng_Viet.doc |
| 한글.txt | 10 | 22 | han_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.
| Input | Universal | POSIX | Why |
|---|---|---|---|
| áux.txt | _aux.txt | aux.txt | Reserved only after the accent is folded. |
| çon.txt | _con.txt | con.txt | The same, via a cedilla. |
| cöm1.txt | _com1.txt | com1.txt | Device names are numbered too. |
| CON.txt | _CON.txt | CON.txt | POSIX has no reserved names at all. |
| my:file?.txt | my_file.txt | my:file?.txt | Colon and question mark are legal on POSIX. |
| ../../../etc/passwd | _.etcpasswd | _.etcpasswd | Separators 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")
// One safe filename from a name that three platforms store differently.
// cargo add disarm
use disarm::api::{normalize, sanitize_filename, NormalizationForm, Platform};
fn san(s: &str) -> String {
sanitize_filename(s, "_", 255, Platform::Universal, None, true).unwrap()
}
fn main() {
// macOS stores filenames decomposed; Windows and Linux store what they are
// given, which is usually composed. Same name on screen, different bytes.
let name = "Tiếng Việt.doc";
let nfc = normalize(name, NormalizationForm::Nfc); // Windows, Linux
let nfd = normalize(name, NormalizationForm::Nfd); // macOS
assert_ne!(nfc, nfd, "expected the two forms to differ");
assert_eq!((nfc.len(), nfd.len()), (18, 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_eq!(san(&nfc), "Tieng_Viet.doc");
assert_eq!(san(&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.
assert_eq!(san("áux.txt"), "_aux.txt");
println!("ok: {}B and {}B converge on one name", nfc.len(), nfd.len());
}
// One safe filename from a name that three platforms store differently.
// npm install disarm
const { sanitizeFilename } = require("disarm");
// macOS stores filenames decomposed; Windows and Linux store what they are
// given, which is usually composed. Same name on screen, different bytes.
const name = "Tiếng Việt.doc";
const nfc = name.normalize("NFC"); // Windows, Linux
const nfd = name.normalize("NFD"); // macOS
const bytes = (s) => Buffer.byteLength(s, "utf8");
if (nfc === nfd) throw new Error("expected the two forms to differ");
if (bytes(nfc) !== 18 || bytes(nfd) !== 22) throw new Error("unexpected byte lengths");
// 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.
if (sanitizeFilename(nfc) !== "Tieng_Viet.doc") throw new Error("NFC did not converge");
if (sanitizeFilename(nfd) !== "Tieng_Viet.doc") throw new Error("NFD did not converge");
// Transliteration runs before reserved-name detection, so a name that is not a
// Windows device becomes one once its accent is folded.
if (sanitizeFilename("áux.txt") !== "_aux.txt") throw new Error("reserved name missed");
console.log(`ok: ${bytes(nfc)}B and ${bytes(nfd)}B converge on one name`);
# One safe filename from a name that three platforms store differently.
# gem install disarm
require "disarm"
require "unicode_normalize"
# 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 = name.unicode_normalize(:nfc) # Windows, Linux
nfd = name.unicode_normalize(:nfd) # macOS
raise "expected the two forms to differ" if nfc == nfd
raise "unexpected byte lengths" unless [nfc.bytesize, nfd.bytesize] == [18, 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.
raise "NFC did not converge" unless Disarm.sanitize_filename(nfc) == "Tieng_Viet.doc"
raise "NFD did not converge" unless Disarm.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.
raise "reserved name missed" unless Disarm.sanitize_filename("áux.txt") == "_aux.txt"
puts "ok: #{nfc.bytesize}B and #{nfd.bytesize}B converge on one name"
// One safe filename from a name that three platforms store differently.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
public class SanitizeFilename {
static int bytes(String s) {
return s.getBytes(StandardCharsets.UTF_8).length;
}
public static void main(String[] args) {
// macOS stores filenames decomposed; Windows and Linux store what they
// are given, which is usually composed. Same name, different bytes.
String name = "Tiếng Việt.doc";
String nfc = Normalizer.normalize(name, Normalizer.Form.NFC); // Windows, Linux
String nfd = Normalizer.normalize(name, Normalizer.Form.NFD); // macOS
// Java's `assert` is disabled unless the JVM is started with -ea, so
// these throw instead.
if (nfc.equals(nfd)) {
throw new IllegalStateException("expected the two forms to differ");
}
if (bytes(nfc) != 18 || bytes(nfd) != 22) {
throw new IllegalStateException("unexpected byte lengths");
}
// 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.
if (!Disarm.sanitizeFilename(nfc).equals("Tieng_Viet.doc")) {
throw new IllegalStateException("NFC did not converge");
}
if (!Disarm.sanitizeFilename(nfd).equals("Tieng_Viet.doc")) {
throw new IllegalStateException("NFD did not converge");
}
// Transliteration runs before reserved-name detection, so a name that
// is not a Windows device becomes one once its accent is folded.
if (!Disarm.sanitizeFilename("áux.txt").equals("_aux.txt")) {
throw new IllegalStateException("reserved name missed");
}
System.out.printf("ok: %dB and %dB converge on one name%n", bytes(nfc), bytes(nfd));
}
}
// One safe filename from a name that three platforms store differently.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.Disarm
import java.text.Normalizer
fun bytes(s: String) = s.toByteArray(Charsets.UTF_8).size
fun main() {
// macOS stores filenames decomposed; Windows and Linux store what they are
// given, which is usually composed. Same name, different bytes.
val name = "Tiếng Việt.doc"
val nfc = Normalizer.normalize(name, Normalizer.Form.NFC) // Windows, Linux
val nfd = Normalizer.normalize(name, Normalizer.Form.NFD) // macOS
check(nfc != nfd) { "expected the two forms to differ" }
check(bytes(nfc) == 18 && bytes(nfd) == 22) { "unexpected byte lengths" }
// 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.
check(Disarm.sanitizeFilename(nfc) == "Tieng_Viet.doc") { "NFC did not converge" }
check(Disarm.sanitizeFilename(nfd) == "Tieng_Viet.doc") { "NFD did not converge" }
// Transliteration runs before reserved-name detection, so a name that is
// not a Windows device becomes one once its accent is folded.
check(Disarm.sanitizeFilename("áux.txt") == "_aux.txt") { "reserved name missed" }
println("ok: ${bytes(nfc)}B and ${bytes(nfd)}B converge on one name")
}
What each platform forbids
| Platform | Illegal characters | Reserved 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
- Normalize Unicode whitespace — blanks that are not spaces, which arrive in filenames as readily as anywhere else.
- Remove invisible characters — zero-width characters that make two filenames differ without looking different.
- Detect script spoofing — a name written wholly in one script whose every character has a Latin twin.