cache/s3: fix io.ReaderAt contract violations in the import read path - #7123
Closed
ernetas wants to merge 1 commit into
Closed
cache/s3: fix io.ReaderAt contract violations in the import read path#7123ernetas wants to merge 1 commit into
ernetas wants to merge 1 commit into
Conversation
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The s3 cache backend reads blobs back through a
ReaderAtbuilt on rangedGetObjectcalls. There are three bugs in it.A short read comes back with no error
This is the loop that fills the buffer:
nnis 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.ReaderAtsays 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 byteoffof 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 returnsio.ErrUnexpectedEOFwhen 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:
TestReaderAtShortReadsat chunk sizes 1 and 50TestReaderAtSequentialReadsReuseStreamTestReaderAtOffsetAccountingTestReaderAtTruncatedObjectTestReaderAtEmptyReadThe 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.