using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Ftp;
using Sunfish.Shares;
namespace Sunfish.Tests;
/// <summary>Cliente FTP minimo para las pruebas: control en texto y datos en modo pasivo o activo.</summary>
internal sealed class FtpTestClient : IAsyncDisposable
{
private readonly TcpClient _tcp = new();
private Stream _stream = Stream.Null;
private readonly List<byte> _pending = new();
public Encoding Encoding { get; set; } = Encoding.Latin1;
public static async Task<FtpTestClient> ConnectAsync(int port)
{
var client = new FtpTestClient();
await client._tcp.ConnectAsync(IPAddress.Loopback, port);
client._stream = client._tcp.GetStream();
var banner = await client.ReadReplyAsync();
Assert.Equal(220, banner.Code);
return client;
}
public async Task<(int Code, string Text)> CommandAsync(string line)
{
await SendAsync(line);
return await ReadReplyAsync();
}
public Task SendAsync(string line) => _stream.WriteAsync(Encoding.GetBytes(line + "\r\n")).AsTask();
/// <summary>Respuesta completa (tambien las de varias lineas "211-...211 End").</summary>
public async Task<(int Code, string Text)> ReadReplyAsync()
{
string first = await ReadLineAsync();
int code = int.Parse(first[..3]);
var text = new StringBuilder(first);
if (first.Length > 3 && first[3] == '-')
{
while (true)
{
string line = await ReadLineAsync();
text.Append('\n').Append(line);
if (line.StartsWith($"{code} ", StringComparison.Ordinal))
break;
}
}
return (code, text.ToString());
}
private async Task<string> ReadLineAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var one = new byte[1];
while (true)
{
int n = await _stream.ReadAsync(one, cts.Token);
if (n == 0)
throw new IOException("closed");
if (one[0] == '\n')
break;
_pending.Add(one[0]);
}
string line = Encoding.GetString(_pending.ToArray()).TrimEnd('\r');
_pending.Clear();
return line;
}
public async Task LoginAsync(string user = "anonymous", string password = "test@")
{
Assert.Equal(331, (await CommandAsync("USER " + user)).Code);
var pass = await CommandAsync("PASS " + password);
Assert.Equal(230, pass.Code);
}
/// <summary>Comando con conexion de datos pasiva; devuelve la respuesta final y lo recibido.</summary>
public async Task<(int Code, byte[] Data)> PassiveAsync(string command, byte[]? upload = null)
{
var pasv = await CommandAsync("EPSV");
Assert.Equal(229, pasv.Code);
int port = int.Parse(Regex.Match(pasv.Text, @"\|\|\|(\d+)\|").Groups[1].Value);
using var data = new TcpClient();
await data.ConnectAsync(IPAddress.Loopback, port);
await SendAsync(command);
var first = await ReadReplyAsync();
if (first.Code >= 400)
return (first.Code, []);
Assert.Equal(150, first.Code);
var ms = new MemoryStream();
var stream = data.GetStream();
if (upload is not null)
{
await stream.WriteAsync(upload);
data.Client.Shutdown(SocketShutdown.Send);
}
await stream.CopyToAsync(ms);
data.Close();
var final = await ReadReplyAsync();
return (final.Code, ms.ToArray());
}
public async Task<string> ListAsync(string command = "LIST")
{
var (code, data) = await PassiveAsync(command);
Assert.Equal(226, code);
return Encoding.GetString(data);
}
public async ValueTask DisposeAsync()
{
try { await SendAsync("QUIT"); } catch (IOException) { }
_tcp.Dispose();
}
}
public class FtpUnitTests
{
[Theory]
[InlineData("", "/a/b")]
[InlineData("c", "/a/b/c")]
[InlineData("../c", "/a/c")]
[InlineData("/x/./y", "/x/y")]
[InlineData("../../..", "/")]
[InlineData("\\dos\\path", "/dos/path")]
public void ResolvesPaths(string argument, string expected) =>
Assert.Equal(expected, FtpPath.Format(FtpPath.Resolve(["a", "b"], argument)!));
[Fact]
public void RejectsInvalidSegments() => Assert.Null(FtpPath.Resolve([], "a:b"));
[Fact]
public void FormatsUnixListings()
{
var now = new DateTime(2026, 11, 4, 12, 0, 0);
Assert.Equal("-rw-r--r-- 1 ftp ftp 1234 Nov 3 10:05 nota.txt",
FtpListing.UnixLine(new FileEntry("nota.txt", false, 1234, new DateTime(2026, 11, 3, 10, 5, 0)), now));
Assert.Equal("drwxr-xr-x 1 ftp ftp 0 Jan 2 2020 viejo",
FtpListing.UnixLine(new FileEntry("viejo", true, 0, new DateTime(2020, 1, 2)), now));
}
[Fact]
public void ParsesPortCommands()
{
Assert.Equal(new IPEndPoint(IPAddress.Parse("10.1.2.3"), 4 * 256 + 5), DataChannel.ParsePort("10,1,2,3,4,5"));
Assert.Null(DataChannel.ParsePort("10,1,2,3,4"));
Assert.Equal(new IPEndPoint(IPAddress.IPv6Loopback, 6000), DataChannel.ParseEprt("|2|::1|6000|"));
Assert.Equal((50000, 50010), DataChannel.ParseRange("50000-50010"));
Assert.Equal((0, 0), DataChannel.ParseRange("80-90"));
}
[Fact]
public void ActiveModeOnlyToTheClientAndHighPorts()
{
using var channel = new DataChannel(IPAddress.Parse("10.0.0.5"));
Assert.False(channel.SetActive(new IPEndPoint(IPAddress.Parse("10.0.0.6"), 5000)));
Assert.False(channel.SetActive(new IPEndPoint(IPAddress.Parse("10.0.0.5"), 25)));
Assert.True(channel.SetActive(new IPEndPoint(IPAddress.Parse("10.0.0.5"), 5000)));
}
}
/// <summary>Servidor FTP real sobre localhost.</summary>
public class FtpServerTests : IAsyncLifetime
{
private readonly TempDir _pub = new();
private readonly TempDir _rw = new();
private readonly TempDir _priv = new();
private TestServer _server = null!;
public Task InitializeAsync()
{
_pub.File("leeme.txt", "linea 1\nlinea 2\n");
_pub.File("año.txt", "acentos");
_pub.File("docs/manual.txt", "0123456789");
_rw.File("existe.txt", "original");
_priv.File("secreto.txt", "s");
var config = new SunfishConfig();
config.Users.Add(new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) });
config.Shares.Add(new ShareConfig { Name = "pub", Location = "/pub", Path = _pub.Path });
config.Shares.Add(new ShareConfig { Name = "rw", Location = "/rw", Path = _rw.Path, Upload = true, Rename = true, Delete = true });
config.Shares.Add(new ShareConfig { Name = "priv", Location = "/priv", Path = _priv.Path, Users = ["ana"] });
config.Shares.Add(new ShareConfig { Name = "noftp", Location = "/noftp", Path = _pub.Path, Ftp = false });
_server = new TestServer(config);
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await _server.DisposeAsync();
_pub.Dispose();
_rw.Dispose();
_priv.Dispose();
}
private async Task<FtpTestClient> Anonymous()
{
var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
await c.LoginAsync();
return c;
}
[Fact]
public async Task RequiresLoginBeforeCommands()
{
await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
Assert.Equal(530, (await c.CommandAsync("PWD")).Code);
Assert.Equal(215, (await c.CommandAsync("SYST")).Code);
Assert.Contains("MLST", (await c.CommandAsync("FEAT")).Text);
}
[Fact]
public async Task AnonymousSeesOnlyPublicFtpShares()
{
await using var c = await Anonymous();
string root = await c.ListAsync("NLST");
Assert.Contains("pub", root);
Assert.Contains("rw", root);
Assert.DoesNotContain("priv", root);
Assert.DoesNotContain("noftp", root);
Assert.Equal(550, (await c.CommandAsync("CWD /priv")).Code);
}
[Fact]
public async Task UsersSeeTheirShares()
{
await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
await c.LoginAsync("ana", "1234");
Assert.Contains("priv", await c.ListAsync("NLST"));
Assert.Equal(250, (await c.CommandAsync("CWD priv")).Code);
var (code, data) = await c.PassiveAsync("RETR secreto.txt");
Assert.Equal(226, code);
Assert.Equal("s", Encoding.ASCII.GetString(data));
}
[Fact]
public async Task WrongPasswordsAreRejected()
{
await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
Assert.Equal(331, (await c.CommandAsync("USER ana")).Code);
Assert.Equal(530, (await c.CommandAsync("PASS mala")).Code);
}
[Fact]
public async Task NavigatesAndListsLikeLs()
{
await using var c = await Anonymous();
Assert.Equal(250, (await c.CommandAsync("CWD pub/docs")).Code);
Assert.Contains("\"/pub/docs\"", (await c.CommandAsync("PWD")).Text);
Assert.Equal(250, (await c.CommandAsync("CDUP")).Code);
string list = await c.ListAsync("LIST -la");
Assert.Matches(@"^drwxr-xr-x 1 ftp ftp\s+0 \w{3} [ \d]\d [\d :]{5} docs\r$", list.Split('\n')[0]);
Assert.Contains("leeme.txt", list);
}
[Fact]
public async Task NamesUseLatin1UnlessUtf8IsRequested()
{
await using var c = await Anonymous();
await c.CommandAsync("CWD /pub");
c.Encoding = Encoding.Latin1;
Assert.Contains("año.txt", await c.ListAsync("NLST"));
Assert.Equal(200, (await c.CommandAsync("OPTS UTF8 ON")).Code);
c.Encoding = Encoding.UTF8;
Assert.Contains("año.txt", await c.ListAsync("NLST"));
var (code, data) = await c.PassiveAsync("RETR año.txt");
Assert.Equal(226, code);
Assert.Equal("acentos", Encoding.ASCII.GetString(data));
}
[Fact]
public async Task MlsdUsesFacts()
{
await using var c = await Anonymous();
string mlsd = await c.ListAsync("MLSD /pub");
Assert.Contains("type=dir;", mlsd);
Assert.Matches(@"type=file;size=7;modify=\d{14};perm=r; año\.txt", Encoding.UTF8.GetString(Encoding.Latin1.GetBytes(mlsd)));
}
[Fact]
public async Task RetrWithRestAndAsciiMode()
{
await using var c = await Anonymous();
await c.CommandAsync("CWD /pub");
Assert.Equal(350, (await c.CommandAsync("REST 3")).Code);
var (_, partial) = await c.PassiveAsync("RETR docs/manual.txt");
Assert.Equal("3456789", Encoding.ASCII.GetString(partial));
await c.CommandAsync("TYPE A");
var (_, ascii) = await c.PassiveAsync("RETR leeme.txt");
Assert.Equal("linea 1\r\nlinea 2\r\n", Encoding.ASCII.GetString(ascii));
Assert.Equal("16", (await c.CommandAsync("SIZE leeme.txt")).Text[4..]);
Assert.Matches(@"^213 \d{14}$", (await c.CommandAsync("MDTM leeme.txt")).Text);
}
[Fact]
public async Task UploadsRespectPermissions()
{
await using var c = await Anonymous();
await c.CommandAsync("CWD /rw");
var (code, _) = await c.PassiveAsync("STOR nuevo.txt", Encoding.ASCII.GetBytes("subido"));
Assert.Equal(226, code);
Assert.Equal("subido", File.ReadAllText(Path.Combine(_rw.Path, "nuevo.txt")));
(code, _) = await c.PassiveAsync("STOR existe.txt", Encoding.ASCII.GetBytes("pisado"));
Assert.Equal(550, code); // sin Overwrite
Assert.Equal("original", File.ReadAllText(Path.Combine(_rw.Path, "existe.txt")));
(code, _) = await c.PassiveAsync("STOR /pub/x.txt", Encoding.ASCII.GetBytes("x"));
Assert.Equal(550, code); // share de solo lectura
Assert.False(File.Exists(Path.Combine(_pub.Path, "x.txt")));
(code, _) = await c.PassiveAsync("STOR /x.txt", Encoding.ASCII.GetBytes("x"));
Assert.Equal(553, code); // raiz virtual
}
[Fact]
public async Task FoldersRenameAndDelete()
{
await using var c = await Anonymous();
await c.CommandAsync("CWD /rw");
Assert.Equal(257, (await c.CommandAsync("MKD carpeta")).Code);
Assert.True(Directory.Exists(Path.Combine(_rw.Path, "carpeta")));
Assert.Equal(350, (await c.CommandAsync("RNFR existe.txt")).Code);
Assert.Equal(250, (await c.CommandAsync("RNTO carpeta/movido.txt")).Code);
Assert.True(File.Exists(Path.Combine(_rw.Path, "carpeta", "movido.txt")));
Assert.Equal(550, (await c.CommandAsync("RMD carpeta")).Code); // no esta vacia
Assert.Equal(250, (await c.CommandAsync("DELE carpeta/movido.txt")).Code);
Assert.Equal(250, (await c.CommandAsync("RMD carpeta")).Code);
Assert.False(Directory.Exists(Path.Combine(_rw.Path, "carpeta")));
Assert.Equal(550, (await c.CommandAsync("DELE /pub/leeme.txt")).Code);
Assert.Equal(550, (await c.CommandAsync("RMD /rw")).Code); // la raiz del share no se borra
}
[Fact]
public async Task TraversalStaysInsideTheTree()
{
await using var c = await Anonymous();
Assert.Equal(250, (await c.CommandAsync("CWD /pub/../../..")).Code);
Assert.Contains("\"/\"", (await c.CommandAsync("PWD")).Text);
Assert.Equal(550, (await c.CommandAsync("CWD C:")).Code);
}
[Fact]
public async Task PortToAnotherAddressIsRefused()
{
await using var c = await Anonymous();
Assert.Equal(500, (await c.CommandAsync("PORT 10,9,8,7,200,1")).Code);
Assert.Equal(500, (await c.CommandAsync("PORT 127,0,0,1,0,21")).Code);
}
[Fact]
public async Task ActiveModeTransfers()
{
await using var c = await Anonymous();
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
Assert.Equal(200, (await c.CommandAsync($"PORT 127,0,0,1,{port / 256},{port % 256}")).Code);
await c.SendAsync("RETR /pub/docs/manual.txt");
using var data = await listener.AcceptTcpClientAsync();
Assert.Equal(150, (await c.ReadReplyAsync()).Code);
var ms = new MemoryStream();
await data.GetStream().CopyToAsync(ms);
Assert.Equal(226, (await c.ReadReplyAsync()).Code);
Assert.Equal("0123456789", Encoding.ASCII.GetString(ms.ToArray()));
listener.Stop();
}
[Fact]
public async Task ProfileSuffixInTheUserName()
{
await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
await c.LoginAsync("anonymous+dos");
Assert.Contains("437", (await c.CommandAsync("STAT")).Text);
}
}