Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 19 – Files, JSON, XML, dates, databases and HTTP

Files, paths and safe file access

Read and write files in C#, build paths that work on every OS, list folders, save without half-written files and keep user file names inside their folder.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Read and write whole files with File, and large ones with File.ReadLines, FileStream and RandomAccess
  • Build paths with Path.Join and Path.GetFullPath, and list folders with EnumerationOptions
  • Prevent path traversal by resolving a user's file name and checking that it stays inside its folder
  • Save a file atomically by writing a temporary file and moving it over the old one

Before you start

What you’ll learn · Before you start

What you will learn

  • Read and write whole files with File, and large ones with File.ReadLines, FileStream and RandomAccess
  • Build paths with Path.Join and Path.GetFullPath, and list folders with EnumerationOptions
  • Prevent path traversal by resolving a user's file name and checking that it stays inside its folder
  • Save a file atomically by writing a temporary file and moving it over the old one

Before you start

On this page

A download handler that opened Path.Combine(uploads, name) gave a user the contents of secrets.txt, a file next to its folder, when the name was ../secrets.txt, and it would have opened /etc/hosts just as readily. Seven lines that resolve the name before using it refused both, and this lesson runs them against four names.

Around that guard you will read and write files with the File class and with streams, build paths that work on Windows, macOS and Linux, list folders without surprises, and save a settings file so that a crash never leaves half of it on the disk.

The File class reads or writes a whole file in one call

files.cs writes a small CSV of stations and reads it back, searches a log of 100,000 lines in two ways, then writes through a stream and reads at an offset:

files.cs: whole files, lines one at a time, a stream and a read at an offset C# · whole-files/files.cs
// The File class reads and writes a whole file in one call; File.ReadLines and FileStream work through it piece by
// piece. Every file goes into the folder "data", next to this program.
using System.Text;

Directory.CreateDirectory("data");
string stations = Path.Join("data", "stations.csv");

// 1. A whole file in one call each way.
File.WriteAllText(stations, "code,name\nPUNE,Pune Junction\nNDLS,New Delhi\nMAS,Chennai Central\n");
string[] rows = File.ReadAllLines(stations);
Console.WriteLine($"WriteAllText wrote {new FileInfo(stations).Length} bytes; ReadAllLines gave {rows.Length} lines, the last \"{rows[^1]}\"");

// 2. The text encoding: UTF-8 without a byte order mark unless you ask for one.
File.WriteAllText(Path.Join("data", "plain.txt"), "₹");
File.WriteAllText(Path.Join("data", "marked.txt"), "₹", Encoding.UTF8);
Console.WriteLine($"\"₹\" as written by default: {Hex("plain.txt")}; with Encoding.UTF8: {Hex("marked.txt")}");

// 3. A log of 100,000 lines, searched two ways for the line of one train.
string log = Path.Join("data", "trains.log");
File.WriteAllLines(log, Enumerable.Range(1, 100_000).Select(i => $"{i:D6} train {12000 + i} on time"));
Measure("ReadAllLines", () => File.ReadAllLines(log).First(line => line.Contains("12042")));
Measure("ReadLines", () => File.ReadLines(log).First(line => line.Contains("12042")));

// 4. A FileStream opened for asynchronous I/O, and a read at an offset with RandomAccess.
await using (var stream = new FileStream(Path.Join("data", "counts.bin"), FileMode.Create, FileAccess.Write,
                 FileShare.None, bufferSize: 4096, FileOptions.Asynchronous))
{
    await stream.WriteAsync(new byte[] { 10, 20, 30, 40, 50 });
    Console.WriteLine($"FileStream.IsAsync: {stream.IsAsync}, wrote {stream.Position} bytes");
}
using (var handle = File.OpenHandle(Path.Join("data", "counts.bin")))
{
    byte[] two = new byte[2];
    int read = RandomAccess.Read(handle, two, fileOffset: 3);
    Console.WriteLine($"RandomAccess read {read} bytes at offset 3: {two[0]} {two[1]} (file length {RandomAccess.GetLength(handle)})");
}

static string Hex(string name) => string.Join(" ", File.ReadAllBytes(Path.Join("data", name)).Select(b => b.ToString("X2")));

static void Measure(string name, Func<string> find)
{
    find(); // once first, so that only the search itself is measured
    long before = GC.GetAllocatedBytesForCurrentThread();
    string found = find();
    double mb = (GC.GetAllocatedBytesForCurrentThread() - before) / 1_000_000.0;
    Console.WriteLine($"{name,-12} found \"{found}\" and allocated {mb:F1} MB");
}

Output

WriteAllText wrote 64 bytes; ReadAllLines gave 4 lines, the last "MAS,Chennai Central"
"₹" as written by default: E2 82 B9; with Encoding.UTF8: EF BB BF E2 82 B9
ReadAllLines found "000042 train 12042 on time" and allocated 10.9 MB
ReadLines    found "000042 train 12042 on time" and allocated 0.0 MB
FileStream.IsAsync: True, wrote 5 bytes
RandomAccess read 2 bytes at offset 3: 40 50 (file length 5)

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run files.cs

