Cross-platform path handling in .NET: separators, absolute vs relative paths, the current directory, Path.Combine vs Path.Join, case sensitivity and long paths.
\ (and accepts /), Linux and macOS use only /. Never hard-code \; use Path.Combine/Path.Join or Path.DirectorySeparatorChar.C:\data\a.txt or /data/a.txt are absolute; data/a.txt is resolved against the current working directory, which differs between dotnet run, a service and a container. Base paths on AppContext.BaseDirectory or configuration.Path.Combine drops earlier parts if a later part is rooted (Combine("/app", "/etc/passwd") gives /etc/passwd). Path.Join just concatenates with one separator.Config.json vs config.json works on your laptop and fails in the Linux container.| Topic | Windows | Linux | macOS (default) |
|---|---|---|---|
| Separator | \ (also accepts /) |
/ only |
/ only |
| Root | C:\, \\server\share |
/ |
/ |
| Case sensitive | No (NTFS can enable per folder) | Yes | No |
| Invalid name chars | < > : " / \ | ? * and control chars |
/ and NUL |
/ and NUL (: awkward in Finder) |
| Reserved names | CON, PRN, AUX, NUL, COM1β¦ |
none | none |
File.ReadAllText("appsettings.json") resolves against Environment.CurrentDirectory:
dotnet run from the project folder β the project folder.C:\Windows\System32.WORKDIR from the Dockerfile.Use AppContext.BaseDirectory for files deployed with the app, IHostEnvironment.ContentRootPath in ASP.NET Core, and configuration for data folders.
Path.Combine("/app/data", "reports", "a.csv"); // /app/data/reports/a.csv
Path.Combine("/app/data", "/etc/passwd"); // /etc/passwd β rooted part wins
Path.Join("/app/data", "/etc/passwd"); // /app/data//etc/passwd
Path.GetFullPath("reports/../a.csv", "/app/data"); // /app/data/a.csv
Path.Combine is fine for trusted segments. Neither method is a security check; for user input see path traversal protection.
A team developed on Windows and loaded templates with File.ReadAllText($"Templates\\{name}.html"). In the Linux container it failed: the backslash became part of the file name, and the folder on disk was templates in lowercase. The fix was Path.Join(AppContext.BaseDirectory, "Templates", $"{name}.html"), renaming the folder consistently, and running the test suite in a Linux container in CI so case and separator issues show up before release.
Practical cross-platform awareness. Many teams now develop on Windows/macOS and deploy to Linux containers.
Green flags
AppContext.BaseDirectory / ContentRootPath.Path.Combine rooted-argument behaviour and Path.Join.Red flags
Path.Combine prevents directory traversal.Windows: C:\Users\sam\app\data\report.csv
β βββββ directories ββββββ file name β
β drive root extension βββ
UNC: \\fileserver\share\data\report.csv
Linux: /home/sam/app/data/report.csv
β root
Path.GetFullPath resolves . and .., fixes separators on Windows and makes the path absolute (against the current directory or a base you pass).
"/app/data/./reports/../a.csv" ββGetFullPathβββΊ "/app/data/a.csv"
It does not touch the disk, so it does not resolve symbolic links and does not check existence.
| Member | Example result |
|---|---|
Path.GetFileName("/a/b/c.txt") |
c.txt |
Path.GetFileNameWithoutExtension |
c |
Path.GetExtension |
.txt |
Path.GetDirectoryName |
/a/b |
Path.IsPathRooted("/x") |
true |
Path.IsPathFullyQualified("C:x") |
false on Windows (drive-relative) |
Path.GetRelativePath("/a", "/a/b/c") |
b/c |
Path.GetTempPath() / Path.GetRandomFileName() |
temp folder / random safe name |
Path.GetInvalidFileNameChars() |
platform-specific list |
Dev laptop (Windows/macOS): Config.json == config.json β
Linux container: Config.json != config.json β FileNotFoundException
The same applies to <Content Include="..."> items, static files served by ASP.NET Core and import paths in front-end bundles. Git on Windows/macOS may also not notice a rename that only changes case; use git mv Config.json config.json.
Windows historically limited paths to 260 characters (MAX_PATH). Modern .NET handles longer paths on Windows, but other tools in your pipeline (old installers, some zip tools, Explorer in some cases) may not. Deep node_modules or nested build output are the usual triggers. Linux allows 4,096 bytes for a path and 255 bytes per name.
Environment.CurrentDirectory and AppContext.BaseDirectory, then run it with dotnet run and with dotnet bin/Debug/net10.0/App.dll from another folder.Path.Combine("/app", "/etc/hosts") and Path.Join("/app", "/etc/hosts").docker run --rm -v $PWD:/src -w /src mcr.microsoft.com/dotnet/sdk:10.0 dotnet test to catch case-sensitivity bugs locally.// Files shipped with the app: resolve from the app's folder, not the working directory
string templatePath = Path.Join(AppContext.BaseDirectory, "Templates", "welcome.html");
string html = await File.ReadAllTextAsync(templatePath, ct);
// Data folders: from configuration, normalised once at startup
var dataRoot = Path.GetFullPath(builder.Configuration["Storage:Root"] ?? "/var/lib/myapp");
Directory.CreateDirectory(dataRoot);
// Building a safe file name from user-provided text
static string ToSafeFileName(string input)
{
var invalid = Path.GetInvalidFileNameChars();
var chars = input.Select(c => invalid.Contains(c) ? '_' : c).ToArray();
return new string(chars).Trim();
}
// Path.Combine vs Path.Join
Console.WriteLine(Path.Combine("/app/data", "reports", "2026.csv")); // /app/data/reports/2026.csv
Console.WriteLine(Path.Combine("/app/data", "/etc/passwd")); // /etc/passwd
Console.WriteLine(Path.Join("/app/data", "/etc/passwd")); // /app/data//etc/passwd
Console.WriteLine(Path.GetRelativePath("/app", "/app/data/a.csv")); // data/a.csv
Mistake: Hard-coding \\ in paths.
Why it is wrong: On Linux the backslash is a valid file name character, so "Templates\\a.html" looks for one file literally named Templates\a.html.
Instead: Use Path.Join or Path.Combine, or Path.DirectorySeparatorChar when formatting by hand.
Mistake: Relying on the current working directory.
Why it is wrong: It changes between dotnet run, services, containers and test runners, so the same relative path points at different places.
Instead: Anchor paths on AppContext.BaseDirectory, ContentRootPath or a configured absolute root.
Mistake: Assuming Path.Combine keeps you inside the base folder.
Why it is wrong: A rooted later argument replaces everything before it, and .. segments can climb out.
Instead: Normalise with Path.GetFullPath and check the result still starts with the root (plus a separator), or map IDs to stored paths instead of accepting user paths.
Mistake: Testing only on the developer's OS. Why it is wrong: Case sensitivity and separator bugs never appear on Windows or macOS, so they ship and fail in the Linux container. Instead: Add a Linux job to the CI pipeline (and a Windows one if you ship there) and run file-path tests on both.
Mistake: Comparing paths with ==.
Why it is wrong: C:\Data\a.txt and c:/data/./a.txt are the same file on Windows but different strings.
Instead: Normalise with Path.GetFullPath and compare with the platform's case rules.
Most path bugs are environment bugs: code that works on a Windows laptop and breaks in a Linux container. I prevent them structurally rather than by being careful each time.
| Practice | What it prevents | Cost |
|---|---|---|
One place that defines roots (configuration or AppContext.BaseDirectory) |
Working-directory surprises | Tiny |
Only Path APIs to build paths |
Separator bugs | None |
| Lowercase, hyphenated file names | Case mismatches between dev and prod | A naming convention |
| Linux and Windows CI jobs | Platform-only failures | Extra CI minutes |
| Startup check that required files exist | Silent failures on first request | A few lines of code |
| Normalise plus root-prefix check for user input | Path traversal | A small helper |
Security and stability: Path.Combine and Path.GetFullPath are string operations, not security boundaries. For anything built from user input I normalise, check the result is still under the intended root, and prefer mapping opaque IDs to stored paths. On case-insensitive systems I also remember that two different-looking names can be the same file, which matters for allow-lists and caches keyed by path.
It is a path traversal vulnerability. file=../../../etc/passwd resolves outside the reports folder, and file=/etc/passwd is worse: Path.Combine discards the base path when the second argument is rooted. The endpoint also has no authorisation and leaks whether files exist.
Fix: do not let users name files. Map an ID to a stored path (database), or if names are required, take only Path.GetFileName(file), build the path, Path.GetFullPath it and check it starts with the root plus a separator, using the platform's case rules. Serve through PhysicalFileProvider or Results.File with the checked path, and add authorisation.
rwx bits for owner, group and others (for example 0644, 0755). On a directory, x means "can enter" and w means "can create, delete or rename entries". Root bypasses most checks.The container error usually means the app runs as a non-root user (the official .NET 10 images define user app, UID 1654, via $APP_UID) while the mounted folder is owned by root with 0755. Fix it by giving that UID or a shared group write access (chown, Kubernetes fsGroup, or volume mount options), not by running the container as root or using chmod 777.
How to tell it (STAR)
**S:** We moved our [email service] from Windows VMs to Linux containers, and every email started failing. **T:** I had to restore emails and stop it happening again. **A:** I logged the resolved path and saw the backslash had become part of the file name, and the folder case did not match. I rolled back, switched to `Path.Join` with `AppContext.BaseDirectory`, normalised file names, and added a Linux test job plus a startup check. **R:** Emails were back within [an hour], and since then we have had [no] platform-specific path bugs reach production.
Last updated 23 Oct 2026.