namespace Sunfish.Mac;
/// <summary>
/// Mac clasico de forma transparente sobre el disco (DOC/MAC-CLASICO.md): al subir, un MacBinary
/// o un BinHex validos se separan en "nombre" + "._nombre" (AppleDouble); al descargar se vuelven
/// a juntar. Si el fichero no tiene nada propio de Mac no se toca.
/// </summary>
public static class MacFiles
{
/// <summary>Limite para BinHex (se decodifica en memoria).</summary>
public const long MaxBinHex = 256L * 1024 * 1024;
public enum Unpacked { NotMac, Unpacked, ExistsKept }
/// <summary>
/// Si el fichero recien subido es MacBinary (II/III, o I si <paramref name="acceptVersion1"/>)
/// o BinHex con CRC correctos, lo separa en data fork + AppleDouble con el nombre que trae.
/// Si ese nombre ya existe y no se permite sobrescribir, deja el fichero codificado como
/// estaba (no se pierde nada). Devuelve que ha pasado y el nombre final.
/// </summary>
public static async Task<(Unpacked Result, string Name)> TryUnpackAsync(string path, bool overwrite, bool acceptVersion1, CancellationToken ct)
{
string directory = Path.GetDirectoryName(path)!;
string original = Path.GetFileName(path);
var info = new FileInfo(path);
if (!info.Exists || info.Length < MacBinary.HeaderSize)
return (Unpacked.NotMac, original);
var head = new byte[MacBinary.HeaderSize];
await using (var probe = File.OpenRead(path))
await probe.ReadExactlyAsync(head, ct);
bool v1 = acceptVersion1 && original.EndsWith(".bin", StringComparison.OrdinalIgnoreCase);
if (MacBinary.Parse(head, info.Length, v1) is { } header)
{
string name = SafeName(header.File.Name, original, ".bin");
string target = Path.Combine(directory, name);
if (!CanWrite(target, path, overwrite))
return (Unpacked.ExistsKept, original);
string temp = Path.Combine(directory, Shares.FileOperations.TempPrefix + Guid.NewGuid().ToString("N") + ".tmp");
MacFile file;
await using (var input = File.OpenRead(path))
await using (var output = File.Create(temp))
file = await MacBinary.DecodeAsync(input, header, output, ct);
Finish(path, temp, target, file);
return (Unpacked.Unpacked, name);
}
if (info.Length <= MaxBinHex && BinHex.LooksLikeBinHex(await ReadStartAsync(path, ct)) &&
BinHex.Decode(await File.ReadAllBytesAsync(path, ct)) is { } hqx)
{
string name = SafeName(hqx.File.Name, original, ".hqx");
string target = Path.Combine(directory, name);
if (!CanWrite(target, path, overwrite))
return (Unpacked.ExistsKept, original);
string temp = Path.Combine(directory, Shares.FileOperations.TempPrefix + Guid.NewGuid().ToString("N") + ".tmp");
await File.WriteAllBytesAsync(temp, hqx.DataFork, ct);
Finish(path, temp, target, hqx.File);
return (Unpacked.Unpacked, name);
}
return (Unpacked.NotMac, original);
}
private static async Task<byte[]> ReadStartAsync(string path, CancellationToken ct)
{
var buffer = new byte[4096];
await using var stream = File.OpenRead(path);
int n = await stream.ReadAtLeastAsync(buffer, buffer.Length, throwOnEndOfStream: false, ct);
return buffer[..n];
}
private static bool CanWrite(string target, string source, bool overwrite) =>
string.Equals(target, source, StringComparison.OrdinalIgnoreCase) || overwrite || (!File.Exists(target) && !Directory.Exists(target));
/// <summary>Coloca el data fork, escribe el AppleDouble si hace falta y borra el codificado.</summary>
private static void Finish(string encoded, string temp, string target, MacFile file)
{
File.Move(temp, target, overwrite: true);
if (file.Modified is { } modified)
File.SetLastWriteTime(target, modified);
string companion = AppleDouble.CompanionOf(target);
if (file.HasMacData)
File.WriteAllBytes(companion, AppleDouble.Write(file));
else if (File.Exists(companion))
File.Delete(companion);
if (!string.Equals(encoded, target, StringComparison.OrdinalIgnoreCase))
File.Delete(encoded);
}
/// <summary>
/// Nombre de destino a partir del que trae la cabecera: valido en este sistema; si no lo es,
/// el del fichero subido sin la extension de codificacion.
/// </summary>
internal static string SafeName(string macName, string uploaded, string encodingExtension)
{
string clean = new string(macName.Select(c => c is '/' or '\\' or ':' || char.IsControl(c) ? '_' : c).ToArray()).Trim().TrimEnd('.');
if (Shares.FileOperations.IsCreatableName(clean))
return clean;
string fallback = uploaded.EndsWith(encodingExtension, StringComparison.OrdinalIgnoreCase) ? uploaded[..^encodingExtension.Length] : uploaded;
return Shares.FileOperations.IsCreatableName(fallback) ? fallback : uploaded;
}
/// <summary>El fichero con su AppleDouble, o con tipo y creador por extension si no lo tiene.</summary>
public static MacFile Describe(string physical)
{
var file = AppleDouble.ReadCompanion(physical) ?? new MacFile();
file.Name = Path.GetFileName(physical);
if (file.FinderInfo.Take(8).All(b => b == 0))
{
var (type, creator) = MacNames.TypeFor(file.Name);
file.Type = type;
file.Creator = creator;
}
file.Modified ??= File.GetLastWriteTime(physical);
return file;
}
/// <summary>Tiene un AppleDouble con resource fork o datos del Finder.</summary>
public static bool HasCompanion(string physical) => File.Exists(AppleDouble.CompanionOf(physical));
}