File Paths and Their Combinations
A file is one of the best-known entities in computing; opening a file is one of the most basic functions almost any programming language provides. A file is, in most cases, a named location on a storage device that contains data. And a file path is, thus, also a well-known entity: it is some sort of textual identifier that can be used to access a file.
Almost everything in these basic definitions deserves an asterisk, because there are certain exceptions to each aspect: files are not necessarily named; the names are not necessarily textual; files aren't necessarily placed on a storage device; nor do they have to contain data. But most programmers would think they agree on the basic definitions and, in particular, on how a file path is supposed to act and what is looks like.
In this post, we'll discuss some lesser-known details of file path behavior on various desktop operating systems (Linux, macOS, Windows).
How Paths Looked Historically
The first mentions of file paths in a hierarchical file system that I was able to find come from MULTICS. For example, check the examples in this document describing a function that performs something similar to separating the file and directory names from a full file path.
In MULTICS, a path consists of a sequence of names separated by the characters > and <.
> means something like / on Unix (so, it's used as the root directory name, and also separates different names in a chain), while < is a reference to a parent directory, so you could say that it could be replaced by the modern /../.
Later in computing, paths were changed to use the / and \ separators we know today.
Some examples of MULTICS paths:
>a>b>cmeans/a/b/c,a>b<cmeans./a/b/../c.
Path Separators
Speaking of which: what separators do modern operating systems use?
On Unix, it is really simple: / is the root folder designator and also a path separator. It separates different levels of the file system hierarchy, i.e., the directories in a path. /foo/bar means the bar file or directory inside the foo directory, which is inside the root directory.
On Windows, the situation is significantly more complex. The canonical path separator is \. E.g., C:\Windows\System32 means the System32 file or directory inside the Windows directory inside the root directory of the drive C:. However, Windows also supports / in almost all use cases (even in UNC paths such as //server/share), except for some exotic paths: most notably, the paths starting with \\?\, which skip path normalization, so a / in them is not converted to \.
Moreover, in certain locales (Imagine that! Locales are cursed again! What a surprise!), Windows will render the path separator as ¥ (in the Japanese locale) or ₩ (in the Korean locale). This is for presentation only: the paths still use the \ character code underneath, but it gets rendered as ¥ or ₩. So, if you copy a path on a Japanese or Korean version of Windows, you can paste it anywhere or send it to another user, and the path will bear the standard separators. Read more in Michael Kaplan's post.
Case Sensitivity
Let's start with a simple (he-he) question: are file paths supposed to be case-sensitive or not? If you only work with one operating system, your answer will probably be a definite "yes" or a definite "no". If you have developed for multiple operating systems, you might answer more fully: on Linux (and BSD, for that matter), paths are case-sensitive (so, Foo and foo are different files), while on Windows (and macOS, a fact that often surprises people, even if they use macOS themselves), paths are case-insensitive (so, Foo and foo are the same file).
Of course, this is not the whole truth. Linux doesn't have to be case-sensitive, and macOS/Windows don't have to be case-insensitive. All three systems provide general mechanisms to control and query the case sensitivity of each path. On Windows, you can control this via fsutil file setCaseSensitiveInfo, and query it via fsutil file queryCaseSensitiveInfo. On Linux, you can mount both case-sensitive and case-insensitive file systems, and ext4, for example, supports this per-directory with the Casefold attribute. On macOS, you can control the case sensitivity of each file system when creating it (there's a flag on APFS that determines the case sensitivity of the whole FS). You can mount a case-sensitive FS to your file system at any point.
This means that generally speaking, you cannot tell whether a path is case-sensitive or not just by knowing the OS your code executes on. Every time you want to know this (and there are many situations when you'd want to know this — e.g., when comparing the file paths to figure out if they point to the same file or directory), you need to query the file system (or, you know, employ other ways to query the file identity).
But wait: what is case-sensitive, anyway? What does it mean? Well, of course, for different operating systems and file systems, this means different things!
Apple's HFS+ file system is documented to store file names in fully decomposed form, according to the table in the standard. Additionally, the standard provides the algorithm to perform the case-insensitive comparison of file names. So, case-insensitivity in the case of HFS+ comes from the very definition of the file system.
APFS (a newer file system from Apple), though, doesn't force any form of normalization when storing the file names: it stores all the names as-is. But macOS itself will still normalize the paths on APFS, similarly to how it's done for HFS+, for compatibility (and sanity) reasons! The case-insensitivity in this case acts according to the rules declared by the Unicode 9.0 standard (I would assume they have updated this to newer Unicode standards later, but I was unable to find any documentation on this).
What about Microsoft? Are they doing anything similar? Of course — nope! While Microsoft file systems are also case-insensitive, they have a different set of quirks. The basic idea is that they don't normalize the file names in any way when saving them to disk, and they perform an ordinal-ignore-case comparison.
If you check the API for Windows drivers, you'll find the function FsRtlAreNamesEqual that generally compares the file names, and you will see a mysterious optional parameter UpcaseTable. What is an "upcase table"? Well, this is a table used to find the corresponding uppercase character (names are compared by converting them to uppercase, of all things!), and the table is usually stored on disk! So, for different versions of software to treat case equality on disk in the same way, file systems tend to store a whole case mapping table on disk in a special place (NTFS, for example, keeps it in the hidden $UpCase file), for all the software to refer to this table when necessary! You can read more interesting lore in a post by Michael Kaplan. See also: the exFAT specification.
In user mode, the closest thing to this comparison is the Win32 function CompareStringOrdinal with bIgnoreCase set to TRUE. Note that it uses the casing data of the running OS, and not the upcase table stored on a particular volume, so the results may differ for some rare characters. .NET on Windows used to call this function for StringComparison.OrdinalIgnoreCase, but since .NET 5, it uses its own casing tables based on ICU by default (CompareStringOrdinal is still used in the NLS mode).
File Path Kinds
There are two well-known kinds of file paths: absolute and relative.
An absolute path is also known as a fully qualified path. It is a path that starts from the root directory of the file system. It is always unambiguous, and it does not depend on the current working directory: an absolute path to a file is always the same, regardless of where you are in the file system. An example of an absolute file path on Unix is /usr/bin. An example of an absolute file path on Windows is C:\Windows.
A relative path is a path that is relative to the current working directory. Depending on your current working directory, it might point to different files or directories. An example of a relative file path is ./bin (on both Windows and Unix, since Windows supports / separators in almost every file path).
Of course, there are some tricky questions regarding this matter. For example, consider the string C:\Windows. What is this, generally speaking? What kind of path is it? Unfortunately, there's no answer: while it seems obvious that this is an absolute Windows file path, on Unix this is also a valid path, and it is relative! It's actually the name of a file that you can create; nothing will prevent you from doing so.
Path Combination
You can combine the basic paths, absolute and relative, to (usually) get a longer path, using the path separator: / on Unix and \ on Windows (though Windows mostly supports / as well). For example, a combination of C:\Windows and System32 will result in C:\Windows\System32. And a combination of /usr and bin will give you /usr/bin. Path combination is also a very simple operation. But as usual, there are a lot of interesting details.
There are two special entries, . and ... . means the current directory (it's seldom used by itself, but you often see it, e.g., in shell commands, to specify that you want to run an executable file from the current directory, and not a globally available one). .. means the parent directory of whatever directory you are currently working with. Both entries can be present in absolute or relative paths. For example, on Windows, C:\Windows\.. is the same folder as C:\. The process of resolving . and .. entries and removing duplicate path separators is called path normalization. It is a text-only process: it doesn't go to disk to check anything. These entries should be considered when combining paths.
There's one peculiar case of path combination: combining two absolute paths. A user can combine paths /usr and /bin, and the result will be /bin — the last absolute path wins. Nobody knows why it was implemented this way, and most parties refer to each other. I was able to track the first glimpses of this behavior back to Emacs code from 1985: see how the expand-file-name function seems to perform a combination of paths. It seems like (expand-file-name "/bar" "/foo") would result in /bar, while (expand-file-name "bar" "/foo") would result in /foo/bar. A more explicit mention can be seen in Python's posixpath.py from 1990, in the function cat, described as performing "intelligent pathname concatenation".
Path Roots
(Yes, plural!)
So, on Unix, there's only one root directory, and it's marked as /. Every other path relates to the root directory somehow. You can always build a relative path between any two absolute paths.
On Windows, the situation is, as usual, significantly more complex. First, there are multiple drives, each one marked with a letter (e.g., C: or D:). Each drive has its own root directory, which is marked as C:\ or D:\. Every other path on these drives relates to the root directory of the corresponding drive. C:\ and D:\ are called the roots. You cannot build a relative path between two absolute paths if they belong to different drives: there's no relative path between C:\ and D:\.
If you consider exotic Windows paths, though, the situation becomes even more complex: for UNC paths, \\server\share\ is considered the root of the share (so, it's always the server name plus the share name).
And for more exotic paths such as DOS devices (\\?\ and \\.\) and object manager expressions (\??\), there's an even more complex set of rules, and so far I have been unable to even identify where this is documented.
For any absolute path, it's expected that taking its root is a valid operation, regardless of the definition the root directory takes for this particular path.
Exotic Relative Paths
In all its wisdom, Windows introduces two more kinds of non-absolute paths!
These are drive-relative and current directory on a drive paths:
C:Somethingmeans theSomethingfile or folder inside the current directory on driveC:. Other possible forms are, e.g.,C:.\SomethingorC:..\Something; all of them resolve relative to the current directory on driveC:as well.What is the "current" directory on a drive? I am so glad you asked! You see, the Windows shell (mainly
cmd.exe, though PowerShell also remembers a separate location for each drive) remembers the current directories on each drive separately. When you runcd C:\Windowsin Cmd, it will act differently depending on whether your current working directory is on driveC:or not. If you are already on this drive, it will just change the current working directory to whatever you asked. Otherwise, it will set theC:drive's working directory toC:\Windowsand not change the current working directory — meaning you'll stay on whatever other drive you were on. If you want to change your active drive, you need to invoke the commandcd /d.So, Windows' Cmd shell introduces the concept of a per-drive working directory. And it keeps these directories in special environment variables (e.g.,
=C:for theC:drive) that are passed to each process you start from the shell, and this allows the file system APIs to properly react when you request a path relative to the current directory on a certain drive, such asC:file.txt.PowerShell, on the other hand, keeps its per-drive locations to itself: it doesn't set these variables, so neither the file system APIs nor the programs you start from PowerShell know about them. If you
cd D:\Projectsand thencd C:\, PowerShell's owncd D:will bring you back toD:\Projects, while[IO.Path]::GetFullPath('D:x')in the same session will returnD:\x.\Somethingis a directory relative to a drive root, without specifying what drive it is related to. So, when resolved, it will always take the current drive (i.e., the drive your current working directory is on) and use that as the base for this path.
Both of these cases are, confusingly, what .NET calls "rooted paths" (Path.IsPathRooted returns true for both).
Exotic Relative Path Resolution
These paths are resolved in very interesting ways, depending on the technology you use. And there are different opinions on what should happen when they are resolved.
Here's an overview of how different technologies handle these exotic paths (bold entries are "incorrect"; more on what I mean by "incorrect" below):
| Path 1 | Path 2 | Node.js | D | .NET | Win32 | Rust | Java | Python | C++17 | TruePath |
|---|---|---|---|---|---|---|---|---|---|---|
C:x |
C:y |
C:x\C:y |
C:x\C:y |
C:y |
C:y |
C:y |
C:y |
C:x\y |
C:x\y |
C:x\y |
C:x |
D:y |
C:x\D:y |
C:x\D:y |
D:y |
D:y |
D:y |
D:y |
D:y |
D:y |
D:y |
C:\x |
C:y |
C:\x\C:y |
C:\x\C:y |
C:y |
C:y |
C:y |
C:\x\y |
C:\x\y |
C:\x\y |
C:\x\y |
C:\x |
D:y |
C:\x\D:y |
C:\x\D:y |
D:y |
D:y |
D:y |
D:y |
D:y |
D:y |
D:y |
C:x |
y |
C:x\y |
C:x\y |
C:x\y |
C:x\y |
C:x\y |
C:x\y |
C:x\y |
C:x\y |
C:x\y |
C:x |
\y |
C:x\y |
\y |
\y |
C:\y |
C:\y |
C:\y |
C:\y |
C:\y |
C:\y |
C:x |
D:\y |
C:x\D:\y |
D:\y |
D:\y |
D:\y |
D:\y |
D:\y |
D:\y |
D:\y |
D:\y |
C: |
x |
C:\x |
C:\x |
C:\x |
C:\x |
C:x |
C:x |
C:x |
C:x |
C:x |
| 1/8 | 2/8 | 4/8 | 5/8 | 6/8 | 7/8 | 8/8 | 8/8 | 8/8 |
Here,
- Node.js uses
path.join(there's also thepath.resolveAPI, but it always results in an absolute path, so it's harder to compare since it depends on the working directories across the affected drives; if I had to estimate, I'd say it produces 8/8 "correct" answers as well, although they are absolute, and I decided not to include it in the comparison), - D uses
std.path : buildPath, - .NET uses
System.IO.Path.Combine, - Win32 uses the
PathCombinefunction, - Rust uses
Path::join, - Java uses
java.nio.file.Path.resolve, - Python uses
os.path.join, - C++17 uses
operator /onstd::filesystem::path, - TruePath (a library we'll discuss below) uses
operator /onLocalPath.
While preparing this report, I found that .NET changed its behavior regarding certain paths around .NET Core 2.1, and they forgot to document this, so I opened an issue, and my PR fixing the documentation on the Path API in .NET has been merged.
What Do We Consider "Correct"?
In the aforementioned table, I assigned the "correctness" score according to the mnemonic rule of resolve (not something normative, just one I invented), which explains how path combination should work. Path combination should result in the same path that you can arrive at via a sequence of cd calls (or cd /d, for the Cmd shell). For example (here I'll omit /d when not changing the drive):
"a" / "b"is the same ascd a && cd b;"C:\Windows" / "System32"is the same ascd /d C:\Windows && cd System32;"C:" / "Windows"is the same ascd /d C: && cd Windows;"C:x" / "C:y"is the same ascd /d C:x && cd /d C:y, meaning the same asC:x\y— the same result we got in several, but not all, of the technologies listed above.
This logic, to my surprise, is a well-defined part of the C++17 standard.
Note that the C++ standard tries to describe the situation regarding the drives (aka "path roots") in generic terms: each path can declare has_root_name() == true, and the rules of relative resolution then depend on whether the paths' root_name() values are the same or not. My understanding is that this terminology covers the logic regarding Windows network shares and various exotic paths as well, but I haven't checked that yet.
Rule of Resolve and Symlinks
One aspect that different systems and libraries tend to disagree on is how to handle symlinks. Let's imagine the following situation. We have a file system tree of
demo/
├── real/
│ └── sub/
└── elsewhere/
└── link -> ../real/sub
That is — a bunch of directories and a symlink that points to a subdirectory of one of them.
Now let's imagine where the path ./demo/elsewhere/link/.. will lead us to. If we enter a folder ./demo/elsewhere/link and then invoke command cd .., we'll get back to elsewhere. But if we ask the OS to resolve the path ./demo/elsewhere/link/.., the result will depend on the OS: Linux will get us to ./demo/real, while Windows will get us to elsewhere.
TruePath
You can see that .NET often gets the results "wrong". It doesn't have to! I have recently released a new version of TruePath, a .NET library that implements the "correct" behavior, according to the mnemonic rule of file path resolve. It also supports combining paths with the / operator, allows you to strictly define a path as an AbsolutePath (so that none of the aforementioned shenanigans apply to your code), provides some file operations on paths (e.g., comparison with OS-default case sensitivity — and it will (optionally) handle the per-path sensitivity one day as well!), and has other nice features.
More Exotic Paths
That's not all! Windows has even more path kinds, ones that are not yet correctly handled by TruePath (but will be handled in the future):
- UNC Paths,
\\server\share\path\to\file; - Local Device Paths,
\\.\C:\path\to\file; - Root Local Device Paths,
\\?\C:\path\to\file.
Read this wonderful document written by James Forshaw from Google Project Zero on other interesting aspects of these paths.