using System.IO.Compression; using System.Text.Json; using System.Text.Json.Nodes; namespace PrivaPub.Domain.Portability { // An archive someone uploads, opened only if nothing in it can harm the server: no link, no path leaving it (.. or // absolute), no name twice, at most MaxEntries files and MaxBytes in all (and what the disk has), and no file // compressed more than MaxRatio to one. Every file is read through a stream that stops at its stated size, and the // outbox one item at a time, none larger than MaxItemBytes, so a large archive never sits in memory. public sealed class SafeArchive : IDisposable { public const int MaxEntries = 200_000; public const long MaxBytes = 64L * 1024 * 1024 * 1024; public const int MaxRatio = 100; public const int MaxItemBytes = 1024 * 1024; public const int MaxJsonBytes = 64 * 1024 * 1024; readonly ZipArchive _zip; readonly Dictionary _entries; readonly string _root;//the folder the archive's files sit in, when it wraps them in one SafeArchive(ZipArchive zip, Dictionary entries, string root) { _zip = zip; _entries = entries; _root = root; } public static (SafeArchive Archive, string Error) Open(string path) { ZipArchive zip; try { zip = ZipFile.OpenRead(path); } catch (InvalidDataException) { return (default, "not a zip archive"); } var entries = new Dictionary(StringComparer.Ordinal); var total = 0L; var free = new DriveInfo(Path.GetFullPath(path)).AvailableFreeSpace; string Refuse(string why) { zip.Dispose(); return why; } if (zip.Entries.Count > MaxEntries) return (default, Refuse("too many files")); foreach (var entry in zip.Entries) { var name = entry.FullName; if (name.Contains('\\') || name.StartsWith('/') || name.Contains(':') || name.Split('/').Any(part => part is ".." or ".")) return (default, Refuse($"{name}: a path that leaves the archive")); if (((entry.ExternalAttributes >> 16) & 0xF000) == 0xA000) return (default, Refuse($"{name}: a link")); if (name.EndsWith('/')) continue; if (!entries.TryAdd(name, entry)) return (default, Refuse($"{name}: twice")); total += entry.Length; if (total > MaxBytes || total > free) return (default, Refuse("larger than the server can take")); if (entry.Length > 1024 * 1024 && entry.Length > (long)MaxRatio * Math.Max(1, entry.CompressedLength)) return (default, Refuse($"{name}: compressed more than {MaxRatio} to one")); } // some archivers wrap everything in one folder var root = string.Empty; if (!entries.ContainsKey("actor.json") && entries.Keys.FirstOrDefault(k => k.EndsWith("/actor.json", StringComparison.Ordinal)) is { } nested && nested.Count(c => c == '/') == 1) root = nested[..(nested.IndexOf('/') + 1)]; return (new SafeArchive(zip, entries, root), default); } public bool Has(string name) => _entries.ContainsKey(_root + name); public long Length(string name) => _entries.TryGetValue(_root + name, out var entry) ? entry.Length : -1; /// A file's contents, never more than its stated size. public Stream Read(string name) => _entries.TryGetValue(_root + name, out var entry) ? new Bounded(entry.Open(), entry.Length) : default; /// A JSON file, or null when it is missing, too large or not JSON. public JsonNode Json(string name) { if (!_entries.TryGetValue(_root + name, out var entry) || entry.Length > MaxJsonBytes) return default; try { using var stream = Read(name); return JsonNode.Parse(stream); } catch (JsonException) { return default; } } /// The lines of a text file (a CSV), none when it is missing or too large. public List Lines(string name) { if (!_entries.TryGetValue(_root + name, out var entry) || entry.Length > MaxJsonBytes) return []; using var reader = new StreamReader(Read(name)); var lines = new List(); while (reader.ReadLine() is { } line) if (line.Length > 0) lines.Add(line); return lines; } /// outbox.json's orderedItems, one object at a time; an item larger than MaxItemBytes stops it. public async IAsyncEnumerable Outbox([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token) { await using var stream = Read("outbox.json"); if (stream == default) yield break; var buffer = new byte[64 * 1024]; var filled = 0; var final = false; var state = new JsonReaderState(); var phase = Phase.Seeking; while (phase != Phase.Done) { if (!final) { if (filled == buffer.Length) { if (buffer.Length > MaxItemBytes + 64 * 1024) throw new InvalidDataException($"an outbox item larger than {MaxItemBytes / 1024} KiB"); Array.Resize(ref buffer, buffer.Length * 2); } var read = await stream.ReadAsync(buffer.AsMemory(filled), token); filled += read; final = read == 0; } var (items, consumed, next, after) = Scan(buffer.AsSpan(0, filled), final, state, phase); foreach (var item in items) yield return item; (state, phase) = (next, after); Buffer.BlockCopy(buffer, consumed, buffer, 0, filled - consumed); filled -= consumed; if (final && consumed == 0 && items.Count == 0) break; } } enum Phase { Seeking, Array, Done } static (List Items, int Consumed, JsonReaderState State, Phase Phase) Scan(ReadOnlySpan data, bool final, JsonReaderState state, Phase phase) { var items = new List(); var reader = new Utf8JsonReader(data, final, state); while (phase != Phase.Done) { var mark = reader; if (!reader.Read()) { reader = mark; break; } if (phase == Phase.Seeking) { if (reader.TokenType == JsonTokenType.PropertyName && reader.CurrentDepth == 1 && reader.ValueTextEquals("orderedItems")) { var before = reader; if (!reader.Read()) { reader = mark; break; } if (reader.TokenType == JsonTokenType.StartArray) phase = Phase.Array; else { reader = before; if (!reader.TrySkip()) { reader = mark; break; } } continue; } if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray && reader.CurrentDepth >= 1 && !reader.TrySkip()) { reader = mark; break; } continue; } if (reader.TokenType == JsonTokenType.EndArray) { phase = Phase.Done; break; } if (reader.TokenType != JsonTokenType.StartObject) { if (reader.TokenType == JsonTokenType.StartArray && !reader.TrySkip()) { reader = mark; break; } continue; } var start = (int)reader.TokenStartIndex; if (!reader.TrySkip()) { reader = mark; break; } var length = (int)reader.BytesConsumed - start; if (length > MaxItemBytes) throw new InvalidDataException($"an outbox item larger than {MaxItemBytes / 1024} KiB"); if (JsonNode.Parse(data.Slice(start, length)) is JsonObject item) items.Add(item); } return (items, (int)reader.BytesConsumed, reader.CurrentState, phase); } public void Dispose() => _zip.Dispose(); // a stream that ends at the size the archive states, whatever the compressed data says sealed class Bounded(Stream inner, long length) : Stream { long _left = length; public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); public override int Read(Span buffer) { if (_left <= 0) return 0; var read = inner.Read(buffer[..(int)Math.Min(buffer.Length, _left)]); _left -= read; return read; } public override async ValueTask ReadAsync(Memory buffer, CancellationToken token = default) { if (_left <= 0) return 0; var read = await inner.ReadAsync(buffer[..(int)Math.Min(buffer.Length, _left)], token); _left -= read; return read; } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken token) => ReadAsync(buffer.AsMemory(offset, count), token).AsTask(); public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => length; public override long Position { get => length - _left; set => throw new NotSupportedException(); } public override void Flush() { } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); protected override void Dispose(bool disposing) { if (disposing) inner.Dispose(); base.Dispose(disposing); } } } }