Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/SIL.Machine/Corpora/DblBundleTextCorpus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Linq;
using System.Xml.Linq;
using SIL.IO;
using SIL.Machine.Utils;

namespace SIL.Machine.Corpora
{
Expand All @@ -17,7 +18,7 @@ public DblBundleTextCorpus(string fileName)
using (ZipArchive archive = ZipFile.OpenRead(fileName))
{
ZipArchiveEntry metadataEntry = archive.GetEntry("metadata.xml");
using (Stream stream = metadataEntry.Open())
using (Stream stream = metadataEntry.OpenBoundedStream())
{
var doc = XDocument.Load(stream);
var version = (string)doc.Root.Attribute("version");
Expand Down
3 changes: 2 additions & 1 deletion src/SIL.Machine/Corpora/ZipEntryStreamContainer.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.IO;
using System.IO.Compression;
using SIL.Machine.Utils;
using SIL.ObjectModel;

namespace SIL.Machine.Corpora
Expand All @@ -17,7 +18,7 @@ public ZipEntryStreamContainer(string archiveFileName, string entryPath)

public Stream OpenStream()
{
return _entry.Open();
return _entry.OpenBoundedStream();
}

protected override void DisposeManagedResources()
Expand Down
9 changes: 3 additions & 6 deletions src/SIL.Machine/Corpora/ZipParatextProjectFileHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.IO.Compression;
using System.Linq;
using SIL.IO;
using SIL.Machine.Utils;

namespace SIL.Machine.Corpora
{
Expand All @@ -26,19 +27,15 @@ public Stream Open(string fileName)
ZipArchiveEntry entry = _archive.Entries.FirstOrDefault(e =>
e.FullName.Equals(fileName, System.StringComparison.InvariantCultureIgnoreCase)
);
if (entry == null)
return null;
return entry.Open();
return entry?.OpenBoundedStream();
}

public string Find(string extension)
{
ZipArchiveEntry entry = _archive.Entries.FirstOrDefault(e =>
e.FullName.EndsWith(extension, System.StringComparison.InvariantCultureIgnoreCase)
);
if (entry == null)
return null;
return entry.FullName;
return entry?.FullName;
}

public UsfmStylesheet CreateStylesheet(string fileName)
Expand Down
96 changes: 96 additions & 0 deletions src/SIL.Machine/Utils/BoundedStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace SIL.Machine.Utils
{
public class BoundedStream : Stream
{
private readonly Stream _innerStream;
private readonly long _maxSize;
private long _totalBytesProcessed;

public BoundedStream(Stream innerStream, long maxSize)
{
_innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream));
_maxSize = maxSize < 0 ? throw new ArgumentOutOfRangeException(nameof(maxSize)) : maxSize;
}

public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => _innerStream.CanSeek;
public override bool CanWrite => _innerStream.CanWrite;
public override long Length => _innerStream.Length;

public override long Position
{
get => _innerStream.Position;
set => _innerStream.Position = value;
}

public override int Read(byte[] buffer, int offset, int count)
{
int bytesRead = _innerStream.Read(buffer, offset, count);
TrackAndValidate(bytesRead);
return bytesRead;
}

public override async Task<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken
)
{
int bytesRead = await _innerStream.ReadAsync(buffer, offset, count, cancellationToken);
TrackAndValidate(bytesRead);
return bytesRead;
}

public override void Write(byte[] buffer, int offset, int count)
{
TrackAndValidate(count);
_innerStream.Write(buffer, offset, count);
}

public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
TrackAndValidate(count);
await _innerStream.WriteAsync(buffer, offset, count, cancellationToken);
}

public override void Flush() => _innerStream.Flush();

public override Task FlushAsync(CancellationToken cancellationToken) =>
_innerStream.FlushAsync(cancellationToken);

public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);

public override void SetLength(long value)
{
if (value > _maxSize)
{
throw new IOException(
$"SetLength value of {value} bytes exceeds the maximum limit of {_maxSize} bytes."
);
}

_innerStream.SetLength(value);
}

private void TrackAndValidate(int bytesProcessed)
{
_totalBytesProcessed += bytesProcessed;
if (_totalBytesProcessed > _maxSize)
throw new IOException($"Stream operation aborted. Exceeded maximum limit of {_maxSize} bytes.");
}

protected override void Dispose(bool disposing)
{
if (disposing)
_innerStream.Dispose();

base.Dispose(disposing);
}
}
}
43 changes: 43 additions & 0 deletions src/SIL.Machine/Utils/ZipArchiveEntryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.IO;
using System.IO.Compression;