File.WriteAllText created the file, wrote 64 bytes and closed it; File.ReadAllLines gave back its 4 lines. These methods are the simplest way to handle a file that fits easily in memory. Microsoft’s reference says WriteAllText writes UTF-8 without a byte order mark: An optional marker at the start of a text file that names its Unicode encoding: the bytes EF BB BF for UTF-8. File.WriteAllText writes UTF-8 without it unless the encoding asks for it. More in “Unicode, UTF-8 and text elements”, and the run agrees: the rupee sign became the three bytes E2 82 B9, and only Encoding.UTF8 put EF BB BF in front of them.

The log shows what reading everything costs. To find line 42, File.ReadAllLines allocated 10.9 MB: all 100,000 lines and the array that holds them. File.ReadLines hands out the lines one at a time as a loop asks for them, so First stopped at line 42 and the search allocated 0.0 MB.

Below the line-based methods sit streams. A FileStream reads and writes bytes in pieces of the size you choose, and its WriteAsync lets the calling thread carry on while the bytes are written. FileOptions.Asynchronous, which set IsAsync to True here, asks Windows for its own asynchronous file I/O; on Linux and macOS, .NET runs asynchronous file writes on a thread-pool thread instead. RandomAccess reads at an offset you name without moving any position, which its documentation describes as thread-safe: it read 40 and 50 from offset 3. The table sums up which to use.

API What it reads Use it for
File.ReadAllText, ReadAllLines, ReadAllBytes the whole file, in one call settings, small CSV and JSON files
File.ReadLines one line per pass of a loop logs and large text files, read from the top
FileStream, File.OpenRead bytes, in pieces binary formats, async I/O, streams stacked on top
RandomAccess, File.OpenHandle bytes at a given offset fixed-size records, several readers at once

Reading a file that keeps growing in one call

File.ReadAllText on a log or an export holds the whole file in memory at once, and an export that is small today will not stay small. Read such files with File.ReadLines or a stream, and keep the whole-file methods for files whose size you control.

Path.Join keeps every part, Path.Combine lets a rooted part replace the rest

A path is text, and the same parts can make different paths depending on the method that joins them. paths.cs joins a folder and a file name four ways and prints what each method returns:

paths.cs: Combine, Join and GetFullPath on the same parts C# · paths/paths.cs
// Building paths: what Path.Combine, Path.Join and Path.GetFullPath return for the same parts. Paths inside this
// program's folder are printed relative to it.
string here = Path.GetFullPath(".");
string Show(string path) => path.StartsWith(here + "/", StringComparison.Ordinal) ? path[(here.Length + 1)..] : path;

Console.WriteLine($"separator '{Path.DirectorySeparatorChar}', alternative '{Path.AltDirectorySeparatorChar}', in PATH '{Path.PathSeparator}'");

(string Folder, string Name)[] cases = [("uploads", "report.pdf"), ("uploads", "/etc/hosts"), ("uploads", "../app.config"), ("uploads/", "/docs/a.txt")];
foreach (var (folder, name) in cases)
{
    Console.WriteLine($"\"{folder}\" + \"{name}\"");
    Console.WriteLine($"  Combine:     {Path.Combine(folder, name)}");
    Console.WriteLine($"  Join:        {Path.Join(folder, name)}");
    Console.WriteLine($"  GetFullPath: {Show(Path.GetFullPath(Path.Join(folder, name)))}");
}

// GetFullPath with a base folder of your own: the result never depends on the current directory.
string root = Path.GetFullPath("uploads");
Console.WriteLine($"GetFullPath(\"a/./b/../c.txt\", root) = {Show(Path.GetFullPath("a/./b/../c.txt", root))}");
Console.WriteLine($"GetRelativePath(root, its parent's app.config) = {Path.GetRelativePath(root, Path.GetFullPath("app.config"))}");

// A backslash is a separator on Windows only; here it is part of a file name.
Console.WriteLine($"GetFileName(\"..\\\\notes.txt\") = {Path.GetFileName("..\\notes.txt")}");
Console.WriteLine($"Characters a file name may not contain here: {Path.GetInvalidFileNameChars().Length} (codes {string.Join(", ", Path.GetInvalidFileNameChars().Select(c => (int)c))})");

// The file system decides whether case matters: one file, asked for in another case.
Directory.CreateDirectory("uploads");
File.WriteAllText(Path.Join("uploads", "report.pdf"), "demo");
Console.WriteLine($"File.Exists(\"uploads/REPORT.PDF\") on this Mac: {File.Exists(Path.Join("uploads", "REPORT.PDF"))}");

Output

separator '/', alternative '/', in PATH ':'
"uploads" + "report.pdf"
  Combine:     uploads/report.pdf
  Join:        uploads/report.pdf
  GetFullPath: uploads/report.pdf
"uploads" + "/etc/hosts"
  Combine:     /etc/hosts
  Join:        uploads/etc/hosts
  GetFullPath: uploads/etc/hosts
"uploads" + "../app.config"
  Combine:     uploads/../app.config
  Join:        uploads/../app.config
  GetFullPath: app.config
"uploads/" + "/docs/a.txt"
  Combine:     /docs/a.txt
  Join:        uploads//docs/a.txt
  GetFullPath: uploads/docs/a.txt
