An FTP client written against the protocol rather than against a library: a cross-platform
client library that speaks RFC 959 over TcpClient, a keyboard-driven console browser, and
a WinForms window. It ships with a small FTP server so you can try all of it without a
machine on the network.
This began as a university exercise in April 2019 and was rebuilt in 2026. Both versions
are in the repository on purpose — the 2019 code is tagged
v0.1-original, and the part worth reading is what changed and
why.
The 2019 program was a thin wrapper over FtpWebRequest. That class, and the
WebRequest.Create factory it came from, are [Obsolete] as of .NET 6 (SYSLIB0014),
and .NET ships no replacement — there is no FtpClient in the framework the way there is an
HttpClient. Microsoft's guidance is to use a third-party library.
So the interesting half of the job is the half the 2019 version never had to do: opening a
control connection, reading replies that arrive in pieces, negotiating a data channel, and
making sense of a directory listing. That is what FtpClient.Protocol is.
The one piece the 2019 version did write itself — the directory-listing parser — is also the one piece that was wrong. It is the centrepiece below.
LIST has no standard. The server prints a directory the way ls -l or dir would and the
client is expected to cope:
drwxr-xr-x 2 owner group 4096 Nov 25 2002 bussys
-rw-r--r-- 1 owner group 1024 Apr 4 12:06 my report.txt
02-03-04 07:46PM <DIR> My Documents
The 2019 parser found the filename by searching the line for the date it had just extracted and taking everything after it:
// 2019 — DirectoryListParser.ParseFileStructFromUnixStyleRecord
f.CreateTime = getCreateTimeString(record); // regex over English month names
int fileNameIndex = record.IndexOf(f.CreateTime) + f.CreateTime.Length;
if (fileNameIndex == 0)
{
fileNameIndex = 48; // ← and when that fails, column 48
}
f.Name = record.Substring(fileNameIndex).Trim();A search for a substring is not a way to find a field. When the regex missed, CreateTime
was "", IndexOf("") returned 0, and the hard-coded column took over. The DOS branch had
the mirror-image problem: it split the tail on whitespace and kept one token.
I ran the 2019 file against a set of listing lines before touching it. Four defects, all reproducible:
| Input | 2019 | Now |
|---|---|---|
DOS line, file my report.txt |
name is my |
my report.txt |
Unix line, non-English month (kwi 4 12:06) |
name is 12:06 raport.txt |
raport.txt, no timestamp |
| Unix listing containing one truncated line | ArgumentOutOfRangeException — whole directory lost |
that line reported, every other entry returned |
Unix symlink line (latest -> v2.txt) |
silently dropped | returned, with its target |
Three of those produce a wrong answer rather than an error, which is the worst outcome a
file browser can have: you cannot download my report.txt if the client thinks it is called
my.
The rewrite (UnixListingParser)
consumes the eight fields in front of the name instead of hunting for one of them:
^(?<perms>[bcdlps-][rwxsStTlL-]{9})[+@.]?\s+ (?<links>\d+)\s+ (?<owner>\S+)\s+
(?:(?<group>\S+)\s+)? (?<size>\d+)\s+
(?<month>[A-Za-z]{3})\s+ (?<day>\d{1,2})\s+ (?<stamp>\d{4}|\d{1,2}:\d{2})
[ ](?<name>.+)$
Three decisions are doing the work:
- The name is whatever follows the timestamp field, captured whole. Spaces in filenames are the normal case, not an edge case.
- The month is
[A-Za-z]{3}, not a list of English months. A server under a Polish locale writeskwi. That should cost the timestamp and nothing else — so date recognition and name extraction are independent, and a failure to read the date leavesModifiednull instead of corruptingName. - A line that does not match is returned, not thrown and not swallowed. A listing
routinely contains lines that are not entries;
FtpListingResultcarries them inUnparsedLines, and both clients show the count. In the screenshot above, "2 lines not understood" is the honest report of atotal 12header and a deliberately truncated line.
Format detection is also per line rather than once per listing. The 2019 code guessed Unix or DOS from the first recognisable line and then applied that guess to everything below it; one odd line at the top made the rest of the directory wrong.
Most servers now support MLSD (RFC 3659), which returns the same directory in a form meant
for programs:
type=file;size=1024;modify=20190404120600; my report.txt
Typed facts, an unambiguous UTC timestamp, and the pathname as everything after the first
space. FtpSession asks FEAT at login and prefers MLSD when it is offered, falling back
to LIST and the heuristics above when it is not. The best fix for a fragile parser is to
stop needing it.
Replies arrive in pieces. A reply is one line, or a block whose first line has a hyphen
where the space would be and which ends at the first line repeating the same code followed by
a space. FEAT and the login greeting are both block replies, and RFC 959 warns that an
intermediate line may itself begin with three digits — only the separator distinguishes it
from the terminator. TCP is free to split any of this anywhere, so
FtpControlChannel buffers across reads and
only surfaces a reply once it is complete. The tests drive it through a stream that returns
one byte per read, so the split is deterministic rather than left to the network.
Data connections are passive only. EPSV first — it carries no address, so it survives
NAT and works over IPv6 — falling back to PASV. When a PASV reply advertises a private
address, the address from the control connection is used with the advertised port, because
the control connection is proof of what actually reaches the server. Active mode is not
implemented: it needs the server to dial back to the client, which stopped working
generally about twenty-five years ago.
Transfers are binary. TYPE I is set at login. The protocol default is ASCII, which
silently rewrites line endings inside every file that is not text.
TLS is real. AUTH TLS / PBSZ 0 / PROT P (RFC 4217) puts both the control channel
and the data channels under TLS. The 2019 code had a _UseSSL field hard-wired to false.
One command at a time. FTP carries no request identifiers, so two overlapping commands on
one connection cannot be told apart. FtpSession serialises operations behind a semaphore.
That is not a theoretical concern: the WinForms client fires two loads at once when it
connects, and before the semaphore existed they interleaved and left the reply reader
decoding a byte range that was no longer there. That bug was found by taking the screenshot
below for this README, and there is now a test for it.
Here is a full session, which is what --trace prints:
< 220 FtpClient test server ready.
> USER test_user
< 331 Password required.
> PASS ****
< 230 Logged in.
> FEAT
< 211-Features:
< UTF8
< SIZE
< TVFS
< EPSV
< 211 End
> OPTS UTF8 ON
< 200 Option accepted.
> TYPE I
< 200 Type set.
> EPSV
< 229 Entering Extended Passive Mode (|||60673|).
> LIST /
< 150 Opening data connection.
< 226 Transfer complete.
> QUIT
The password is redacted in the trace and nowhere else — it still reaches the server.
Requires the .NET 8 SDK. The protocol library, the console browser and the whole test suite run on Windows, Linux and macOS; only the WinForms client is Windows-only.
git clone https://github.com/Dimitriuses/FTPClient.git
cd FTPClient
dotnet build FtpClient.slnThe quickest way in is tools/dev.ps1, which starts the bundled server and
opens the browser against it in one step. It is the same entry point the VS Code tasks and
the Server + console browser launch profile use, and it runs under Windows PowerShell 5.1
as well as pwsh on Linux and macOS:
./tools/dev.ps1 demo # server + console browser
./tools/dev.ps1 build # add -Configuration Release for a release build
./tools/dev.ps1 test
./tools/dev.ps1 helpOr drive the projects directly. Start the bundled server — it serves a directory over FTP so you have something to point a client at:
dotnet run --project tests/FtpClient.TestServer -- --root ./demo --port 2121Console browser:
dotnet run --project src/FtpClient.Cli -- \
--host 127.0.0.1 --port 2121 --user test_user --password test_password| Key | |
|---|---|
| ↑ ↓ PgUp PgDn Home End | move |
| Enter | open a directory, or download a file |
| ← Backspace | parent directory |
| R | refresh |
| Q Esc | quit |
--list prints one listing and exits instead of opening the browser, --trace echoes the
protocol conversation, --tls negotiates AUTH TLS, and --help lists the rest. Omitting
--password prompts for it rather than leaving it in your shell history.
WinForms client (Windows): dotnet run --project src/FtpClient.Gui, then
File → Connect. Passing --host/--port/--user/--password opens it already connected.
Both screenshots are produced by tools/capture-screenshots.ps1,
which builds, stages a demo directory, starts the server telling it to speak the DOS
listing dialect and to mix in three lines that are not entries, drives both clients and
captures their windows. The scene is deliberate: every defect in the table above is visible
not happening.
dotnet test tests/FtpClient.Protocol.Tests104 tests, all green, ~0.6 s. They split three ways:
- Parser tests over listing text. The first four are named after the 2019 symptoms —
ParseList_DosEntryWhoseNameContainsSpaces_KeepsTheWholeName,ParseList_UnixEntryWithUnrecognisedMonth_KeepsTheNameAndLosesOnlyTheDate,ParseList_ListingContainsATruncatedLine_KeepsEveryOtherEntryAndReportsTheLine,ParseList_UnixSymbolicLink_IsReturnedWithItsTarget— and each was checked against the 2019 file first, so they are regression tests rather than tests written to pass. - Framing tests over a stream that returns one byte per read, including
ReadReply_MultiLineBlockContainingALineThatStartsWithDigits_DoesNotEndEarlyandReadReply_ReplySplitExactlyBetweenTheCarriageReturnAndTheLineFeed_IsNotTruncated. - Integration tests that run a real client over a real socket against
FtpClient.TestServer: login, both listing dialects,MLSDand the fallback toLIST,EPSVand the fallback toPASV, a 256-byte binary round trip, upload, delete, a server that writes every reply one byte at a time, and TLS against a self-signed certificate generated in the fixture.
CI runs the suite on windows-latest and ubuntu-latest. The Linux job also starts the
server and drives the console browser against it, so "cross-platform" is a checkmark rather
than a claim.
src/
FtpClient.Protocol/ net8.0 the library: control channel, PASV/EPSV, listings
Listing/ LIST (Unix + DOS) and MLSD parsers
FtpClient.Cli/ net8.0 `ftpbrowse`, the console browser
FtpClient.Gui/ net8.0-windows WinForms client
tests/
FtpClient.TestServer/ net8.0 an FTP server, also runnable standalone
FtpClient.Protocol.Tests/ net8.0 104 xUnit tests
tools/
dev.ps1 build / test / run / demo, for VS Code and shells
capture-screenshots.ps1 builds, drives both clients, captures docs/*.png
Directory.Build.props turns on nullable reference types, TreatWarningsAsErrors and
EnforceCodeStyleInBuild for every project. The build is clean at zero warnings.
- No active mode. Passive only (
EPSV, thenPASV). A server that refuses both cannot be used. - No resume. No
REST; an interrupted transfer starts again. - No recursive download or upload. One file at a time, in both clients.
FtpClient.TestServeris a test fixture, not a server. One hard-coded account, no access control beyond the path check that keeps requests inside its root, no rate limits. It exists so the tests and the screenshots do not need a machine on the network. Do not expose it.- TLS is explicit only (
AUTH TLS). Implicit FTPS on port 990 is not implemented, and--insecuredisables certificate validation outright — it is there for self-signed test servers and it does mean what it says. - The listing parser understands Unix and DOS
LIST, plusMLSD. Other dialects — VMS, MVS, NetWare — are reported as unparsed lines rather than guessed at. - Filenames are assumed UTF-8. The client negotiates
OPTS UTF8 ON; a server that ignores that and sends another encoding will produce mangled names. - The WinForms client is deliberately minimal. Browse, download, and that is it — no upload, no drag and drop, no transfer queue. The library is the part worth reading.
MIT.

