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