Newer
Older
Sunfish / tests / Sunfish.Tests / MacTests.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 14 KB 3.0
using System.Text;
using Sunfish.Config;
using Sunfish.Mac;
using Sunfish.Shares;
using Xunit;

namespace Sunfish.Tests;

public class MacUnitTests
{
    internal static MacFile Sample(string name = "Lectura", byte[]? resource = null) => new()
    {
        Name = name,
        Type = "TEXT",
        Creator = "ttxt",
        ResourceFork = resource ?? Enumerable.Range(0, 300).Select(i => (byte)(i * 7)).ToArray(),
        Modified = new DateTime(1995, 8, 24, 10, 30, 0),
    };

    internal static async Task<byte[]> MacBinaryOf(MacFile file, byte[] data)
    {
        var ms = new MemoryStream();
        await MacBinary.EncodeAsync(file, new MemoryStream(data), data.Length, ms, CancellationToken.None);
        return ms.ToArray();
    }

    [Fact]
    public async Task MacBinaryRoundTrip()
    {
        var data = Encoding.ASCII.GetBytes("hola desde System 7");
        var file = Sample();
        var encoded = await MacBinaryOf(file, data);
        Assert.Equal(MacBinary.EncodedLength(data.Length, file.ResourceFork.Length), encoded.Length);
        Assert.Equal(0, encoded.Length % 128);

        var header = MacBinary.Parse(encoded.AsSpan(0, MacBinary.HeaderSize), encoded.Length, acceptVersion1: false);
        Assert.NotNull(header);
        var output = new MemoryStream();
        var decoded = await MacBinary.DecodeAsync(new MemoryStream(encoded), header, output, CancellationToken.None);
        Assert.Equal(data, output.ToArray());
        Assert.Equal("Lectura", decoded.Name);
        Assert.Equal("TEXT", decoded.Type);
        Assert.Equal("ttxt", decoded.Creator);
        Assert.Equal(file.ResourceFork, decoded.ResourceFork);
        Assert.Equal(file.Modified, decoded.Modified);
    }

    [Fact]
    public void RandomDataIsNotMacBinary()
    {
        var random = new Random(42);
        for (int i = 0; i < 200; i++)
        {
            var bytes = new byte[4096];
            random.NextBytes(bytes);
            bytes[0] = 0; // lo mas parecido posible: primer byte a cero
            Assert.Null(MacBinary.Parse(bytes.AsSpan(0, MacBinary.HeaderSize), bytes.Length, acceptVersion1: false));
        }
    }

    [Fact]
    public async Task CorruptedCrcIsRejected()
    {
        var encoded = await MacBinaryOf(Sample(), [1, 2, 3]);
        encoded[2] ^= 0x20; // cambia el nombre
        Assert.Null(MacBinary.Parse(encoded.AsSpan(0, MacBinary.HeaderSize), encoded.Length, acceptVersion1: false));
    }

    [Fact]
    public void BinHexRoundTripIncludingRunMarkers()
    {
        var data = new byte[] { 1, 0x90, 0x90, 0x90, 0x90, 2, 2, 2, 2, 2, 2, 0x90, 3, 0, 0 };
        var file = Sample("Datos é", resource: [0x90, 0x00, 0x90, 9, 9, 9, 9]);
        var text = BinHex.Encode(file, data);
        Assert.True(BinHex.LooksLikeBinHex(text));
        Assert.All(text, b => Assert.True(b < 128));

        var decoded = BinHex.Decode(text);
        Assert.NotNull(decoded);
        Assert.Equal(data, decoded.Value.DataFork);
        Assert.Equal("Datos é", decoded.Value.File.Name);
        Assert.Equal("TEXT", decoded.Value.File.Type);
        Assert.Equal(file.ResourceFork, decoded.Value.File.ResourceFork);
    }

    [Fact]
    public void BinHexWithBadCrcIsRejected()
    {
        var text = BinHex.Encode(Sample(), Encoding.ASCII.GetBytes("contenido de prueba bastante largo"));
        int i = Array.IndexOf(text, (byte)':') + 30;
        text[i] = text[i] == (byte)'A' ? (byte)'B' : (byte)'A';
        Assert.Null(BinHex.Decode(text));
    }

