writing to nul.txt succeeds and creates nothing, because a handful of MS-DOS device names are still reserved in every directory
env paths · case
Symptom
Section titled “Symptom”A file write reports success and the file is not there. No exception, no error code, nothing in the directory listing:
C:\> echo hello > nul.txtC:\> dir nul.txtFile Not FoundThe realistic version is not someone typing nul.txt. It is an extractor
unpacking an archive that contains aux, a generator naming a file from a data
field that happens to be con, or a test fixture named after a case id. Those
work on Linux and macOS, so they reach Windows already committed.
C:\> echo hello > NUL.txtC:\> echo hello > NUL.tar.gzC:\> mkdir sub && echo hello > sub\NUL.txtC:\> dir /bsubMicrosoft’s own documented example, with the superscript form:
C:\> echo test > COM¹That “fails to create a file” — the docs say so in those words, and the superscript digits are the sentence that makes the reservation explicitly apply in every directory rather than only at the root.
On Linux and macOS all of these are ordinary filenames.
CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9 and the ISO-8859-1
superscript forms (COM¹, COM², COM³, and the LPT equivalents) are MS-DOS
device aliases that Win32 still honors. Path parsing recognizes a legacy device
name as its own path type and rewrites it into the NT device namespace before any
directory is applied, so C:\anywhere\NUL.txt resolves to the Null device rather
than to a file in that folder.
Three details do most of the damage:
- An extension does not help. Microsoft documents
NUL.txtandNUL.tar.gzas both equivalent toNUL. CreateFileopens devices as well as files, so the call SUCCEEDS. That is why there is no error to catch: your bytes went to the Null device, and a write toCONgoes to the console instead.- The list is exact and short.
COM10,COM0,CON1, andconsole.txtare not reserved.CON.txtis. Serial ports past 9 need the\\.\COM56form precisely because they are not in the legacy set.
When a reserved-name call fails rather than succeeds, the runtime error adds a
second layer of confusion. Following the published mapping tables, Win32
ERROR_INVALID_NAME (123) reaches Node as ENOENT and Python as EINVAL, so
the same wall would carry two different names depending on your language. Which
Win32 code a given reserved name actually returns, for a given open disposition,
is not something this corpus has executed — see the verification note.
Windows 11 did not repeal this. What changed there is narrower: .NET’s
Path.GetFullPath no longer rewrites a path that BEGINS with a legacy device
name. The reserved-name list itself is current documentation.
Workaround
Section titled “Workaround”Reject or mangle the closed set at the Windows boundary, matching on the stem rather than the whole filename:
const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(\.|$)/i;if (RESERVED.test(basename)) throw new Error(`reserved device name: ${basename}`);Put that check in generators, archive extractors, and fixture naming — the three places that produce filenames nobody typed.
What does not work: adding an extension, moving it into a subdirectory, or
trusting an existence check afterwards. Test-Path and fs.existsSync can be
answering for the device rather than for a file.
The \\?\ extended-length prefix disables the path parsing that performs the
device rewrite, which is the documented mechanism — but it applies only to
fully-qualified Unicode paths on APIs that accept it, and Explorer is not
guaranteed to understand what you create that way. Treat it as a targeted escape
hatch, not an application-wide setting.
Verification note
Section titled “Verification note”Quoted from Microsoft’s file-naming documentation: the reserved list including
the superscript forms, the “reserved in every directory” statement, the
NUL.txt and NUL.tar.gz equivalence, and echo test > COM¹ failing to create
a file.
NOT executed, and therefore stated as inference rather than observation: the
exact Win32 error a given reserved-name open returns and how each runtime maps
it; whether a \\?\-prefixed nul.txt create produces a real file (the docs
say the prefix disables the parsing that performs the device rewrite, which is
not the same sentence); and whether reading con.txt blocks on console input.
This corpus has no Windows host, hence repro: historical.
test-path-trailing-whitespace is the other case where Win32 path parsing
silently rewrites what you asked for. There a trailing space is stripped; here a
whole name is redirected to a device.