---
title: "The Path Not Taken: everything that can go wrong with a path"
url: https://www.exakat.io/the-path-not-taken-everything-that-can-go-wrong-with-a-path/
date: 2026-08-26
modified: 2026-08-26
author: "dams"
description: "The Path Not Taken: everything that can go wrong with a path A path is a string. And, just like concatenation, this is the source of all our troubles. PHP..."
categories:
  - "Technology"
tags:
  - "file"
  - "names"
  - "path"
image: https://www.exakat.io/wp-content/uploads/2026/08/path.320.jpg
word_count: 4240
---

# The Path Not Taken: everything that can go wrong with a path

# The Path Not Taken: everything that can go wrong with a path

A path is a string. And, just like concatenation, this is the source of all our troubles.

PHP treats it as a string, the filesystem treats it as a sequence of bytes with opinions, and Windows treats it as a suggestion. Somewhere in between, our code does `$dir . '/' . $name` and calls it a day.

Let's have a look at everything that can happen in that little dot operator.

## Three layers, three sets of rules

A path goes through three validators before a file is opened, and they do not agree with each other.

- **PHP** : stream wrappers, null byte checks, `open_basedir`, the realpath cache.
- **The OS API** : `PATH_MAX`, `MAX_PATH`, separator translation, normalization.
- **The filesystem** : `NAME_MAX`, allowed bytes, case sensitivity, Unicode normalization.

A string may be perfectly valid at one layer, and rejected at the next. Or worse: silently modified at the next, PHP-style or not. Most path bugs live in that gap.

## 1. Length: bytes, not characters

Everyone knows a filename can be 255 long. Few people ask: 255 what?

On Linux, `NAME_MAX` is 255 **bytes**. That is 255 ASCII characters, or 63 emoji. Your user naming their invoice with a family of four, that is 👨‍👩‍👧‍👦, 25 bytes each, thank you ZWJ, will find the limit sooner than you expect.

Here is the table nobody prints on a poster:

| System | One component | Whole path |
| ------ | ------------- | ---------- |
| Linux, ext4/xfs/btrfs | 255 bytes | 4096 (`PATH_MAX`) |
| macOS, APFS | 255 UTF-16 units | 1024 |
| Windows | 255 UTF-16 units | 260 (`MAX_PATH`) |
| eCryptfs | ~143 bytes | Mmm, OK |
| NFS, SMB, that one NAS | ¯\_(ツ)_/¯ | ¯\_(ツ)_/¯ |

And `PATH_MAX` is not a constraint. You can *create* a tree deeper than 4096 bytes by chdir-ing along the way and using relative names. You simply cannot open it afterwards with an absolute path. Backup scripts love this. So do `rm -rf` sessions at 2 AM.

Windows raised `MAX_PATH` in Windows 10 1607, if the registry key is set, and if the application manifest asks for it. Two conditions, both outside your code. The other escape is the `\\?\` prefix:

This works, and it also switches off *all* normalization: no `..` resolution, no `/` to `\` translation, no trailing-dot cleanup. You get exactly what you typed. Never combine that prefix with user input, unless you enjoy adventure.

## 2. Which characters are forbidden? Almost none.

On Unix, exactly two bytes are illegal in a filename: `/` and `\0`. Everything else is fair game.

That last one is not a wildcard on disk. It becomes a wildcard the moment it meets `glob()`, or a shell, or your `find` command. The filesystem is permissive; everything downstream is not.

A few classics:

### Windows, which has opinions

Forbidden in a component: `< > : " / \ | ? *` and bytes `0x00` to `0x1F`. Then it gets interesting.

**Reserved device names**, case-insensitive, extension or not:

`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`. A user uploading `aux.jpg` from a French keyboard is not attacking you. They just wrote "aux" because it is a French word. The result is the same.

**Trailing dots and spaces are silently removed:**

**Alternate Data Streams**, the old favourite:

```
GET /index.php::$DATA
?>
[/php]

```

served the source code of `index.php` instead of executing it, for years.

**Short 8.3 names**, still alive:

**Drive-relative paths**, which look absolute and are not:

**UNC and device namespaces:**

## 3. Separators: two of them, sometimes

