using System.Buffers.Binary;
using System.Text;
using Sunfish.Shares;
namespace Sunfish.Web;
/// <summary>
/// ZIP generado en flujo, sin fichero temporal, pensado para descompresores antiguos (PKZIP 2.x,
/// StuffIt): metodo Stored, CRC calculado antes de cada fichero (sin data descriptors) y tamano
/// total conocido de antemano (Content-Length, para que el navegador muestre el progreso).
/// Nombres no ASCII: para clientes antiguos en CP437 si se pueden representar (lo que esperan
/// PKZIP y compania); para los modernos, y si CP437 no basta, en UTF-8 con su indicador (los
/// descompresores actuales leen como UTF-8 los nombres sin indicador).
/// Cada fichero se lee dos veces (CRC y envio). Sin ZIP64: hasta 4 GiB y 65535 entradas.
/// </summary>
internal sealed class ZipStreamWriter
{
public sealed record Entry(string Name, string? Physical, long Size, DateTime Modified)
{
public bool IsDirectory => Physical is null;
}
private sealed record Planned(Entry Entry, byte[] Name, bool Utf8, long Offset);
private readonly List<Planned> _plan = new();
public long TotalLength { get; }
public int Count => _plan.Count;
public const long MaxLength = uint.MaxValue - 1;
public const int MaxEntries = ushort.MaxValue - 1;
public ZipStreamWriter(IEnumerable<Entry> entries, bool preferUtf8)
{
long offset = 0;
long central = 0;
foreach (var entry in entries)
{
var (name, utf8) = EncodeName(entry.Name, preferUtf8);
_plan.Add(new Planned(entry, name, utf8, offset));
offset += 30 + name.Length + entry.Size;
central += 46 + name.Length;
}
TotalLength = offset + central + 22;
}
public bool TooLarge => TotalLength > MaxLength || _plan.Count > MaxEntries;
/// <summary>Recorre una carpeta para el ZIP: sin ocultos ni enlaces (no se siguen).</summary>
public static List<Entry> Collect(string directory, string prefix = "")
{
var result = new List<Entry>();
foreach (var e in ShareFileSystem.List(directory).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
{
if (e.IsLink)
continue;
string physical = Path.Combine(directory, e.Name);
string name = prefix + e.Name;
if (e.IsDirectory)
{
result.Add(new Entry(name + "/", null, 0, e.Modified));
result.AddRange(Collect(physical, name + "/"));
}
else
{
result.Add(new Entry(name, physical, e.Size, e.Modified));
}
}
return result;
}
public async Task WriteAsync(Stream output, CancellationToken ct)
{
var crcs = new uint[_plan.Count];
var buffer = new byte[65536];
for (int i = 0; i < _plan.Count; i++)
{
var p = _plan[i];
uint crc = 0;
if (!p.Entry.IsDirectory)
crc = await Crc32.OfFileAsync(p.Entry.Physical!, p.Entry.Size, buffer, ct);
crcs[i] = crc;
await output.WriteAsync(LocalHeader(p, crc), ct);
if (!p.Entry.IsDirectory)
await CopyExactAsync(p.Entry.Physical!, p.Entry.Size, output, buffer, ct);
}
long centralStart = _plan.Count == 0 ? 0 : _plan[^1].Offset + 30 + _plan[^1].Name.Length + _plan[^1].Entry.Size;
long centralSize = 0;
for (int i = 0; i < _plan.Count; i++)
{
var header = CentralHeader(_plan[i], crcs[i]);
centralSize += header.Length;
await output.WriteAsync(header, ct);
}
await output.WriteAsync(EndOfCentralDirectory(centralStart, centralSize), ct);
}
private static async Task CopyExactAsync(string path, long size, Stream output, byte[] buffer, CancellationToken ct)
{
await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true);
long remaining = size;
while (remaining > 0)
{
int n = await file.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ct);
if (n == 0)
throw new IOException("File changed while zipping: " + path);
await output.WriteAsync(buffer.AsMemory(0, n), ct);
remaining -= n;
}
}
private static byte[] LocalHeader(Planned p, uint crc)
{
var h = new byte[30 + p.Name.Length];
var s = h.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(s, 0x04034b50);
BinaryPrimitives.WriteUInt16LittleEndian(s[4..], 20);
BinaryPrimitives.WriteUInt16LittleEndian(s[6..], Flags(p));
BinaryPrimitives.WriteUInt16LittleEndian(s[8..], 0); // Stored
WriteDosTime(s[10..], p.Entry.Modified);
BinaryPrimitives.WriteUInt32LittleEndian(s[14..], crc);
BinaryPrimitives.WriteUInt32LittleEndian(s[18..], (uint)p.Entry.Size);
BinaryPrimitives.WriteUInt32LittleEndian(s[22..], (uint)p.Entry.Size);
BinaryPrimitives.WriteUInt16LittleEndian(s[26..], (ushort)p.Name.Length);
BinaryPrimitives.WriteUInt16LittleEndian(s[28..], 0);
p.Name.CopyTo(s[30..]);
return h;
}
private static byte[] CentralHeader(Planned p, uint crc)
{
var h = new byte[46 + p.Name.Length];
var s = h.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(s, 0x02014b50);
BinaryPrimitives.WriteUInt16LittleEndian(s[4..], 20); // creado por: MS-DOS, version 2.0
BinaryPrimitives.WriteUInt16LittleEndian(s[6..], 20);
BinaryPrimitives.WriteUInt16LittleEndian(s[8..], Flags(p));
BinaryPrimitives.WriteUInt16LittleEndian(s[10..], 0);
WriteDosTime(s[12..], p.Entry.Modified);
BinaryPrimitives.WriteUInt32LittleEndian(s[16..], crc);
BinaryPrimitives.WriteUInt32LittleEndian(s[20..], (uint)p.Entry.Size);
BinaryPrimitives.WriteUInt32LittleEndian(s[24..], (uint)p.Entry.Size);
BinaryPrimitives.WriteUInt16LittleEndian(s[28..], (ushort)p.Name.Length);
// extra, comentario, disco y atributos internos a cero
BinaryPrimitives.WriteUInt32LittleEndian(s[38..], p.Entry.IsDirectory ? 0x10u : 0x20u);
BinaryPrimitives.WriteUInt32LittleEndian(s[42..], (uint)p.Offset);
p.Name.CopyTo(s[46..]);
return h;
}
private byte[] EndOfCentralDirectory(long centralStart, long centralSize)
{
var h = new byte[22];
var s = h.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(s, 0x06054b50);
BinaryPrimitives.WriteUInt16LittleEndian(s[8..], (ushort)_plan.Count);
BinaryPrimitives.WriteUInt16LittleEndian(s[10..], (ushort)_plan.Count);
BinaryPrimitives.WriteUInt32LittleEndian(s[12..], (uint)centralSize);
BinaryPrimitives.WriteUInt32LittleEndian(s[16..], (uint)centralStart);
return h;
}
private static ushort Flags(Planned p) => p.Utf8 ? (ushort)0x0800 : (ushort)0;
internal static (byte[] Name, bool Utf8) EncodeName(string name, bool preferUtf8)
{
if (name.All(char.IsAscii))
return (Encoding.ASCII.GetBytes(name), false);
if (preferUtf8)
return (Encoding.UTF8.GetBytes(name), true);
try
{
return (Encodings.Cp437Strict.GetBytes(name), false);
}
catch (EncoderFallbackException)
{
return (Encoding.UTF8.GetBytes(name), true);
}
}
private static void WriteDosTime(Span<byte> s, DateTime time)
{
if (time.Year < 1980)
time = new DateTime(1980, 1, 1);
if (time.Year > 2107)
time = new DateTime(2107, 12, 31, 23, 59, 58);
ushort dosTime = (ushort)((time.Hour << 11) | (time.Minute << 5) | (time.Second / 2));
ushort dosDate = (ushort)(((time.Year - 1980) << 9) | (time.Month << 5) | time.Day);
BinaryPrimitives.WriteUInt16LittleEndian(s, dosTime);
BinaryPrimitives.WriteUInt16LittleEndian(s[2..], dosDate);
}
}
/// <summary>CRC-32 (el de ZIP). System.IO.Hashing no esta en la BCL.</summary>
internal static class Crc32
{
private static readonly uint[] Table = BuildTable();
private static uint[] BuildTable()
{
var table = new uint[256];
for (uint i = 0; i < 256; i++)
{
uint c = i;
for (int k = 0; k < 8; k++)
c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
table[i] = c;
}
return table;
}
public static uint Update(uint crc, ReadOnlySpan<byte> data)
{
crc = ~crc;
foreach (byte b in data)
crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
return ~crc;
}
public static async Task<uint> OfFileAsync(string path, long size, byte[] buffer, CancellationToken ct)
{
await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, useAsync: true);
uint crc = 0;
long remaining = size;
while (remaining > 0)
{
int n = await file.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ct);
if (n == 0)
throw new IOException("File changed while zipping: " + path);
crc = Update(crc, buffer.AsSpan(0, n));
remaining -= n;
}
return crc;
}
}