Skip to content

Tolerate GIFs missing the final trailer byte (0x3B)#237

Open
lilith wants to merge 1 commit into
image-rs:masterfrom
lilith:tolerate-missing-trailer
Open

Tolerate GIFs missing the final trailer byte (0x3B)#237
lilith wants to merge 1 commit into
image-rs:masterfrom
lilith:tolerate-missing-trailer

Conversation

@lilith
Copy link
Copy Markdown
Contributor

@lilith lilith commented May 30, 2026

Problem

A surprising number of real-world GIFs omit the final trailer block (0x3B). NB: This crate, if the encoder is not dropped before accessing the stream, also fails to emit the final trailer block. This is a consumer usage error but a light footgun I've personally run into. Browsers, ImageMagick, and PIL all decode them without complaint. Currently, after decoding every frame, the decoder hits EOF while looking for the next block introducer and surfaces UnexpectedEof — so a strict consumer (e.g. the image crate's collect path) rejects a fully-decodable GIF as corrupt.

Concrete repro

A real 67-frame GIF in the wild (example, 1.97 MB, 260×342) ends with 0x00 (the block terminator of its last image sub-block) and has no 0x3B trailer.

  • gif 0.14.2 and current master: decode all 67 frames correctly (RGBA buffers match ImageMagick / PIL), then return Err(UnexpectedEof).
  • ImageMagick and PIL both accept it (67 frames).

Fix

This relaxes only the clean-boundary case: when the decoder is between blocks — the previous frame fully completed and the next byte it expects is a block introducer/trailer — and at least one frame has been decoded, a trailing EOF is reported as a clean end-of-stream (Ok(None)) instead of an error.

Concretely, in ReadDecoder::decode_next, when fill_buf() returns empty:

if self.any_frame_decoded && self.decoder.is_at_block_boundary() {
    self.at_eof = true;
    break; // clean end-of-stream
}
return Err(DecodingError::UnexpectedEof);

is_at_block_boundary() is true only for the BlockStart(_) / BlockEnd states (waiting for the next block introducer). any_frame_decoded is set when a frame's Decoded::DataEnd is observed.

What still errors (zero-trust boundary)

  • EOF in the middle of a frame (truncated image data / incomplete LZW sub-block / partial header) — genuine truncation → still UnexpectedEof / Truncated.
  • EOF before any frame (zero-frames-then-EOF) — malformed → still errors.
  • Any non-EOF error is unchanged.

This extends the same "accept non-terminated streams by cutting off when all bytes are decoded" philosophy noted in #235 to the missing-end-trailer case. It mirrors a guard we ship downstream.

Tests (tests/missing_trailer.rs)

  • Positive: a valid two-frame GIF with the 0x3B trailer stripped decodes all frames cleanly, pixel-identical to the with-trailer decode.
  • Negative (zero-trust): a GIF truncated mid-frame (frame 1 fully decodes, frame 2's data is cut) still errors — proving the relaxation doesn't open a truncation hole even after a complete frame.
  • Negative: header-only / zero-frame-then-EOF still errors.

The full existing suite passes unchanged (trailer-present, truncation, stall/byte-at-a-time streaming).

Notes

  • No public API change (is_at_block_boundary is pub(crate); any_frame_decoded is a private field).
  • MSRV unchanged (1.85) — pure logic, no dependency change. Verified building + testing on 1.85.
  • cargo fmt clean; clippy clean on the touched files.

Comment thread src/reader/decoder.rs Outdated
/// are partway through a structure (image data, a sub-block, a palette,
/// an extension, …) and an EOF there is genuine truncation.
pub(crate) fn is_at_block_boundary(&self) -> bool {
matches!(self.state, BlockStart(_) | BlockEnd)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BlockStart means a block already started parsing, which is a bug here

lilith added a commit to lilith/image-gif that referenced this pull request Jun 3, 2026
Addresses kornelski's review on PR image-rs#237: `is_at_block_boundary` matched
`BlockStart(_) | BlockEnd`, but `BlockStart(type_)` is reached only after the
next block introducer byte was already read in `BlockEnd` — a block has started
parsing and its body is now expected. EOF there is a truncated block, not a
missing trailer.

The `any_frame_decoded` guard masked the hole for the header-only case, but a
stream with a complete first frame followed by a bare introducer byte and then
EOF was silently accepted with the truncated trailing block dropped (decode
returned only frame 1, no error).

Restrict the boundary to `BlockEnd` (the post-frame / post-extension resting
state where the introducer has not yet been read). Add a regression test that
fails on the old condition: a complete frame + `0x2C` introducer + EOF must
surface UnexpectedEof, not silently truncate.
Many real-world GIFs omit the GIF trailer block (0x3B). Browsers, ImageMagick,
and PIL all decode them, but previously the decoder hit EOF while looking for
the next block introducer and surfaced UnexpectedEof, rejecting a fully
decodable GIF as corrupt.

This relaxes only the clean-boundary case: when at least one frame has been
fully decoded and the input ends exactly in the `BlockEnd` state (a complete
frame finished, the next block introducer not yet read), a trailing EOF is
reported as a clean end-of-stream. Only `BlockEnd` qualifies — `BlockStart(_)`
means the next introducer byte was already read, so a block has started and its
body is owed; an EOF there is genuine truncation. EOF mid-frame, after a bare
introducer, or before any frame still errors.

Adds tests covering the positive case and the three truncation cases
(mid-frame, introducer-then-EOF after a frame, and zero-frame).
@lilith lilith force-pushed the tolerate-missing-trailer branch from e8b5c67 to 2db69fd Compare June 3, 2026 09:49
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.

2 participants