Windows accepts `/` and `\`. Unix accepts only `/`, and considers `\` an ordinary, perfectly legal character in a name.

This asymmetry is the engine of a whole family of traversal bugs, because `basename()` follows the platform:

If your sanitizer runs on a Linux box and your storage is a Windows share, congratulations, you have built a tunnel.

Rule of thumb: normalize `\` to `/` on input, always, on every platform. Then use `DIRECTORY_SEPARATOR` on output if you must.

## 4. . and .., which do not mean the same thing everywhere

Everyone knows `..` goes up. Not everyone knows *how*.

- **Windows resolves `..` lexically**, on the string, before touching the disk.
- **Unix resolves `..` on the actual tree**, after following symlinks.

This is why string-level normalization is not a security control. `str_replace('../', '', $path)` is not a security control either, and it never was:

Other small print in the same neighbourhood:

## 5. Encoding: PHP has no idea

PHP does not have a concept of filename encoding. A path is a byte string. That is all.

On Linux, this means two files can look identical and not be:

Normalize before comparing, before storing, before everything:

macOS makes its own choices: HFS+ *forced* NFD; APFS preserves what you give it but compares insensitively to normalization. So the name you read back may not be byte-identical to the name you wrote. Test for it, do not assume it.

Windows stores UTF-16. PHP 7.1+ converts through the active code page, which is usually not UTF-8:

Skip that, and accented filenames turn into a museum of mojibake.

Normalization has a twin sister, and she gets her own section below.

One more: `basename()` is locale-dependent and not fully multibyte-safe. It has been known to eat leading bytes of multibyte characters under the wrong locale. If it matters, split the string yourself.

## 6. Case: the second axis of identity

Unicode normalization asked whether two different byte strings are the same file. Case asks the same question again, on a different axis, and gets a different answer per volume.

Note the word: **per volume**. Not per operating system. Everyone repeats "Linux is case-sensitive, Windows and macOS are not", and everyone is wrong at least once a year:

| | Default | But also |
| --- | ------- | -------- |
| ext4 | sensitive | `casefold` feature since Linux 5.2, `chattr +F` on an empty directory |
| APFS / HFS+ | **insensitive**, preserving | can be formatted case-sensitive (APFSX) |
| NTFS | **insensitive**, preserving | per-directory flag since Win10 1803, `fsutil file setCaseSensitiveInfo` |
| exFAT / FAT32 | insensitive | preserving, mostly |
| SMB / NFS mount | inherits the *server*, not your kernel | often surprising |
| Docker bind mount on a Mac | insensitive | inside a Linux container. Yes, really. |

That last row deserves a moment. Your container says `Linux 6.x`. Your test suite passes. The filesystem underneath is your Mac's, and it folds case. You will discover this in production, on a machine whose volume does not.

So: never hardcode the answer. Ask.

### Preserving is not the same as insensitive

These are two independent properties, and mixing them up is where the data loss comes from.

Now put that behind an upload form. Two users, two rows in your database, `Photo.jpg` and `photo.jpg`, one file on disk. One of them is looking at the other's picture. On Linux, the same code produces two files and no incident, which is exactly why nobody caught it in review.

The reverse mismatch is just as common, and comes from the database side:

```
-- MySQL, utf8mb4_general_ci : case-insensitive by default
SELECT * FROM files WHERE name = 'Report.pdf'; -- also matches report.pdf
?>
[/php]

```

Your unique index says these are duplicates. Your Linux filesystem says they are two files. Somewhere between them, a row and a file stop pointing at each other.

### Renaming by case only

On Linux: two distinct names, a real rename, no drama. On a case-insensitive volume: source and destination resolve to the *same* file. Windows handles this and updates the stored spelling. Some network filesystems return success and change nothing. Some return an error. Git has an entire flag for this (`git mv --force`), which tells you how well it goes.

If you must, do it in two steps through a name that collides with nothing:

### Case folding is not strtolower()

The comparison is done by the filesystem, using a table the filesystem chose, and those tables are frozen in time.

- NTFS bakes an uppercase table into the volume at format time (`$UpCase`). It does not update when Unicode does.
- HFS+ folded using Unicode 3.2. From 2002.
- ext4's `casefold` uses whatever Unicode version the kernel shipped with.
- macOS, Windows and Linux therefore disagree about which pairs collide.

Which means your `strtolower()`-based uniqueness check and the filesystem's opinion are two different algorithms:

Do not implement folding. If you need a case-insensitive key, use `Normalizer::normalize()` followed by `mb_convert_case($s, MB_CASE_FOLD, 'UTF-8')`, store *that* as the uniqueness key alongside the original name, and let the filesystem have its own opinion in peace.

### The security half

This is where it stops being an annoyance.

**Extension blacklists**. A blacklist is a string comparison; the filesystem is not.

And the same trap one layer down, in the web server:

```
# Case-sensitive. shell.PHP walks straight past it.
<FilesMatch "\.php$">
SetHandler application/x-httpd-php

