Skip to content
This repository was archived by the owner on Aug 5, 2026. It is now read-only.

Repository files navigation

FTPClient

CI .NET 8 License: MIT

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.

The console browser listing a directory

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.


Why it had to be rewritten rather than ported

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.


The listing parser

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 ArgumentOutOfRangeExceptionwhole 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 writes kwi. That should cost the timestamp and nothing else — so date recognition and name extraction are independent, and a failure to read the date leaves Modified null instead of corrupting Name.
  • A line that does not match is returned, not thrown and not swallowed. A listing routinely contains lines that are not entries; FtpListingResult carries them in UnparsedLines, and both clients show the count. In the screenshot above, "2 lines not understood" is the honest report of a total 12 header 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.

Better still: don't parse prose

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.


The rest of the protocol layer

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.


Running it

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.sln

The 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 help

Or 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 2121

Console 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.

The WinForms client

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.


Tests

dotnet test tests/FtpClient.Protocol.Tests

104 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_DoesNotEndEarly and ReadReply_ReplySplitExactlyBetweenTheCarriageReturnAndTheLineFeed_IsNotTruncated.
  • Integration tests that run a real client over a real socket against FtpClient.TestServer: login, both listing dialects, MLSD and the fallback to LIST, EPSV and the fallback to PASV, 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.


Layout

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.


Known limitations

  • No active mode. Passive only (EPSV, then PASV). 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.TestServer is 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 --insecure disables 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, plus MLSD. 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.

Licence

MIT.

About

FTP client speaking RFC 959 directly over TcpClient — .NET 8 library, console browser and WinForms app, with a bundled test server. A 2019 student project rebuilt around the directory-listing bug that broke it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages