Newer
Older
Sunfish / tests / Sunfish.Tests / FtpTests.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 14 KB 3.0
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);
    }
}