```

# What you meant:
<FilesMatch "(?i)\.php$">
?>
[/php]

```

```

Whitelist extensions, lowercase before comparing, and never let the handler config and your validator disagree about case.

**Path whitelists and containment**. Every string comparison in your security code is case-sensitive; the lookup it is protecting may not be.

On a case-insensitive volume, `/var/www/PUBLIC/x` and `/var/www/public/x` are one file with two spellings, and only one of them passes your check. Worse, `realpath()` does **not** reliably return the canonical on-disk spelling, do not rely on it to normalize case for you. On such a volume, fold both sides before comparing, or better, compare identity instead of strings:

(`ino` is meaningless on Windows in older PHP builds. Nothing is free.)

**Existence checks are not whitelists**. `file_exists('CONFIG.PHP')` returning true on Windows means "some file matched", not "the file you named exists".

### And the one that actually wakes you up

PSR-4 says the file must be `User.php`. Your Mac says both work. Composer's dev autoloader scans and finds it anyway. Then you deploy to Linux with `--optimize-autoloader`, the classmap holds the real filename, and:

```
Fatal error: Uncaught Error: Class "App\Models\User" not found
?>
[/php]

```

Nothing changed in the code. The filesystem simply stopped being polite. Run `composer dump-autoload --optimize --strict-psr` in CI, on a case-sensitive volume, and find out on a Tuesday instead of during a release.

## 7. The PHP-specific layer

### The null byte, may it rest in peace

Dead since 5.3.4, and a `ValueError` since PHP 8. But note *where* it dies: inside the filesystem function. Your own validation code, running earlier, may still be fooled by a string that PHP will later reject, or that you `substr()` before PHP ever sees it. Check for `"\0"` yourself, first.

### Every path is also a URL

This is the one that surprises people. Almost every function taking a "filename" also takes a stream wrapper:

`allow_url_fopen` and `allow_url_include` only close the network ones. `php://`, `data://`, `glob://` and the famous `phar://` stay. `phar://` is special: opening it unserializes the archive metadata, which turns a humble file-read into object injection. Any function. `file_exists()` counts.

A scheme is roughly `[a-zA-Z0-9+.-]{2,}://`: the two-character minimum is exactly why `C:/temp` is not mistaken for a wrapper. Small mercies.

Reject schemes explicitly:

### open_basedir compares prefixes, not directories

```
open_basedir = /var/www
?>
[/php]

```

This also allows `/var/www-backup`, `/var/www.old`, and `/var/wwwhatever`. It is a string prefix test.

```
open_basedir = /var/www/
?>
[/php]

```

The trailing slash is not decoration. It is the whole feature.

### The caches you forgot about

`realpath_cache_ttl` defaults to 120 seconds. Swap a symlink during a deploy and, for two minutes, PHP serves you the previous release with great confidence. Both caches are per-process, so half your workers agree and half do not. Debugging that is a character-building experience.

### realpath() returns false more often than you think

For a file you are about to create, resolve the *directory* and append the name afterwards.

### pathinfo() and its little surprises

The last one is not `null`. The key is simply absent. `??` is your friend.

### $_FILES is a hostile witness

Never build a storage path from any of these. Generate a name, store the original in the database, and sleep well.

## 8. Syntax is not semantics

You can validate a path perfectly and still open the wrong file.

**Symlinks**. A string confined to your directory can point anywhere. Only `realpath()` closes this; string normalization does not.

**TOCTOU**. Between the check and the open, the world moves:

In a world-writable directory, open first and inspect the handle (`fstat`), rather than the name.

**Zip Slip**. Archive entries are just strings, and nobody validated them for you:

**Permissions**. Traversal needs `+x` on every directory of the chain. A readable file inside a non-searchable directory is a readable file you cannot reach.

## 9. So, how do we build a path?

The only order that holds: **reject, normalize, resolve, verify containment**.

Note step 4. Without the appended separator, `/var/www-backup/secrets` passes a `str_starts_with('/var/www')` test with flying colours.

And for names you generate yourself, the portable intersection of all the rules above is small and boring, which is exactly what you want:

## 10. The actual conclusion: PHP needs a Path type

Look back at the last nine sections. Every single mitigation was userland. A regex here, a `realpath()` there, a `str_starts_with()` with a carefully appended separator. We wrote a function called `safePath()` and we will now copy it into the next project, where it will slowly diverge from this one.

That is the actual bug. Not `..`, not `NUL`, not NFD. The bug is that **a path has no type**.

Four strings. To PHP, and to every static analyser, and to every code reviewer skimming a diff at 18:30 on a Friday, they are the same type: `string`. Nothing in the language distinguishes an absolute path from a relative one, a resolved path from a lexical one, a filename from a URL, or text from a byte sequence that merely looks like text.