GetFullPath("a/./b/../c.txt", root) = uploads/a/c.txt
GetRelativePath(root, its parent's app.config) = ../app.config
GetFileName("..\\notes.txt") = ..\notes.txt
Characters a file name may not contain here: 2 (codes 0, 47)
File.Exists("uploads/REPORT.PDF") on this Mac: True

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run paths.cs

Path.Combine("uploads", "/etc/hosts") returned /etc/hosts. When a later argument is rooted, Combine ignores every part before it, and its documentation tells you to call Path.Join instead whenever a later part comes from a user. Path.Join only adds a separator: it returned uploads/etc/hosts.

Neither method removes ..: both kept uploads/../app.config as it was. Path.GetFullPath resolves . and .. and makes a path absolute. With one argument it starts from the current directory, which can change while a program runs; with a base folder of your own, GetFullPath("a/./b/../c.txt", root) gave the full path of uploads/a/c.txt whatever the current directory was (paths.cs prints the paths inside its own folder relative to it). The file need not exist for either call.

The separator here is /. On Windows it is \, with / accepted too, so build paths with Path.Join instead of typing separators into strings. On Linux and macOS a backslash is an ordinary character of a file name: GetFileName returned ..\notes.txt whole. Only two characters may not appear in a file name here, code 0 and /, so Path.GetInvalidFileNameChars is no filter for unsafe names.

Whether case matters is decided by the file system, not by .NET. File.Exists found REPORT.PDF on this Mac, whose file system ignores case. Apple’s Disk Utility guide lists a case-sensitive APFS as a format of its own, and on such a volume, or any file system that compares names exactly, the same call returns False. Code that opens only the exact names it wrote works on all of them.

Checkpoint

Check: Whole files, lines and streams · Join, Combine and GetFullPath

  1. Question 1 of 2 A 2 GB log must be searched for the first line that mentions one PNR. Which call keeps the memory small?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: File.ReadLines(path).First(line => line.Contains(pnr))

    Only File.ReadLines reads lazily: the loop, or First, pulls one line at a time and stops reading when it stops asking. The whole-file methods load everything first.

  2. Question 2 of 2 On Linux, what does Path.Combine("/srv/uploads", name) return when a user sends the name "/etc/hosts"?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: /etc/hosts

    Path.Combine assumes that every part after the first is relative; when one is rooted, the result starts there. Use Path.Join for parts you build, and resolve-and-check for parts a user sends.

EnumerateFiles hands out names one at a time, and its options decide what it skips

listing.cs builds a small tree: a hidden .cache, two CSV files whose extensions differ in case, a note, and an archive folder two levels deep. Then it lists the tree with different options:

listing.cs: EnumerateFiles with and without EnumerationOptions C# · listing/listing.cs
// Listing a folder: Directory.EnumerateFiles with and without EnumerationOptions, on a tree this program makes.
// The file system returns names in no promised order, so every list is sorted before it is printed.
string root = "records";
foreach (string file in new[] { "a.csv", "B.CSV", ".cache", "notes.txt", "archive/q1.csv", "archive/q2.csv", "archive/old/q4.csv" })
{
    string path = Path.Join(root, file);
    Directory.CreateDirectory(Path.GetDirectoryName(path)!);
    File.WriteAllText(path, "x");
}

string List(IEnumerable<string> paths) => string.Join(", ", paths.Select(p => Path.GetRelativePath(root, p)).Order(StringComparer.Ordinal));

Console.WriteLine($"attributes of .cache: {File.GetAttributes(Path.Join(root, ".cache"))}; of notes.txt: {File.GetAttributes(Path.Join(root, "notes.txt"))}");
var defaults = new EnumerationOptions();
Console.WriteLine($"new EnumerationOptions(): recurse {defaults.RecurseSubdirectories}, skip {defaults.AttributesToSkip}, ignore inaccessible {defaults.IgnoreInaccessible}, casing {defaults.MatchCasing}");
Console.WriteLine($"EnumerateFiles(root)                 {List(Directory.EnumerateFiles(root))}");
Console.WriteLine($"EnumerateFiles(root, \"*\", options)   {List(Directory.EnumerateFiles(root, "*", defaults))}");
Console.WriteLine($"\"*.csv\", default options             {List(Directory.EnumerateFiles(root, "*.csv", defaults))}");
Console.WriteLine($"\"*.csv\", CaseSensitive               {List(Directory.EnumerateFiles(root, "*.csv", new EnumerationOptions { MatchCasing = MatchCasing.CaseSensitive }))}");
Console.WriteLine($"\"*.csv\", RecurseSubdirectories       {List(Directory.EnumerateFiles(root, "*.csv", new EnumerationOptions { RecurseSubdirectories = true }))}");
Console.WriteLine($"\"*.csv\", recurse, MaxRecursionDepth 1 {List(Directory.EnumerateFiles(root, "*.csv", new EnumerationOptions { RecurseSubdirectories = true, MaxRecursionDepth = 1 }))}");

// EnumerateFiles hands names over one at a time; GetFiles builds the whole array first.
using IEnumerator<string> names = Directory.EnumerateFiles(root, "*", new EnumerationOptions { RecurseSubdirectories = true }).GetEnumerator();
names.MoveNext();
Console.WriteLine($"EnumerateFiles gave its first name before reading the rest; GetFiles returned {Directory.GetFiles(root, "*", new EnumerationOptions { RecurseSubdirectories = true }).Length} at once");

Output

attributes of .cache: Hidden; of notes.txt: Normal
new EnumerationOptions(): recurse False, skip Hidden, System, ignore inaccessible True, casing PlatformDefault
EnumerateFiles(root)                 .cache, B.CSV, a.csv, notes.txt
EnumerateFiles(root, "*", options)   B.CSV, a.csv, notes.txt
"*.csv", default options             B.CSV, a.csv
"*.csv", CaseSensitive               a.csv
"*.csv", RecurseSubdirectories       B.CSV, a.csv, archive/old/q4.csv, archive/q1.csv, archive/q2.csv
"*.csv", recurse, MaxRecursionDepth 1 B.CSV, a.csv, archive/q1.csv, archive/q2.csv
EnumerateFiles gave its first name before reading the rest; GetFiles returned 6 at once

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run listing.cs

File.GetAttributes reported .cache as Hidden, because .NET treats a name that starts with a dot as hidden on Linux and macOS. Directory.EnumerateFiles(root) listed it anyway, but given a new EnumerationOptions the same call skipped it, since the options skip hidden and system files by default. They also skip folders the program may not read, with IgnoreInaccessible, instead of throwing in the middle of the listing.

MatchCasing.PlatformDefault let *.csv match B.CSV on this Mac, and CaseSensitive matched a.csv alone. RecurseSubdirectories walked into the archive, and MaxRecursionDepth = 1 stopped one level down, before archive/old. Every list is sorted before it is printed, because the file system returns names in no promised order.

EnumerateFiles hands out names while it reads the folder, so a loop can start at once and stop early; GetFiles fills a whole array first, 6 names here and perhaps millions in a real archive. Microsoft’s reference gives the same advice for large folders: prefer the enumerating method.

Watching a folder

FileSystemWatcher raises events when files change, but its documentation warns that its buffer can overflow when many changes arrive at once, and that one move can raise several events. Treat its events as a hint to list the folder again with EnumerateFiles, never as the list itself.

Checkpoint

Check: Listing folders with EnumerationOptions

  1. One question A folder on a Mac holds .env, a.txt and B.TXT. Which names does Directory.EnumerateFiles(folder, "*", new EnumerationOptions()) return?

    Choose one answer.

    Show the answer: One question

    Answer: a.txt and B.TXT

    EnumerationOptions skips hidden and system files by default, and on Linux and macOS a name that starts with a dot counts as hidden. The overload without options does not skip them.

A file name from a user can leave its folder unless you resolve it first

path traversal: A weakness in which a file name from outside the program, such as ../secrets.txt or an absolute path, makes it read or write a file outside the folder it meant to use. is the weakness of the handler at the top of this lesson: a name such as ../secrets.txt or an absolute path makes the program open a file outside the folder it meant to use. guard.cs creates uploads, a report inside it and secrets.txt next to it, then tries four names with Path.Combine and with a guard:

guard.cs: four names from users, Path.Combine against the guard C# · guard/guard.cs
// A download handler gets a file name from a user. Path.Combine alone lets the name leave the folder; the guard
// resolves the name first and refuses anything that does not stay inside. The program makes "uploads", one report
// in it and a file next to it, secrets.txt, that no user may read.
Directory.CreateDirectory("uploads");
File.WriteAllText(Path.Join("uploads", "report.pdf"), "%PDF demo");
File.WriteAllText("secrets.txt", "keys of the payment gateway");
string uploads = Path.GetFullPath("uploads");
string here = Path.GetFullPath(".");
string Show(string path) => path.StartsWith(here + "/", StringComparison.Ordinal) ? path[(here.Length + 1)..] : path;

foreach (string name in new[] { "report.pdf", "../secrets.txt", "sub/../../secrets.txt", "/etc/hosts" })
{
    string naive = Path.GetFullPath(Path.Combine(uploads, name));
    string read = naive.StartsWith(here + "/", StringComparison.Ordinal) && File.Exists(naive)
        ? $"and reads \"{File.ReadAllText(naive)}\""
        : "(outside this folder: not read here)";
    string verdict = TryResolve(uploads, name, out string? safe) ? $"allowed: {Show(safe!)}" : "refused";
    Console.WriteLine($"{name,-22} Combine opens {Show(naive)} {read}");
    Console.WriteLine($"{"",-22} guard: {verdict}");
}

// The guard compares names only. A symbolic link inside the folder can still point outside it.
File.CreateSymbolicLink(Path.Join("uploads", "notes.txt"), Path.Join("..", "secrets.txt"));
Console.WriteLine($"notes.txt (a link)     guard: {(TryResolve(uploads, "notes.txt", out string? link) ? "allowed" : "refused")}, but reading it gives \"{File.ReadAllText(link!)}\"");
Console.WriteLine($"with the link check    guard: {(TryResolveNoLinks(uploads, "notes.txt", out _) ? "allowed" : "refused")}");

// Resolves name inside baseDir. True only when the full path is baseDir plus a separator plus more.
static bool TryResolve(string baseDir, string name, out string? fullPath)
{
    fullPath = null;
    if (string.IsNullOrEmpty(name) || name.Contains('\0')) return false;
    string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(baseDir)) + Path.DirectorySeparatorChar;
    string candidate = Path.GetFullPath(name, root);
    if (!candidate.StartsWith(root, StringComparison.Ordinal)) return false;
    fullPath = candidate;
    return true;
}

