Skip to content

Keep encoder output to non-seekable streams identical to a seekable save - #3232

Open
jafin wants to merge 1 commit into
SixLabors:mainfrom
jafin:jf/chunked-stream-overwrite
Open

jafin wants to merge 1 commit into
SixLabors:mainfrom
jafin:jf/chunked-stream-overwrite

Conversation

@jafin

@jafin jafin commented Oct 17, 2026 •

Copy link
Copy Markdown

Prerequisites

  • I have written a descriptive pull-request title
  • I have verified that there are no overlapping pull-requests open
  • I have verified that I am following the existing coding patterns and practice as demonstrated in the repository. These follow strict Stylecop rules 👮.
  • I have provided test coverage for my change (where applicable)

Description

When the destination stream cannot seek, ImageEncoder encodes into a ChunkedMemoryStream and copies the result to the destination. Two defects in ChunkedMemoryStream made that output differ from a seekable save, or made the save fail.

Overwriting data extended the stream. Write always added the bytes written to the length, also when it overwrote bytes after a seek back. The TIFF encoder seeks back to write each IFD offset (and BMP and ANI patch offsets the same way), so the buffered stream got longer by 4 bytes per patch. Those trailing bytes were uninitialized pooled memory, and they were copied to the destination. A TIFF save to a non-seekable stream, sync or async, therefore ended with stray bytes and did not match a seekable save.
Now the length only grows when a write ends after the current end of the data.

Seeking past the end failed or wrote at the wrong place. The ICO and CUR encoders seek forward past the directory, write the images, then go back and write the directory. On an empty buffer, SetPosition indexed chunk -1 and threw ArgumentOutOfRangeException, so saving ICO or CUR to a non-seekable stream always failed. With chunks allocated, a position at or after the end of the chunks was mapped to the last byte of the last chunk, so the next write started one byte early.
Now such a position maps to the start of the next chunk to allocate. Write finds the chunk again after it expands the buffer, if the position was past the end of the allocated chunks.

Tests

  • ChunkedMemoryStreamTests: an overwrite after a seek back keeps the length and data. A write after a seek past the end (empty stream, exactly at a chunk boundary, and across chunks) lands at the right position.
  • ImageTests.Save.ToNonSeekableStream_SyncAndAsync_MatchSeekableStream: for every built-in encoder, sync and async saves to a non-seekable stream are byte-identical to a seekable save. Before this change, this test failed for TIFF, ANI, ICO and CUR.
  • TiffEncoderTests.TiffEncoder_ToNonSeekableStream_MatchesSeekableStream: a two-frame TIFF, so that more than one IFD offset is patched.

The Release build passes for net10.0. These test sets pass (3879 tests): Tiff, Ico, Cur, Ani and Bmp formats, ImageTests, GeneralFormatTests, ImageDecoder, and ChunkedMemoryStreamTests (which includes the non-seekable decode/encode integration tests over all test images).

Fixes #3244

Encoding to a non-seekable stream buffers the output in a ChunkedMemoryStream.
Overwriting bytes after seeking back, as the TIFF, BMP and ANI encoders do to
patch offsets, extended the stream length, so trailing uninitialized bytes were
copied to the destination. Seeking past the end of the buffered data, as the
ICO and CUR encoders do to leave room for the directory, threw an
ArgumentOutOfRangeException on an empty buffer and wrote one byte early at a
chunk boundary.

The length now only grows when a write ends past the current end, and a
position beyond the allocated chunks is resolved after the buffer expands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Saving TIFF, ANI, ICO or CUR to a non-seekable stream writes extra bytes or throws ArgumentOutOfRangeException

1 participant