    [Fact]
    public void AppleDoubleRoundTrip()
    {
        var file = Sample();
        var read = AppleDouble.Read(AppleDouble.Write(file), "Lectura");
        Assert.NotNull(read);
        Assert.Equal("TEXT", read.Type);
        Assert.Equal("ttxt", read.Creator);
        Assert.Equal(file.ResourceFork, read.ResourceFork);
        Assert.Equal("._x.txt", Path.GetFileName(AppleDouble.CompanionOf(Path.Combine("d", "x.txt"))));
    }

    [Fact]
    public void ShortNamesAreStableAndUnique()
    {
        Assert.True(ShortNames.Is83("README.TXT"));
        Assert.True(ShortNames.Is83("leeme.txt"));
        Assert.False(ShortNames.Is83("Fotos del viaje.jpeg"));
        Assert.False(ShortNames.Is83("a.b.c"));

        string alias = ShortNames.Alias("Fotos del viaje.jpeg");
        Assert.Matches(@"^FOT[0-9A-F]{3}~1\.JPE$", alias);
        Assert.Equal(alias, ShortNames.Alias("Fotos del viaje.jpeg"));
        Assert.Equal("CAN", ShortNames.Alias("Canción.mp3")[..3]); // transliteracion

        var names = new[] { "Fotos del viaje.jpeg", "leeme.txt", "Otro documento.txt", "Año nuevo.doc" };
        var map = ShortNames.Map(names);
        Assert.Equal("leeme.txt", map["leeme.txt"]);
        Assert.Equal(names.Length, map.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count());
        Assert.All(map.Values, a => Assert.True(ShortNames.Is83(a), a));
        // Anadir otro fichero no cambia los alias existentes.
        var more = ShortNames.Map([.. names, "Zeta muy larga.txt"]);
        foreach (var n in names)
            Assert.Equal(map[n], more[n]);
    }

    [Fact]
    public void ShortNameCollisionsGetNextNumber()
    {
        // Mismo prefijo y mismo hash forzado: se simula pidiendo el alias 1 de los dos.
        var map = ShortNames.Map(["Documento largo 1.txt", "Documento largo 2.txt", "Documento largo 3.txt"]);
        Assert.Equal(3, map.Values.Distinct(StringComparer.OrdinalIgnoreCase).Count());
    }

    [Fact]
    public void ResolvesAliasesInAFolder()
    {
        using var dir = new TempDir();
        dir.File("Informe anual 2024.txt", "x");
        string alias = ShortNames.Map(["Informe anual 2024.txt"])["Informe anual 2024.txt"];
        Assert.Equal("Informe anual 2024.txt", ShortNames.Resolve(dir.Path, alias.ToLowerInvariant()));
        Assert.Null(ShortNames.Resolve(dir.Path, "NOEXIS~1.TXT"));
    }

    [Fact]
    public void MacNamesShortenKeepingTheExtension()
    {
        string s = MacNames.Shorten("Un nombre de fichero demasiado largo para HFS.pdf", 31);
        Assert.Equal(31, s.Length);
        Assert.EndsWith(".pdf", s);
        Assert.Equal("corto.txt", MacNames.Shorten("corto.txt", 31));
    }

    [Fact]
    public async Task UnpacksAMacBinaryUpload()
    {
        using var dir = new TempDir();
        var data = Encoding.ASCII.GetBytes("datos");
        string path = Path.Combine(dir.Path, "Lectura.bin");
        await File.WriteAllBytesAsync(path, await MacBinaryOf(Sample(), data));

        var (result, name) = await MacFiles.TryUnpackAsync(path, overwrite: false, acceptVersion1: false, CancellationToken.None);
        Assert.Equal(MacFiles.Unpacked.Unpacked, result);
        Assert.Equal("Lectura", name);
        Assert.False(File.Exists(path));
        Assert.Equal(data, File.ReadAllBytes(Path.Combine(dir.Path, "Lectura")));
        Assert.True(File.Exists(Path.Combine(dir.Path, "._Lectura")));
        Assert.Equal("TEXT", MacFiles.Describe(Path.Combine(dir.Path, "Lectura")).Type);
    }

    [Fact]
    public async Task KeepsTheEncodedFileWhenTheTargetExists()
    {
        using var dir = new TempDir();
        dir.File("Lectura", "ya estaba");
        string path = Path.Combine(dir.Path, "Lectura.bin");
        await File.WriteAllBytesAsync(path, await MacBinaryOf(Sample(), [1, 2]));
        var (result, _) = await MacFiles.TryUnpackAsync(path, overwrite: false, acceptVersion1: false, CancellationToken.None);
        Assert.Equal(MacFiles.Unpacked.ExistsKept, result);
        Assert.True(File.Exists(path));
        Assert.Equal("ya estaba", File.ReadAllText(Path.Combine(dir.Path, "Lectura")));
    }

