Skip to content

cache/s3: fix io.ReaderAt contract violations in the import read path - #7123

Closed
ernetas wants to merge 1 commit into
moby:masterfrom
ernetas:s3-readerat-contract
Closed

cache/s3: fix io.ReaderAt contract violations in the import read path#7123
ernetas wants to merge 1 commit into
moby:masterfrom
ernetas:s3-readerat-contract

Conversation

@ernetas

@ernetas ernetas commented Sep 5, 2026

Copy link
Copy Markdown

The s3 cache backend reads blobs back through a ReaderAt built on ranged GetObject calls. There are three bugs in it.

A short read comes back with no error

This is the loop that fills the buffer:

nn, err = hrs.rc.Read(p)
n += nn
p = p[nn:]
if nn == len(p) || err != nil {
	break
}

nn is what was just read. len(p) after the reslice is what is still wanted. Those are different things, and when they happen to be equal the loop stops early and returns a short count with a nil error.

Ask for 100 bytes from a stream that gives back 50 at a time and you get 50 bytes and no error. One byte at a time gives you 99. io.ReaderAt says a read shorter than the buffer must report an error, and the content package relies on that, so it does not read again. The layer is quietly truncated.

The body of an S3 response is an HTTP stream, so short reads are normal, not an edge case.

The offset is tracked from the wrong place

hrs.offset += int64(n) adds to wherever the reader thought it was, not to the offset it was actually asked for.

After a read at a new offset the tracked position no longer matches the stream. The next read that continues from there looks like a seek, so the object is reopened for nothing. Worse, a later read whose offset happens to equal the stale value looks like a continuation, and gets served from the wrong part of the object.

The io.ReaderAt fast path could not have worked

The stream is opened with a bytes=off- range, so byte 0 of the stream is byte off of the object. The cached reader would then apply the same offset again and read from twice as far in.

It never ran, because an HTTP response body is not an io.ReaderAt. But it was cached in a field and reused for every later call, so if it ever became one the reads would be wrong from then on.

The fix

Use io.ReadFull, which returns io.ErrUnexpectedEOF when it cannot fill the buffer. Track the offset from the one that was requested. Drop the fast path. A zero length read now returns without opening the object at all.

Tests

There were no tests for this file. This adds seven, against a fake stream that hands back a fixed number of bytes per read and records the offsets it was opened at.

Five of them fail on the current code:

  • TestReaderAtShortReads at chunk sizes 1 and 50
  • TestReaderAtSequentialReadsReuseStream
  • TestReaderAtOffsetAccounting
  • TestReaderAtTruncatedObject
  • TestReaderAtEmptyRead

The other two cover Close and concurrent reads and pass either way. They are there to keep the mutex added in b0e7d80 honest.

Notes

This is a standalone fix. It does not depend on anything else and nothing else in the s3 backend changes.

The s3 backend hands blobs to the content store through a ReaderAt built
on ranged GetObject calls. Three things were wrong with it.

The short read loop compared the bytes just read against the bytes still
wanted:

	nn, err = hrs.rc.Read(p)
	n += nn
	p = p[nn:]
	if nn == len(p) || err != nil {
		break
	}

Once those two are equal the loop stops and returns a short count with a
nil error, which io.ReaderAt forbids. Asking for 100 bytes from a stream
that hands back 50 at a time returns 50 and no error; at 1 byte at a time
it returns 99. The content package trusts the contract and does not read
again, so a layer is silently truncated.

Offset accounting added to the stale position rather than the requested
one. After a read at a new offset the tracked position no longer matched
the stream, so a continuation read reopened the object, and a later read
whose offset happened to collide with the stale value was served from the
wrong place.

The io.ReaderAt fast path could not have worked. The stream is opened with
a bytes=off- range, so byte 0 of the stream is byte off of the object, and
the cached reader would apply the same offset a second time. It was dead
in practice because an HTTP response body is not an io.ReaderAt, but it
was cached across calls and would have been wrong if it ever were.

Use io.ReadFull, which reports io.ErrUnexpectedEOF on a partial read, and
track the offset from the one that was asked for. A zero length read now
returns without opening the object.

Add unit tests for the read path. Five of the seven fail on the code they
replace.

Signed-off-by: Ernestas Lukoševičius <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant