Files
SocialPub/PrivaPub/Domain/Portability/SafeArchive.cs
T

270 lines
8.9 KiB
C#
Raw Normal View History

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<string, ZipArchiveEntry> _entries;
readonly string _root;//the folder the archive's files sit in, when it wraps them in one
SafeArchive(ZipArchive zip, Dictionary<string, ZipArchiveEntry> 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<string, ZipArchiveEntry>(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;
/// <summary>A file's contents, never more than its stated size.</summary>
public Stream Read(string name) =>
_entries.TryGetValue(_root + name, out var entry) ? new Bounded(entry.Open(), entry.Length) : default;
/// <summary>A JSON file, or null when it is missing, too large or not JSON.</summary>
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;
}
}
/// <summary>The lines of a text file (a CSV), none when it is missing or too large.</summary>
public List<string> 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<string>();
while (reader.ReadLine() is { } line)
if (line.Length > 0)
lines.Add(line);
return lines;
}
/// <summary>outbox.json's orderedItems, one object at a time; an item larger than MaxItemBytes stops it.</summary>
public async IAsyncEnumerable<JsonObject> 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<JsonObject> Items, int Consumed, JsonReaderState State, Phase Phase) Scan(ReadOnlySpan<byte> data, bool final, JsonReaderState state, Phase phase)
{
var items = new List<JsonObject>();
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<byte> 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<int> ReadAsync(Memory<byte> 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<int> 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);
}
}
}
}