    [Fact]
    public async Task OrdinaryBinFilesAreLeftAlone()
    {
        using var dir = new TempDir();
        var bytes = new byte[1000];
        new Random(1).NextBytes(bytes);
        string path = Path.Combine(dir.Path, "firmware.bin");
        await File.WriteAllBytesAsync(path, bytes);
        var (result, _) = await MacFiles.TryUnpackAsync(path, overwrite: false, acceptVersion1: true, CancellationToken.None);
        Assert.Equal(MacFiles.Unpacked.NotMac, result);
        Assert.Equal(bytes, File.ReadAllBytes(path));
    }

    [Fact]
    public void CompanionsFollowDeleteAndMove()
    {
        using var dir = new TempDir();
        string file = dir.File("Nota", "x");
        File.WriteAllBytes(AppleDouble.CompanionOf(file), AppleDouble.Write(Sample("Nota")));

        string moved = Path.Combine(dir.Path, "Nota 2");
        Assert.Equal(FileOpResult.Ok, FileOperations.Move(file, moved));
        Assert.False(File.Exists(Path.Combine(dir.Path, "._Nota")));
        Assert.True(File.Exists(Path.Combine(dir.Path, "._Nota 2")));

        Assert.Equal(FileOpResult.Ok, FileOperations.Delete(moved));
        Assert.Empty(Directory.GetFiles(dir.Path));
    }
}

/// <summary>Mac clasico y alias 8.3 a traves de la web y el FTP reales.</summary>
public class MacServerTests : IAsyncLifetime
{
    private readonly TempDir _dir = new();
    private TestServer _server = null!;
    private static readonly byte[] Data = Encoding.ASCII.GetBytes("contenido del data fork");

    public Task InitializeAsync()
    {
        _dir.File("Informe anual 2024.txt", "informe");
        string nota = _dir.File("Nota", Encoding.ASCII.GetString(Data));
        File.WriteAllBytes(AppleDouble.CompanionOf(nota), AppleDouble.Write(MacUnitTests.Sample("Nota")));

        var config = new SunfishConfig();
        config.Shares.Add(new ShareConfig { Name = "mac", Location = "/mac", Path = _dir.Path, Upload = true, Rename = true, Delete = true });
        _server = new TestServer(config);
        return Task.CompletedTask;
    }

    public async Task DisposeAsync()
    {
        await _server.DisposeAsync();
        _dir.Dispose();
    }

    private string Alias => ShortNames.Map(Directory.EnumerateFileSystemEntries(_dir.Path).Select(Path.GetFileName)!)["Informe anual 2024.txt"];

    [Fact]
    public async Task DosListingShowsAliasesThatResolve()
    {
        var page = await _server.GetAsync("/mac/?p=dos");
        Assert.Contains(Alias, page.BodyText);
        Assert.Contains("(Informe anual 2024.txt)", page.BodyText);
        Assert.DoesNotContain("._Nota", page.BodyText);

        var file = await _server.GetAsync("/mac/" + Alias + "?p=dos");
        Assert.Equal(200, file.Status);
        Assert.Equal("informe", file.BodyText);
        Assert.Contains(Alias, file.Headers["Content-Disposition"]);
    }

    [Fact]
    public async Task ModernListingOffersMacEncodings()
    {
        var page = await _server.GetAsync("/mac/");
        Assert.Contains("Nota?as=macbin", page.BodyText);
        Assert.Contains("Nota?as=hqx", page.BodyText);
        Assert.DoesNotContain("Informe%20anual%202024.txt?as=macbin", page.BodyText);

        var plain = await _server.GetAsync("/mac/Nota");
        Assert.Equal(Data, plain.Body);
    }

    [Fact]
    public async Task MacBinaryAndBinHexDownloads()
    {
        var bin = await _server.GetAsync("/mac/Nota?as=macbin");
        Assert.Equal(200, bin.Status);
        Assert.Equal("application/x-macbinary", bin.Headers["Content-Type"]);
        Assert.Contains("Nota.bin", bin.Headers["Content-Disposition"]);
        var header = MacBinary.Parse(bin.Body.AsSpan(0, MacBinary.HeaderSize), bin.Body.Length, false);
        Assert.NotNull(header);
        Assert.Equal("TEXT", header.File.Type);
        Assert.Equal(Data.Length, header.DataLength);

        var hqx = await _server.GetAsync("/mac/Nota?as=hqx");
        var decoded = BinHex.Decode(hqx.Body);
        Assert.NotNull(decoded);
        Assert.Equal(Data, decoded.Value.DataFork);
    }