And so we get the signature that has been lying to us since PHP 3:

`string $filename`. It is not a filename. It is absolutely anything.

### What a native class would carry

The interesting part is not the string. It is the metadata that a string cannot hold:

Immutable value object. Every operation returns a new `Path`. The illegal states are simply not constructible, which is the whole point of having types in the first place.

Two details deserve to be first-class rather than bolted on:

- **Target platform**. `Path::posix()` and `Path::windows()` as explicit modes, so that a Linux CI box can correctly validate a name destined for an SMB share. Today, `basename()` guesses from the platform it happens to run on, and guesses wrong exactly when it matters. Case sensitivity belongs here too: it is a property of the *volume*, and only the engine can ask it.
- **Bytes, not text**. Rust got this right with `OsStr` versus `String`: a path is not a string that happens to contain characters, it is a sequence of bytes the kernel will hand back to you unchanged, invalid UTF-8 and all. A `Path` type should own that distinction instead of pretending filenames are prose.

### Everyone else already has one

| Language | Type |
| -------- | ---- |
| Python | `pathlib.Path`, since 3.4 |
| Java | `java.nio.file.Path`, since 7 |
| .NET | `System.IO.Path`, plus `FileInfo` |
| Rust | `Path` / `PathBuf`, and `OsStr` underneath |
| Go | `filepath`, at least a separate package with platform semantics |
| PHP | `string` |

PHP is a language whose primary job, for twenty-five years, has been to sit on a filesystem and serve files from it. It is the one that never grew the type.

### Why userland cannot finish the job

We do have good libraries. `symfony/filesystem` ships a `Path` class, Flysystem abstracts the whole layer, `webmozart/path-util` was there before them. They are worth using today. But they hit two walls that only the engine can climb.

**First, they cannot see the engine**. The realpath cache and its 120-second TTL, `open_basedir` and its prefix comparison, the list of registered stream wrappers, `sapi_windows_cp_get()`, the actual `NAME_MAX` of the mounted filesystem under this specific directory: none of that is reachable from userland with any precision. A userland `Path` validates against a hardcoded guess of the rules. The engine knows the rules.

**Second, and fatally, the boundary is a string**.

Every guarantee evaporates at that call. A value object whose only exit is `__toString()` is a comment with extra syntax. Which gives the actual requirement, and it is not "add a class":
> The core filesystem functions must accept the type.
`fopen(Path|string $filename, ...)`. `include` accepting a `Path`. `$_FILES[...]['name']` typed as something that is honestly labelled untrusted. Without that, a native `Path` is one more wrapper we lovingly construct and then unwrap before use.

Is this an easy RFC? No. The string-typed filesystem API is older than most of the people maintaining it, the BC surface is enormous, and "just use Flysystem" is a defensible answer for a large slice of applications. But `string $filename` is a 30-year-old accident that we keep paying for one CVE at a time, and each of those CVEs is a variation on one of the nine sections above.

Until then: keep `safePath()` in a shared package, not in a shared clipboard.

## In a nutshell

- Length limits count **bytes** on Linux, **UTF-16 units** elsewhere. Use `strlen()`, not `mb_strlen()`.
- Unix forbids two bytes. Windows forbids nine characters, plus a list of device names, plus trailing dots, plus ADS, plus short names.
- `\` is a separator on Windows and a valid character on Linux. `basename()` knows this. Attackers know it too.
- `..` is resolved lexically on Windows and against symlinks on Unix. These are different answers.
- PHP has no filename encoding. Normalize with `Normalizer`, and never trust that what you wrote is what you read back.
- Every "filename" parameter is also a stream wrapper. `phar://` unserializes.
- `open_basedir` is a prefix test. Trailing slash, always.
- The realpath cache lasts 120 seconds and will lie to you during a deploy.
- Case sensitivity is a property of the **volume**, not the OS. Preserving is not insensitive. Lowercase before every extension check, and never let a case-sensitive whitelist guard a case-insensitive lookup.
- Validation is not resolution. Only `realpath()` sees symlinks, and even that is racy.
- Best of all: do not accept paths. Accept an identifier, look the path up in a table, and hand all of the above to someone else.
- And every one of these bullets exists because PHP models a path as a `string`. A native, immutable `Path` type, accepted by the core filesystem functions, not merely stringified into them — would turn this checklist into a constructor.

A path is a string, yes. That is precisely the problem. It is a string with a filesystem attached, and the filesystem has been there much longer than your regex.