namespace SIL.Machine.Utils
{
public static class ZipArchiveEntryExtensions
{
private const long DefaultMaxUncompressedSize = 100 * 1024 * 1024; // 100 MB
private const double DefaultMaxCompressionRatio = 100.0; // 100:1 ratio limit

/// <summary>
/// Opens a bounded stream that will not be larger than the specified maximum uncompressed size.
/// </summary>
/// <param name="entry">The zip archive entry.</param>
/// <param name="maxUncompressedSize">The maximum uncompressed size allowed in bytes.</param>
/// <param name="maxCompressionRatio">The maximum compression ratio allowed.</param>
/// <returns>A bounded stream.</returns>
/// <exception cref="InvalidDataException">
/// The entry's uncompressed size or ratio exceeds the maximum allowed limit.
/// </exception>
public static BoundedStream OpenBoundedStream(
this ZipArchiveEntry entry,
long maxUncompressedSize = DefaultMaxUncompressedSize,
double maxCompressionRatio = DefaultMaxCompressionRatio
)
{
if (entry == null)
return null;

if (entry.Length > maxUncompressedSize)
throw new InvalidDataException("Entry uncompressed size exceeds maximum allowed limit.");

if (entry.CompressedLength > 0)
{
double ratio = (double)entry.Length / entry.CompressedLength;
if (ratio > maxCompressionRatio)
throw new InvalidDataException("Compression ratio exceeds safe threshold.");
}

return new BoundedStream(entry.Open(), maxUncompressedSize);
}
}
}
88 changes: 88 additions & 0 deletions tests/SIL.Machine.Tests/Utils/ZipArchiveEntryExtensionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.IO.Compression;
using NUnit.Framework;

namespace SIL.Machine.Utils;

[TestFixture]
public class ZipArchiveEntryExtensionTests
{
[Test]
public async Task OpenBoundedStream_ReturnsReadableStream()
{
byte[] contentBytes = [.. "Hello World"u8];
byte[] zipBytes = await CreateInMemoryZipFileAsync("test.txt", contentBytes);
using var memoryStream = new MemoryStream(zipBytes);
await using var archive = new ZipArchive(memoryStream);
ZipArchiveEntry? entry = archive.GetEntry("test.txt");

// SUT
await using BoundedStream stream = entry.OpenBoundedStream(maxUncompressedSize: 100);
using var reader = new StreamReader(stream);
string content = await reader.ReadToEndAsync();

Assert.That(content, Is.EqualTo("Hello World"));
}

[Test]
public async Task OpenBoundedStream_ThrowsInvalidDataExceptionWhenHeaderSizeExceeded()
{
byte[] payload = new byte[200];
byte[] zipBytes = await CreateInMemoryZipFileAsync("large.txt", payload);
using var memoryStream = new MemoryStream(zipBytes);
await using var archive = new ZipArchive(memoryStream);
ZipArchiveEntry? entry = archive.GetEntry("large.txt");

// SUT
Assert.Throws<InvalidDataException>(() => entry.OpenBoundedStream(maxUncompressedSize: 100));
}

[Test]
public async Task OpenBoundedStream_ThrowsInvalidDataExceptionWhenCompressionRatioExceeded()
{
byte[] highlyCompressibleData = new byte[10_000];
byte[] zipBytes = await CreateInMemoryZipFileAsync(
"bomb.txt",
highlyCompressibleData,
CompressionLevel.SmallestSize
);
using var memoryStream = new MemoryStream(zipBytes);
await using var archive = new ZipArchive(memoryStream);
ZipArchiveEntry? entry = archive.GetEntry("bomb.txt");

// SUT
Assert.Throws<InvalidDataException>(() =>
entry.OpenBoundedStream(maxUncompressedSize: 20_000, maxCompressionRatio: 2.0)
);
}

[Test]
public void BoundedStream_ThrowsIOExceptionWhenRuntimeExpansionExceedsLimit()
{
byte[] rawData = [.. "1234567890"u8];
using var memoryStream = new MemoryStream(rawData);
using var boundedStream = new BoundedStream(memoryStream, maxSize: 5);

byte[] buffer = new byte[10];

// SUT
boundedStream.ReadExactly(buffer, 0, 4);
Assert.Throws<IOException>(() => boundedStream.ReadExactly(buffer, 0, 4));
}

private static async Task<byte[]> CreateInMemoryZipFileAsync(
string fileName,
ReadOnlyMemory<byte> content,
CompressionLevel level = CompressionLevel.Fastest,
CancellationToken cancellationToken = default
)
{
using var ms = new MemoryStream();
await using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
ZipArchiveEntry entry = archive.CreateEntry(fileName, level);
await using Stream entryStream = await entry.OpenAsync(cancellationToken);
await entryStream.WriteAsync(content, cancellationToken);
}
return ms.ToArray();
}
}
Loading