// The same, and the file must not be a symbolic link.
static bool TryResolveNoLinks(string baseDir, string name, out string? fullPath) =>
    TryResolve(baseDir, name, out fullPath) && new FileInfo(fullPath!).LinkTarget is null;

Output

report.pdf             Combine opens uploads/report.pdf and reads "%PDF demo"
                       guard: allowed: uploads/report.pdf
../secrets.txt         Combine opens secrets.txt and reads "keys of the payment gateway"
                       guard: refused
sub/../../secrets.txt  Combine opens secrets.txt and reads "keys of the payment gateway"
                       guard: refused
/etc/hosts             Combine opens /etc/hosts (outside this folder: not read here)
                       guard: refused
notes.txt (a link)     guard: allowed, but reading it gives "keys of the payment gateway"
with the link check    guard: refused

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run guard.cs

Path.Combine opened secrets.txt for two of the names and pointed at /etc/hosts for the third. The guard, TryResolve, does two things. It resolves the name inside the folder with Path.GetFullPath(name, root), which removes every . and .. part and resolves a rooted name on its own, ignoring the folder. Then it requires the result to start with the folder’s own full path plus a separator. Step through the four names, and a fifth that is a link:

Interactive · stage by stage The guard of guard.cs on five names Step through the checks in the order guard.cs makes them. Each name either reaches the last box or is refused at the first check it fails.
The guard of guard.cs on five names, step 6 of 6. Showing A name from a user (notes.txt), GetFullPath(name, root) (uploads/notes.txt), Starts with root + '/'? (yes), Not a symbolic link? (no: a link, refused), Open the file (not opened). In focus: Not a symbolic link?.A name from a usernotes.txtGetFullPath(name, root)uploads/notes.txtStarts with root + '/'?yesNot a symbolic link?no: a link, refusedOpen the filenot opened

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. report.pdf. The first name is report.pdf. GetFullPath resolves it inside the folder to uploads/report.pdf, which starts with the folder plus a separator, so the guard allows it.
  2. Inside the folder. The full path starts with the full path of uploads plus a slash, it is not a link, and the file is opened. Of the five names, report.pdf is the only one that gets this far.
  3. ../secrets.txt. GetFullPath removes the .. and the folder before it, so the name resolves to secrets.txt in the parent folder. The check sees that it does not start with uploads/ and refuses it.
  4. sub/../../secrets.txt. A longer way to the same file: down into sub, then up twice. Resolved, it is the same secrets.txt, and the same check refuses it, without the guard ever looking for dots.
  5. /etc/hosts. A rooted name is resolved on its own, ignoring the folder, so the result is /etc/hosts. It does not start with uploads/ either, so it is refused before any file is opened.
  6. notes.txt, a link. notes.txt resolves to uploads/notes.txt and passes the name check, but the file is a symbolic link to ../secrets.txt. Only the link check of TryResolveNoLinks, LinkTarget is not null, refuses it.

