Skip to content

ext4/dmverity: use io.ReadFull when reading the super block and root hash - #2888

Open
Nilesh Patil (nileshpatil6) wants to merge 1 commit into
microsoft:mainfrom
nileshpatil6:fix/dmverity-short-reads
Open

ext4/dmverity: use io.ReadFull when reading the super block and root hash#2888
Nilesh Patil (nileshpatil6) wants to merge 1 commit into
microsoft:mainfrom
nileshpatil6:fix/dmverity-short-reads

Conversation

@nileshpatil6

Copy link
Copy Markdown

The bug

ReadDMVerityInfoReader accepts an io.Reader but reads each block with a single Read call and treats a short return as a failure:

block := make([]byte, blockSize)
if s, err := r.Read(block); err != nil || s != blockSize {
	...
	return nil, fmt.Errorf("unexpected bytes read expected=%d actual=%d: %w", blockSize, s, ErrSuperBlockReadFailure)
}

The same pattern is repeated for the root hash block.

io.Reader explicitly allows a Read to return fewer bytes than requested without that being an error:

Read reads up to len(p) bytes into p. It returns the number of bytes read (0 <= n <= len(p)) and any error encountered. Even if Read returns n < len(p), it may use all of p as scratch space during the call. ... Callers should always process the n > 0 bytes returned before considering the error err.

So a hash device that is completely valid fails to parse whenever it arrives through a reader that returns short reads. It happens to work today only because the exported ReadDMVerityInfo path hands it an *os.File. Any other reader, for example one wrapping a network stream or a tar entry, can fail:

unexpected bytes read expected=4096 actual=1: failed to read dm-verity super block

ext4/dmverity is imported by ext4/tar2ext4, internal/verity, internal/guest/storage/devicemapper and test/internal/layers, and ReadDMVerityInfoReader is exported, so external callers can pass any reader they like.

The change

Use io.ReadFull for both blocks.

Error behaviour for genuinely truncated input is preserved: io.ReadFull returns io.EOF when nothing could be read and io.ErrUnexpectedEOF on a partial block, so the existing unexpected bytes read message and the ErrSuperBlockReadFailure / ErrRootHashReadFailure wrapping still apply. The existing TestInvalidReadEOF and TestInvalidReadNotEnoughBytes pass unchanged.

Testing

Added TestReadDMVerityInfoReaderShortReads, which feeds identical bytes through a reader returning one byte per Read and compares the root digest against a whole-block read.

Without the change:

--- FAIL: TestReadDMVerityInfoReaderShortReads (0.02s)
    dmverity_test.go:109: failed to read verity info from a short reader:
        unexpected bytes read expected=4096 actual=1: failed to read dm-verity super block
FAIL

With it, the whole package passes:

--- PASS: TestInvalidReadEOF
--- PASS: TestInvalidReadNotEnoughBytes
--- PASS: TestReadDMVerityInfoReaderShortReads
--- PASS: TestNotVeritySuperBlock
--- PASS: TestNoMerkleTree
ok  	github.com/Microsoft/hcsshim/ext4/dmverity

gofmt -l ext4/dmverity/ and go vet ./ext4/dmverity/ are both clean. Commit is signed off per the DCO requirement.

Separate issue in the same file, not fixed here

While testing this I also found that MerkleTree(r io.Reader) never terminates on an empty reader: the inner loop breaks with nextLevel.Len() == 0, 0 % blockSize == 0 so no padding is appended, and the only loop exit is nextLevel.Len() == blockSize, which is never reached. Each pass allocates a fresh 1 MiB bufio.NewReaderSize, so it grows memory until the process dies rather than spinning harmlessly. No in-repo caller can currently pass zero bytes, since ConvertTarToExt4 always emits a non-empty superblock, so this only affects external callers of the exported API. Happy to send that as a separate PR if you would like it fixed.

…hash

ReadDMVerityInfoReader takes an io.Reader and calls r.Read(block) once
per block, treating a return of fewer than blockSize bytes as a failure:

    if s, err := r.Read(block); err != nil || s != blockSize {

io.Reader explicitly permits a Read to return fewer bytes than requested
without that being an error, so a hash device that is entirely valid
fails to parse whenever it arrives through a reader that returns short
reads. Only an *os.File happens to satisfy the current assumption.

Use io.ReadFull for both blocks. A genuinely truncated device still
reports the same errors: io.ReadFull returns io.EOF when nothing could
be read and io.ErrUnexpectedEOF on a partial block, so the existing
"unexpected bytes read" message and the ErrSuperBlockReadFailure and
ErrRootHashReadFailure wrapping are preserved.

Adds a test that feeds the same bytes through a reader returning one
byte per call and checks the root digest matches the whole-block read.

Signed-off-by: nileshpatil6 <technil6436@gmail.com>
@nileshpatil6
Nilesh Patil (nileshpatil6) requested a review from a team as a code owner August 23, 2026 18:05
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.

1 participant