    [Fact]
    public async Task ClassicMacGetsMacBinaryAutomatically()
    {
        var r = await _server.GetAsync("/mac/Nota?p=mac");
        Assert.Equal("application/x-macbinary", r.Headers["Content-Type"]);
        // Un fichero sin resource fork se entrega tal cual.
        var plain = await _server.GetAsync("/mac/" + Uri.EscapeDataString("Informe anual 2024.txt") + "?p=mac");
        Assert.Equal("informe", plain.BodyText);
    }

    [Fact]
    public async Task MacBinaryUploadIsUnpacked()
    {
        var page = await _server.GetAsync("/mac/");
        var token = System.Text.RegularExpressions.Regex.Match(page.BodyText,
            "NAME=\"a\" VALUE=\"upload\"><INPUT TYPE=HIDDEN NAME=\"t\" VALUE=\"([^\"]+)\"").Groups[1].Value;
        var encoded = await MacUnitTests.MacBinaryOf(MacUnitTests.Sample("Subido"), Encoding.ASCII.GetBytes("nuevo"));
        string body =
            "--B0und\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nupload\r\n" +
            $"--B0und\r\nContent-Disposition: form-data; name=\"t\"\r\n\r\n{token}\r\n" +
            "--B0und\r\nContent-Disposition: form-data; name=\"f\"; filename=\"Subido.bin\"\r\nContent-Type: application/x-macbinary\r\n\r\n" +
            Encoding.Latin1.GetString(encoded) + "\r\n--B0und--\r\n";
        var r = await _server.SendAsync("POST /mac/ HTTP/1.0\r\nContent-Type: multipart/form-data; boundary=B0und\r\n" +
                                        $"Content-Length: {Encoding.Latin1.GetByteCount(body)}\r\n\r\n{body}");
        Assert.Equal(302, r.Status);
        Assert.Equal("nuevo", File.ReadAllText(Path.Combine(_dir.Path, "Subido")));
        Assert.True(File.Exists(Path.Combine(_dir.Path, "._Subido")));
        Assert.False(File.Exists(Path.Combine(_dir.Path, "Subido.bin")));
    }

    [Fact]
    public async Task FtpDosProfileUsesAliases()
    {
        await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
        await c.LoginAsync("anonymous+dos");
        await c.CommandAsync("CWD /mac");
        string names = await c.ListAsync("NLST");
        Assert.Contains(Alias, names);
        Assert.DoesNotContain("Informe anual", names);
        var (code, data) = await c.PassiveAsync("RETR " + Alias);
        Assert.Equal(226, code);
        Assert.Equal("informe", Encoding.ASCII.GetString(data));
    }

    [Fact]
    public async Task FtpMacProfileSeesMacBinary()
    {
        await using var c = await FtpTestClient.ConnectAsync(_server.FtpPort);
        await c.LoginAsync("anonymous+mac");
        await c.CommandAsync("CWD /mac");
        string names = await c.ListAsync("NLST");
        Assert.Contains("Nota.bin", names);
        Assert.Contains("Informe anual 2024.txt", names);

        var size = await c.CommandAsync("SIZE Nota.bin");
        Assert.Equal(213, size.Code);
        var (code, data) = await c.PassiveAsync("RETR Nota.bin");
        Assert.Equal(226, code);
        Assert.Equal(long.Parse(size.Text.Split(' ')[^1]), data.Length);
        Assert.NotNull(MacBinary.Parse(data.AsSpan(0, MacBinary.HeaderSize), data.Length, false));

        // Y subir un MacBinary lo separa.
        var encoded = await MacUnitTests.MacBinaryOf(MacUnitTests.Sample("Desde FTP"), Encoding.ASCII.GetBytes("ftp"));
        (code, _) = await c.PassiveAsync("STOR Desde FTP.bin", encoded);
        Assert.Equal(226, code);
        Assert.Equal("ftp", File.ReadAllText(Path.Combine(_dir.Path, "Desde FTP")));
        Assert.True(File.Exists(Path.Combine(_dir.Path, "._Desde FTP")));
    }
}