Test files › Folders and paths › case-collision-dirs.zip
Sample ZIP file (case collision dirs)
Two sibling directories whose names differ only in case, each holding a different file.
↓ Download case-collision-dirs.zip| Filename | case-collision-dirs.zip |
| Format | ZIP |
| Size | 486 bytes (486 bytes) |
| MIME type | application/zip |
| SHA-256 | 13822c10204f1f63c8e33ef43df20210725cefbca65754e8fe36c0bab0e9141e |
What this file catchesCase sensitivity, at the directory level rather than the file level. On Linux you get two directories and two files. On Windows and default macOS the two merge into one directory holding both files, so a build that works on a developer laptop and fails in CI (or the reverse) has usually hit exactly this.
Handle it in code
Each snippet below was run against this exact file with Python 3.14.4 and Node 24.15.0 when this page was built, and the output shown is what it printed.
Python
Check an archive's paths before extracting it on another system. Windows reserves device names, drops trailing dots and spaces, and treats \ as a separator; Windows and macOS compare names without case, so two folders that differ only in case merge. Use orig_filename: on Windows, zipfile quietly turns backslashes into slashes.
import unicodedata
import zipfile
RESERVED = {"CON", "PRN", "AUX", "NUL"} | {f"{d}{i}" for d in ("COM", "LPT") for i in range(1, 10)}
LONG = 200 # leaves room for the folder it is extracted into, under Windows' classic 260
def problems(names):
paths = {} # every entry and every parent folder, in order
for name in names:
parts = name.rstrip("/").split("/")
for i in range(len(parts)):
paths.setdefault("/".join(parts[:i + 1]), parts[i])
for path, part in paths.items():
if "\\" in part:
yield f"{path!r}: a backslash is a folder separator on Windows"
if part in ("", "."):
yield f"{path!r}: empty or '.' segment"
elif part.split(".")[0].upper() in RESERVED:
yield f"{path!r}: {part} is a reserved device name on Windows"
elif part != part.rstrip(". "):
yield f"{path!r}: ends in a dot or space, which Windows drops"
if unicodedata.normalize("NFC", part) != part:
yield f"{ascii(path)}: not NFC-normalised, so it may not match the same name typed elsewhere"
groups = {}
for path in paths:
groups.setdefault(unicodedata.normalize("NFC", path).casefold(), []).append(path)
for same in groups.values():
if len(same) > 1:
yield " and ".join(map(repr, same)) + ": the same path on Windows and macOS"
longest = max(names, key=len, default="")
if len(longest) > LONG:
yield f"longest entry is {len(longest)} characters: extracted into any folder, it passes the 260-character limit"
with zipfile.ZipFile("case-collision-dirs.zip") as z:
names = [info.orig_filename for info in z.infolist()]
found = list(problems(names))
print(f"{len(names)} entries: " + (f"{len(found)} portability problem{'s' * (len(found) != 1)}" if found else "portable"))
for p in found:
print(" " + p)Run against this file, it prints: 4 entries: 1 portability problem
'Docs' and 'docs': the same path on Windows and macOS
Related guides and tools
Download & verify
curl -fsSL -o "case-collision-dirs.zip" "https://files.hexaqa.com/folder/case-collision-dirs.zip"
curl -fsSL "https://files.hexaqa.com/folder/case-collision-dirs.zip" | sha256sum
# expect: 13822c10204f1f63c8e33ef43df20210725cefbca65754e8fe36c0bab0e9141e