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.
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:
// 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:
// 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
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 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
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:
// 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- ../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.
- 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.
- /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.
- 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.
Checkpoint
Check: Keeping a user's file name in its folder
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:
// 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:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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
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:
// 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.
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.
-
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.
-
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?
-
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?
Key takeaways
- The whole-file methods of
Filesuit files whose size you control;File.ReadLines,FileStreamandRandomAccessread large files in pieces, andWriteAllTextwrites UTF-8 without a byte order mark. Path.Combinerestarts at any rooted part andPath.Joinnever does; onlyPath.GetFullPathresolves.., and with a base folder it does not depend on the current directory.EnumerateFileshands out names as it reads, and a newEnumerationOptionsskips 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
- File and stream I/O (Microsoft Learn)
- File.WriteAllText method (Microsoft Learn)
- File.ReadLines method (Microsoft Learn)
- RandomAccess class (Microsoft Learn)
- File IO improvements in .NET 6 (.NET Blog, Microsoft)
- Path.Combine method (Microsoft Learn)
- Path.Join method (Microsoft Learn)
- Path.GetFullPath method (Microsoft Learn)
- File path formats on Windows systems (Microsoft Learn)
- EnumerationOptions class (Microsoft Learn)
- Directory.EnumerateFiles method (Microsoft Learn)
- FileSystemWatcher class (Microsoft Learn)
- File.Replace method (Microsoft Learn)
- FileSystem.Unix.cs: MoveFile (.NET Foundation (dotnet/runtime))
- rename, renameat: rename file (POSIX.1, Base Specifications Issue 8) (The Open Group)
- File system formats available in Disk Utility on Mac (Apple)
- Path Traversal (OWASP Foundation)
- File Upload Cheat Sheet (OWASP Cheat Sheet Series)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress