Skip to content
Merged
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
40 changes: 38 additions & 2 deletions src/XLSight/Internal/Readers/Xlsx/ScanBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace XLSight.Internal.Readers.Xlsx;
internal sealed class ScanBuffer : IDisposable
{
private const int BufferSize = 65536;
private const int MaxBufferSize = 16 * 1024 * 1024;

private readonly Stream _source;
private byte[] _buf;
Expand Down Expand Up @@ -45,7 +46,7 @@ internal ScanBuffer(Stream source)
/// <summary>Current unconsumed window as a span.</summary>
internal ReadOnlySpan<byte> Span => _buf.AsSpan(_start, _end - _start);

internal bool CanReadMore => !_streamDone && (_start > 0 || _end < BufferSize);
internal bool CanReadMore => !_streamDone && (_start > 0 || _end < _buf.Length);

/// <summary>
/// Resets the buffer pointers and refills from the underlying stream's current position.
Expand Down Expand Up @@ -105,7 +106,7 @@ internal bool Refill()
// (DeflateStream/zlib-ng returns data in variable-size chunks.)
if (!_streamDone)
{
int space = BufferSize - _end;
int space = _buf.Length - _end;
if (space > 0)
{
int bytesRead = _source.Read(_buf, _end, space);
Expand All @@ -132,6 +133,12 @@ internal bool Refill()
/// Because <see cref="TryWithoutIO"/> skips compaction, the buffer start pointer is
/// already restored before this method is called, so compaction shifts from the
/// correct position.
/// <para>
/// When a pending token already spans the entire buffer (e.g. a very long inline
/// string) there are zero consumed bytes to compact away, so the buffer is grown
/// instead — otherwise this method would report success without reading anything,
/// and the caller's parse/refill loop would spin forever re-parsing the same bytes.
/// </para>
/// </remarks>
internal async ValueTask<bool> RefillAsync(CancellationToken ct = default)
{
Expand All @@ -149,6 +156,12 @@ internal async ValueTask<bool> RefillAsync(CancellationToken ct = default)
if (!_streamDone)
{
int space = _buf.Length - _end;
if (space == 0)
{
Grow();
space = _buf.Length - _end;
}

while (space > 0)
{
int bytesRead = await _source.ReadAsync(_buf.AsMemory(_end, space), ct).ConfigureAwait(false);
Expand All @@ -165,6 +178,29 @@ internal async ValueTask<bool> RefillAsync(CancellationToken ct = default)
return _end > _start;
}

/// <summary>
/// Doubles the buffer's capacity, preserving all unconsumed bytes at the front.
/// Only called when a pending token doesn't fit in the current buffer at all
/// (no bytes available to compact away).
/// </summary>
/// <exception cref="MalformedWorkbookException">
/// The doubled size would exceed <see cref="MaxBufferSize"/> — the pending row/token
/// is treated as corrupt rather than growing the buffer without bound.
/// </exception>
private void Grow()
{
long doubled = (long)_buf.Length * 2;
if (doubled > MaxBufferSize)
{
throw new MalformedWorkbookException("Worksheet row exceeds the supported maximum size.");
}

byte[] grown = ArrayPool<byte>.Shared.Rent((int)doubled);
_buf.AsSpan(0, _end).CopyTo(grown);
ArrayPool<byte>.Shared.Return(_buf);
_buf = grown;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// <summary>True when the stream is exhausted and <see cref="Span"/> is empty.</summary>
internal bool IsExhausted => _streamDone && _start >= _end;

Expand Down
46 changes: 46 additions & 0 deletions tests/XLSight.Tests/Readers/Xlsx/ScanBufferTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,52 @@ public async Task RefillAsync_EmptyStreamAfterConsumingAll_ReturnsFalse()
Assert.False(result);
}

// Regression: a token that fills the buffer with start == 0 left RefillAsync unable
// to compact or read, so it reported success with zero new bytes.
[Fact]
public async Task RefillAsync_AfterFullBufferRewind_GrowsInsteadOfStalling()
{
const int BufferSize = 65536;
var data = new byte[BufferSize + 10];
data.AsSpan(BufferSize).Fill((byte)'Z');
using var stream = new MemoryStream(data);
using var buf = new ScanBuffer(stream);

// Constructor priming fills the buffer completely.
Assert.Equal(BufferSize, buf.Span.Length);

// A token that doesn't fit: the parse consumes nothing and rewinds.
bool parsed = buf.TryWithoutIO(() => buf.Refill());
Assert.False(parsed);
Assert.True(buf.LastParseNeededIO);
Assert.Equal(BufferSize, buf.Span.Length);

bool result = await buf.RefillAsync(TestContext.Current.CancellationToken);

Assert.True(result);
Assert.True(buf.Span.Length > BufferSize, "RefillAsync must grow the buffer to make progress.");
Assert.Equal(BufferSize + 10, buf.Span.Length);
}

// Regression: unbounded growth from a token that never completes (corrupt or
// adversarial input) must stop with a parse error, not grow until OOM.
[Fact]
public async Task RefillAsync_GrowthExceedsMax_ThrowsMalformedWorkbookException()
{
var data = new byte[32 * 1024 * 1024];
using var stream = new MemoryStream(data);
using var buf = new ScanBuffer(stream);

await Assert.ThrowsAsync<MalformedWorkbookException>(async () =>
{
while (true)
{
buf.TryWithoutIO(() => buf.Refill());
await buf.RefillAsync(TestContext.Current.CancellationToken);
}
});
}

// ── IsExhausted ──────────────────────────────────────────────────────────

[Fact]
Expand Down
36 changes: 36 additions & 0 deletions tests/XLSight.Tests/Readers/Xlsx/SheetCursorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,42 @@ public async Task TryParseNext_InlineStringTagNameCollision_Terminates()
Assert.Equal("Item_3", rows[2].GetCell(1).AsText());
}

// Regression: a row larger than the 64 KiB ScanBuffer window (e.g. a long inline
// string) rewound the buffer to start on every attempt, and RefillAsync couldn't
// compact or read more space, so the loop never terminated.
[Fact]
public async Task TryParseNext_RowLargerThanBuffer_Terminates()
{
string hugeText = new string('A', 100_000);
using var cursor = OpenCursor($"""
<worksheet xmlns="{Ns}">
<sheetData>
<row r="1"><c r="A1" t="inlineStr"><is><t>{hugeText}</t></is></c></row>
<row r="2"><c r="A2"><v>2</v></c></row>
</sheetData>
</worksheet>
""");

var rows = new List<ExcelRow>();
int attempts = 0;
while (attempts++ < 1000)
{
if (cursor.TryParseNext(out var row))
{
rows.Add(row.ToSnapshot());
continue;
}

if (cursor.IsSheetDone) { break; }
if (!await cursor.RefillAsync()) { break; }
}

Assert.True(attempts < 1000, "TryParseNext/RefillAsync loop did not terminate for a row larger than the buffer.");
Assert.Equal(2, rows.Count);
Assert.Equal(hugeText, rows[0].GetCell(1).AsText());
Assert.Equal(2.0, rows[1].GetCell(1).AsNumber());
}

// ── Column projection ─────────────────────────────────────────────────────

[Fact]
Expand Down
Loading