using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; namespace NcProgramManager.Tests.Integration.Stubs { /// /// Minimal in-process FTP server using passive mode. /// Supports USER, PASS, SYST, PWD, CWD, TYPE, PASV, LIST, NLST, RETR, STOR, DELE, QUIT. /// Also accepts OPTS, FEAT, NOOP, CLNT, MODE, STRU as no-ops. /// All replies use CRLF (per RFC 959), independent of host platform. /// public sealed class FtpServerStub : IDisposable { private const string CRLF = "\r\n"; private readonly TcpListener _listener; private readonly Thread _acceptThread; private volatile bool _running; public int Port { get; } /// In-memory file store: full path → content. public Dictionary Files { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public FtpServerStub() { _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; _running = true; _acceptThread = new Thread(AcceptLoop) { IsBackground = true, Name = "FtpStub-Accept" }; _acceptThread.Start(); } public void Dispose() { _running = false; try { _listener.Stop(); } catch { } } private void AcceptLoop() { while (_running) { TcpClient client; try { client = _listener.AcceptTcpClient(); } catch { break; } var t = new Thread(() => HandleClient(client)) { IsBackground = true, Name = "FtpStub-Client" }; t.Start(); } } private static void SendLine(Stream stream, string line) { byte[] bytes = Encoding.ASCII.GetBytes(line + CRLF); stream.Write(bytes, 0, bytes.Length); stream.Flush(); } private static string ReadLine(Stream stream) { var sb = new StringBuilder(); int prev = -1; while (true) { int b = stream.ReadByte(); if (b < 0) return sb.Length == 0 ? null : sb.ToString(); if (b == '\n') { if (prev == '\r') sb.Length = sb.Length - 1; // strip CR return sb.ToString(); } sb.Append((char)b); prev = b; } } private void HandleClient(TcpClient client) { using (client) using (var stream = client.GetStream()) { string cwd = "/"; TcpListener dataListener = null; SendLine(stream, "220 FtpServerStub ready"); string line; while ((line = ReadLine(stream)) != null) { line = line.Trim(); if (string.IsNullOrEmpty(line)) continue; string cmd, arg; int sp = line.IndexOf(' '); if (sp >= 0) { cmd = line.Substring(0, sp).ToUpperInvariant(); arg = line.Substring(sp + 1).Trim(); } else { cmd = line.ToUpperInvariant(); arg = ""; } switch (cmd) { case "USER": SendLine(stream, "331 Password required"); break; case "PASS": SendLine(stream, "230 Logged in"); break; case "SYST": SendLine(stream, "215 UNIX Type: L8"); break; case "FEAT": SendLine(stream, "211-Features:"); SendLine(stream, " UTF8"); SendLine(stream, "211 End"); break; case "OPTS": case "CLNT": case "NOOP": case "MODE": case "STRU": case "ALLO": SendLine(stream, "200 OK"); break; case "PWD": SendLine(stream, "257 \"" + cwd + "\""); break; case "CWD": cwd = arg.StartsWith("/") ? arg : cwd.TrimEnd('/') + "/" + arg; if (!cwd.EndsWith("/")) cwd += "/"; SendLine(stream, "250 CWD successful"); break; case "TYPE": SendLine(stream, "200 Type set"); break; case "PASV": dataListener = new TcpListener(IPAddress.Loopback, 0); dataListener.Start(); int dataPort = ((IPEndPoint)dataListener.LocalEndpoint).Port; int p1 = dataPort >> 8; int p2 = dataPort & 0xFF; SendLine(stream, "227 Entering Passive Mode (127,0,0,1," + p1 + "," + p2 + ")"); break; case "LIST": case "NLST": if (dataListener == null) { SendLine(stream, "425 No data connection"); break; } SendLine(stream, "150 Opening data connection"); try { using (var dc = dataListener.AcceptTcpClient()) using (var ds = dc.GetStream()) { // Empty listing — enough for Ping() } } catch { } try { dataListener.Stop(); } catch { } dataListener = null; SendLine(stream, "226 Transfer complete"); break; case "RETR": if (dataListener == null) { SendLine(stream, "425 No data connection"); break; } string retrPath = NormalizePath(cwd, arg); string retrContent; if (!Files.TryGetValue(retrPath, out retrContent)) { try { dataListener.Stop(); } catch { } dataListener = null; SendLine(stream, "550 File not found"); break; } SendLine(stream, "150 Opening data connection"); try { using (var dc = dataListener.AcceptTcpClient()) using (var ds = dc.GetStream()) { byte[] bytes = Encoding.ASCII.GetBytes(retrContent); ds.Write(bytes, 0, bytes.Length); } } catch { } try { dataListener.Stop(); } catch { } dataListener = null; SendLine(stream, "226 Transfer complete"); break; case "STOR": if (dataListener == null) { SendLine(stream, "425 No data connection"); break; } string storPath = NormalizePath(cwd, arg); SendLine(stream, "150 Opening data connection"); var sb = new StringBuilder(); try { using (var dc = dataListener.AcceptTcpClient()) using (var ds = dc.GetStream()) using (var dr = new StreamReader(ds, Encoding.ASCII)) { sb.Append(dr.ReadToEnd()); } } catch { } Files[storPath] = sb.ToString(); try { dataListener.Stop(); } catch { } dataListener = null; SendLine(stream, "226 Transfer complete"); break; case "DELE": string delePath = NormalizePath(cwd, arg); Files.Remove(delePath); SendLine(stream, "250 DELE successful"); break; case "QUIT": SendLine(stream, "221 Goodbye"); return; default: SendLine(stream, "500 Unknown command"); break; } } } } private static string NormalizePath(string cwd, string arg) { if (arg.StartsWith("/")) return arg; return cwd.TrimEnd('/') + "/" + arg; } } }