The separator matters: without it, /srv/uploads-old/x.txt starts with /srv/uploads and would pass. The comparison is ordinal, character by character, on purpose. A comparison that ignored case could accept a folder that only looks the same on a case-sensitive file system, while an ordinal comparison can at worst refuse a good name. And the check reads names, not the disk: notes.txt is a symbolic link: A file-system entry that points to another path. Opening the link opens its target, which can lie outside the folder that holds the link. to ../secrets.txt, so the name passed, while TryResolveNoLinks, which also requires LinkTarget to be null, refused it. That check looks at the file itself; a folder on the way can be a link too, which is one more reason to let nobody create links inside the folder.

Better than any check: keep the user's name out of the path

OWASP’s guidance is to avoid user input in file-system calls where you can. Store an upload under a name you generate, such as a Guid, keep the name the user gave as data, allow only the extensions you need and limit the size. The guard above is then a second line of defence, not the only one.

The lab at the end of this lesson puts both lines of defence into one class: a strict rule for names, extensions and sizes first, then the guard for every name that passes it.

Key idea

Resolve first, compare second: Path.GetFullPath(name, root), then an ordinal StartsWith against the folder’s full path plus a separator. Searching the raw string for .. misses absolute paths, and no string check sees links.

File Safety Inspector See what a file really is, from its own bytes, before trusting its extension.

Checkpoint

Check: Keeping a user's file name in its folder

  1. Question 1 of 2 The guard resolves the name "docs/../../app.config" inside /srv/uploads/. What does it decide, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Refused: it resolves to /srv/app.config, which does not start with /srv/uploads/

    Resolving turns every way of writing a path into one full path, and the prefix check then has only one string to compare: /srv/app.config is outside /srv/uploads/.

  2. Question 2 of 2 Why does the guard compare with "/srv/uploads/" and not with "/srv/uploads"?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Because /srv/uploads-old/report.pdf also starts with /srv/uploads

    A folder path plus a separator is a prefix that only paths inside the folder can have. The bare folder path is also a prefix of every sibling whose name begins the same way.

