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;
}
}
}