Write a temporary file, then move it over the old one

Saving a settings file seems to need one call. atomic.cs saves a new version twice and stops each save after 20 of its 50 bytes, as a crash or a power cut would, first while writing the file in place and then while writing a temporary file:

atomic.cs: a save stopped half way, in place and through a temporary file C# · atomic/atomic.cs
// Saving a settings file. The save is stopped half way, as a crash or a power cut would stop it, first while
// overwriting the file in place, then while writing a temporary file that is moved over the old one at the end.
using System.Text;
using System.Text.Json;

Directory.CreateDirectory("config");
string path = Path.Join("config", "settings.json");
string old = """{"theme":"dark","fontSize":14}""";
string next = """{"theme":"light","fontSize":16,"language":"hi-IN"}""";

File.WriteAllText(path, old);
Try("In place", () => SaveInPlace(path, next, stopAfter: 20));
Report(path);

File.WriteAllText(path, old);
Try("Temporary file", () => SaveAtomically(path, next, stopAfter: 20));
Report(path);

// The same save, not interrupted, and File.Replace, which also keeps the old version as a backup.
SaveAtomically(path, next);
Report(path);
File.WriteAllText(path + ".tmp", """{"theme":"light","fontSize":18}""");
File.Replace(path + ".tmp", path, path + ".bak");
Console.WriteLine($"After File.Replace: settings.json {File.ReadAllText(path)}, settings.json.bak {File.ReadAllText(path + ".bak")}");

// Overwrites the file where it is: File.Create empties it first, so a stop in between leaves part of the text.
static void SaveInPlace(string path, string text, int stopAfter = -1)
{
    using FileStream stream = File.Create(path);
    Write(stream, text, stopAfter);
}

// Writes the text to path.tmp in the same folder, forces it to the disk, then renames it over path.
static void SaveAtomically(string path, string text, int stopAfter = -1)
{
    string temp = path + ".tmp";
    using (FileStream stream = new(temp, FileMode.Create, FileAccess.Write, FileShare.None))
    {
        Write(stream, text, stopAfter);
        stream.Flush(flushToDisk: true);
    }
    File.Move(temp, path, overwrite: true);
}

static void Write(FileStream stream, string text, int stopAfter)
{
    byte[] bytes = Encoding.UTF8.GetBytes(text);
    if (stopAfter < 0) { stream.Write(bytes); return; }
    stream.Write(bytes, 0, stopAfter);
    throw new IOException($"stopped after {stopAfter} of {bytes.Length} bytes");
}

static void Try(string name, Action save)
{
    try { save(); }
    catch (IOException e) { Console.WriteLine($"{name}: the save was {e.Message}"); }
}

static void Report(string path)
{
    string text = File.ReadAllText(path);
    bool valid;
    try { using var _ = JsonDocument.Parse(text); valid = true; } catch (JsonException) { valid = false; }
    string others = string.Join(", ", Directory.EnumerateFiles(Path.GetDirectoryName(path)!).Select(Path.GetFileName).Where(n => n != "settings.json").Order(StringComparer.Ordinal));
    Console.WriteLine($"  settings.json: {text} (valid JSON: {valid}); other files: {(others.Length == 0 ? "none" : others)}");
}

Output

In place: the save was stopped after 20 of 50 bytes
  settings.json: {"theme":"light","fo (valid JSON: False); other files: none
Temporary file: the save was stopped after 20 of 50 bytes
  settings.json: {"theme":"dark","fontSize":14} (valid JSON: True); other files: settings.json.tmp
  settings.json: {"theme":"light","fontSize":16,"language":"hi-IN"} (valid JSON: True); other files: none
After File.Replace: settings.json {"theme":"light","fontSize":18}, settings.json.bak {"theme":"light","fontSize":16,"language":"hi-IN"}

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run atomic.cs

In place, File.Create emptied settings.json before the first new byte, so the stopped save left {"theme":"light","fo, which is not JSON: the app could not even start with it. The second save wrote settings.json.tmp and stopped there, so settings.json still held the old, valid text. Follow the second save through, from the temporary file to the move that replaces the old one:

Interactive · message by message A save through a temporary file, stopped and then finished Step through the second and third saves of atomic.cs. The program is on the left, the temporary file in the middle and the settings file on the right.
A save through a temporary file, stopped and then finished, step 4 of 4. Messages so far: atomic.cs to settings.json.tmp: open for writing; atomic.cs to settings.json.tmp: 20 of 50 bytes; atomic.cs to settings.json.tmp: 50 bytes and a flush; settings.json.tmp to settings.json: rename over the old.atomic.csSaveAtomicallysettings.json.tmpgonesettings.jsonnew: theme lightopen for writing20 of 50 bytes50 bytes and a flushrename over the old

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. The temporary file. The save opens settings.json.tmp in the same folder with FileMode.Create. settings.json is not opened at all, so whatever happens next, it keeps its old text.
  2. Stopped half way. The first 20 of 50 bytes reach the temporary file, then the save stops, as a crash would stop it. The temporary file holds half a document; settings.json is still valid JSON.
  3. The next save. The next save writes all 50 bytes to the temporary file, replacing the half one, and Flush(flushToDisk: true) makes sure they are on the disk before anything else changes.
  4. One rename. File.Move with overwrite: true renames the temporary file to settings.json. The name always refers to a whole file, the old one until this moment and the new one after it.

The move is what makes the save atomic replace: Replacing a file so that every reader sees the whole old file or the whole new one, never part of either: write a temporary file in the same folder, then rename it over the old one.. On Linux and macOS, .NET’s File.Move(temp, path, overwrite: true) is the rename call of the operating system, and POSIX defines rename so that the name refers to the old file or to the new one throughout, never to neither. Flush(flushToDisk: true) makes the bytes reach the disk before the name changes. File.Replace does the same move and keeps the replaced file as a backup: after it, settings.json.bak held the version it replaced. A stopped save leaves settings.json.tmp behind, so delete such leftovers when the program starts.

Writing the temporary file into the temp folder

Path.GetTempPath() is often on another volume. Across volumes .NET’s move falls back to copying the file and then deleting the original, which a crash can stop half way, and File.Replace throws. Create the temporary file in the same folder as the file it replaces.

Checkpoint

Check: Saving a file atomically

  1. Question 1 of 2 A save rewrites settings.json in place with File.Create and is stopped after 20 of its 50 bytes. What does the file hold afterwards?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first 20 bytes of the new text, which is not valid JSON

    Writing in place destroys the old version before the new one is complete, so any stop in between leaves a broken file. A temporary file plus a rename never touches the old one until the new one is whole.

  2. Question 2 of 2 Why does SaveAtomically write settings.json.tmp next to settings.json, not into Path.GetTempPath()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Within one volume File.Move is a rename; across volumes it copies and deletes, which a crash can interrupt

    The temporary file must sit on the same volume as the file it replaces, and the simplest way to be sure is to put it in the same folder. File.Replace even throws when the two are on different volumes.

Practise: a file store that stays in its folder

store.cs holds nine checks of a SafeFileStore, which saves and loads users’ files inside one folder. It starts with Save and Load unwritten, so every check fails:

store.cs: nine checks of SafeFileStore C# · store/store.cs
// A lab: SafeFileStore keeps every file a user saves inside one folder. Save and Load start out unwritten, so the
// checks fail. Write them until all nine pass (each check names the rule it tests).
var store = new SafeFileStore("store");
int passed = 0, total = 0;

Check("saves and loads report.pdf", () => { store.Save("report.pdf", Bytes(100)); return store.Load("report.pdf").Length == 100; });
Check("accepts an extension in capitals, SCAN.PNG", () => { store.Save("SCAN.PNG", Bytes(10)); return store.Load("SCAN.PNG").Length == 10; });
Check("refuses ../escape.txt", () => Refused(() => store.Save("../escape.txt", Bytes(1))));
Check("refuses an absolute path, /etc/hosts", () => Refused(() => store.Load("/etc/hosts")));
Check("refuses a name with a backslash", () => Refused(() => store.Save("..\\escape.txt", Bytes(1))));
Check("refuses run.exe: .pdf, .png and .txt only", () => Refused(() => store.Save("run.exe", Bytes(1))));
Check("refuses invoice.pdf.exe", () => Refused(() => store.Save("invoice.pdf.exe", Bytes(1))));
Check("refuses a file over 2 MB", () => Refused(() => store.Save("big.pdf", Bytes(2 * 1024 * 1024 + 1))));
Check("tells the user nothing about the folder", () => Message(() => store.Load("../store.cs")) == SafeFileStore.Refusal);

Console.WriteLine($"{passed} of {total} checks pass");
return passed == total ? 0 : 1;

void Check(string rule, Func<bool> test)
{
    total++;
    bool ok;
    try { ok = test(); } catch (Exception) { ok = false; }
    if (ok) passed++;
    Console.WriteLine($"{(ok ? "pass" : "FAIL")}  {rule}");
}

static byte[] Bytes(int count) => new byte[count];

// Refused means the store threw ArgumentException; any other exception is a bug.
static bool Refused(Action action)
{
    try { action(); return false; }
    catch (ArgumentException) { return true; }
}

static string? Message(Action action)
{
    try { action(); return null; }
    catch (ArgumentException e) { return e.Message; }
}

sealed class SafeFileStore(string folder)
{
    // The one message a user sees when a name or a size is refused.
    public const string Refusal = "That file is not allowed.";

    public void Save(string name, byte[] content) => throw new NotImplementedException();

    public byte[] Load(string name) => throw new NotImplementedException();
}

Output (exit status 1)

FAIL  saves and loads report.pdf
FAIL  accepts an extension in capitals, SCAN.PNG
FAIL  refuses ../escape.txt
FAIL  refuses an absolute path, /etc/hosts
FAIL  refuses a name with a backslash
FAIL  refuses run.exe: .pdf, .png and .txt only
FAIL  refuses invoice.pdf.exe
FAIL  refuses a file over 2 MB
FAIL  tells the user nothing about the folder
0 of 9 checks pass

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run store.cs

Make store.cs pass

Write Save and Load. Accept a name only if it has letters, digits, -, _ and exactly one dot, starts with no dot and ends in .pdf, .png or .txt in any case; resolve it with the guard of guard.cs; refuse more than 2 MB. Throw ArgumentException with Refusal as its message for every refusal. Run dotnet run store.cs until it prints 9 of 9 checks pass.

The session below covers the five ideas of this lesson, from whole-file reads to the atomic save. It starts where your earlier answers were weakest, and every choice you make comes with the reason it is right or wrong.

Practice that adapts to you

Practise this lesson

9 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 9 Which bytes start a file written by File.WriteAllText(path, "₹")?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: E2 82 B9: UTF-8 without a byte order mark

    File.WriteAllText writes UTF-8 without a byte order mark. Pass an Encoding when a reader needs another encoding or a byte order mark.

  2. Question 2 of 9 Eight threads must read fixed 128-byte records from one large file at the same time. Which API fits best?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: RandomAccess.Read with each record's own offset, on one handle from File.OpenHandle

    Offset-based reads have no position to share, so several readers cannot disturb one another. A FileStream has one position for everyone.

  3. Question 3 of 9 Does Path.Join("uploads", "../app.config") remove the .. part?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: No: it returns uploads/../app.config; only Path.GetFullPath resolves ..

    Join and Combine put text together; neither looks at what the parts mean. GetFullPath resolves . and .. and makes the result absolute.

  4. Question 4 of 9 A Linux service receives the file name ..\report.pdf from a Windows program. What does Path.GetFileName return on Linux?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: The whole name, ..\report.pdf, because a backslash is part of a name on Linux

    The same text means different paths on different systems. A strict rule for names, letters and digits with one dot, avoids the difference entirely.

  5. Question 5 of 9 Why does listing.cs sort the names before printing them?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: The file system returns names in no promised order

    Never rely on the order of a directory listing; sort when order matters, with an explicit comparer.

  6. Question 6 of 9 An import service finds new files only through FileSystemWatcher.Created. Under load, some files are never imported. What is the sounder design?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: Treat each event as a hint and list the folder with EnumerateFiles to find every file waiting

    The documentation warns that events can be missed and repeated, so the list of files must come from the folder itself; the events only say when to look.

  7. Question 7 of 9 uploads/notes.txt is a symbolic link to ../secrets.txt. What does TryResolve, the name check of guard.cs, decide?
    Show the answer: Question 7 of 9

    Answer: It allows it, because the name stays inside uploads; a link check is needed as well

    A name check cannot see what a link points to. Refuse links, as TryResolveNoLinks does with LinkTarget, or do not let anyone create them in the folder.

  8. Question 8 of 9 What does OWASP advise instead of using the file name a user sends for an upload?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: Store the file under a name you generate, such as a GUID, and keep the user's name as data

    The safest name is one the user never touched. The guard and the extension rules are then defence in depth.

  9. Question 9 of 9 What does File.Replace(temp, path, backup) do with the old contents of path?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: It keeps them as the backup file, while temp takes the place of path

    File.Replace replaces the destination with the source and moves the old destination to the backup name. The two files must be on the same volume.

Interview questions

Fresher · What is the difference between Path.Combine and Path.Join?

Both put path parts together with a separator between them. Path.Combine treats a rooted part as a new start: if any part after the first is absolute, such as /etc/hosts, it drops everything before it and returns that part, which makes it unsafe for names that come from users. Path.Join only adds separators: it never drops a part, so Path.Join("uploads", "/etc/hosts") is uploads/etc/hosts. Neither resolves ..; for that, and for an absolute path, call Path.GetFullPath(name, baseFolder). Use Join for parts you build yourself, and the resolve-and-check guard for any part a user sends.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A support portal serves attachments with File.ReadAllBytes(Path.Combine(root, request.FileName)). A tester downloads the portal's own configuration file through it. Explain the cause, the fix you would ship, and how you would show that the fix holds on both Linux and Windows.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    After a power cut, a desktop app refuses to start because settings.json holds half a JSON document. How does the save code produce this, how do you change it, and how do you test the fix without pulling the plug?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    An import service watches a drop folder with FileSystemWatcher and imports each file in its Created handler. Under load, some files are never imported and a few are imported twice. Why, and what design would you use instead?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • The whole-file methods of File suit files whose size you control; File.ReadLines, FileStream and RandomAccess read large files in pieces, and WriteAllText writes UTF-8 without a byte order mark.
  • Path.Combine restarts at any rooted part and Path.Join never does; only Path.GetFullPath resolves .., and with a base folder it does not depend on the current directory.
  • EnumerateFiles hands out names as it reads, and a new EnumerationOptions skips hidden and system files and folders it may not read; sort the names, because their order is not promised.
  • A user’s file name stays inside its folder only if you resolve it with GetFullPath(name, root) and check, ordinally, that it starts with the folder plus a separator, and links need a check of their own.
  • Save by writing a temporary file in the same folder and moving it over the old one, so a crash leaves the old file or the new one, never half of either.

Terms in this lesson

atomic replace
Replacing a file so that every reader sees the whole old file or the whole new one, never part of either: write a temporary file in the same folder, then rename it over the old one.
path traversal
A weakness in which a file name from outside the program, such as ../secrets.txt or an absolute path, makes it read or write a file outside the folder it meant to use.
symbolic link
A file-system entry that points to another path. Opening the link opens its target, which can lie outside the folder that holds the link.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.