Video: Validate ISO BMFF sample-entry framing in codec head scan #5617

This commit is contained in:
Michael Mayer 2026-06-01 18:12:31 +00:00
parent 6d692cf9dc
commit 23802d4fd9
4 changed files with 277 additions and 7 deletions

View file

@ -2,12 +2,26 @@ package video
import (
"bytes"
"encoding/binary"
"errors"
"io"
"github.com/sunfish-shogi/bufseekio"
)
const (
// sampleEntryHeaderLen is the number of leading bytes of an ISO BMFF visual
// sample entry inspected during validation: box size (4) + coding name (4) +
// reserved (6) + data_reference_index (2).
sampleEntryHeaderLen = 16
// minVisualSampleEntrySize is the smallest spec-compliant VisualSampleEntry
// box (8-byte box header + 8-byte SampleEntry base + 70-byte VisualSampleEntry
// fixed fields), and maxVisualSampleEntrySize caps the plausible size so a
// random four-byte value preceding a colliding coding name is rejected.
minVisualSampleEntrySize = 86
maxVisualSampleEntrySize = 1 << 20
)
// Chunks represents a list of file chunks.
type Chunks []Chunk
@ -106,10 +120,10 @@ func (c Chunks) DataOffset(file io.ReadSeeker, offset, maxOffset int) (int, Chun
}
const blockSize = 128 * 1024
const chunkLen = 4 // Chunk is [4]byte; bufseekio uses this as the inter-block overlap.
const cachedBlocks = 4 // Number of blocks bufseekio keeps cached; reads do not overlap.
buffer := make([]byte, blockSize)
r := bufseekio.NewReadSeeker(file, blockSize, chunkLen)
r := bufseekio.NewReadSeeker(file, blockSize, cachedBlocks)
if seekOffset, seekErr := r.Seek(int64(offset), io.SeekStart); seekErr != nil {
return -1, Chunk{}, seekErr
@ -156,3 +170,121 @@ func (c Chunks) DataOffset(file io.ReadSeeker, offset, maxOffset int) (int, Chun
return -1, Chunk{}, nil
}
// isVisualSampleEntry reports whether box begins with a valid ISO BMFF visual
// sample entry header: a big-endian box size within plausible bounds, the
// four-byte coding name, six reserved zero bytes, and a nonzero
// data_reference_index. The reserved zero bytes and nonzero index are mandated
// by ISO/IEC 14496-12, so a coding name that merely collides with random
// payload bytes is rejected. box must hold at least sampleEntryHeaderLen bytes
// starting at the box size field; the coding name (box[4:8]) is matched by the
// caller.
func isVisualSampleEntry(box []byte) bool {
if len(box) < sampleEntryHeaderLen {
return false
}
if size := binary.BigEndian.Uint32(box[0:4]); size < minVisualSampleEntrySize || size > maxVisualSampleEntrySize {
return false
}
// The six bytes following the coding name are a const-zero reserved field.
for _, b := range box[8:14] {
if b != 0 {
return false
}
}
// data_reference_index is a 1-based index and is never zero in practice.
return binary.BigEndian.Uint16(box[14:16]) != 0
}
// SampleEntryOffset scans the head of file (up to maxOffset, or to EOF when
// maxOffset <= 0) for the first chunk in c that is framed as a valid ISO BMFF
// visual sample entry, returning its offset and the matching chunk. Unlike a
// raw byte search, each candidate coding name is validated with
// isVisualSampleEntry so that a four-byte code colliding with random payload
// bytes — common in raw video elementary streams such as DV — is not mistaken
// for a codec. Returns -1 and a zero Chunk when no valid sample entry is found.
func (c Chunks) SampleEntryOffset(file io.ReadSeeker, maxOffset int) (int, Chunk, error) {
if file == nil {
return -1, Chunk{}, errors.New("file is nil")
} else if len(c) == 0 {
return -1, Chunk{}, nil
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return -1, Chunk{}, err
}
const blockSize = 128 * 1024
// carry retains the trailing bytes of each block so a coding name straddling
// a block boundary, and the validation window of a candidate near the end of
// a block, stay visible within a single buffer on the next read.
const carry = sampleEntryHeaderLen
buffer := make([]byte, carry+blockSize)
base := 0 // Absolute file offset of buffer[0].
have := 0 // Number of valid bytes currently in buffer.
for {
n, err := io.ReadFull(file, buffer[have:])
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return -1, Chunk{}, err
}
have += n
atEOF := err == io.EOF || err == io.ErrUnexpectedEOF
// A sample entry's coding name sits at box offset +4, so a candidate at
// index i needs the window buffer[i-4 : i+12]; i therefore starts at 4.
best, bestChunk := -1, Chunk{}
for _, chunk := range c {
needle := chunk.Bytes()
for from := 4; from+len(needle) <= have; {
rel := bytes.Index(buffer[from:have], needle)
if rel < 0 {
break
}
i := from + rel
// Defer candidates whose validation window is not fully buffered yet;
// the carry-over re-surfaces them with complete context next read.
if i-4+sampleEntryHeaderLen > have {
break
}
if isVisualSampleEntry(buffer[i-4 : i-4+sampleEntryHeaderLen]) {
if best < 0 || i < best {
best, bestChunk = i, chunk
}
break
}
from = i + 1
}
}
if best >= 0 {
return base + best, bestChunk, nil
}
if atEOF {
return -1, Chunk{}, nil
}
if maxOffset > 0 && base+have >= maxOffset {
return -1, Chunk{}, nil
}
// Carry the trailing bytes to the front and refill the remainder.
if have > carry {
copy(buffer, buffer[have-carry:have])
base += have - carry
have = carry
}
}
}

View file

@ -1,6 +1,8 @@
package video
import (
"bytes"
"encoding/binary"
"io"
"testing"
@ -10,6 +12,24 @@ import (
"github.com/sunfish-shogi/bufseekio"
)
// sampleEntryHeader returns a valid 16-byte ISO BMFF visual sample entry header
// (box size, coding name, six reserved zero bytes, data_reference_index = 1) for
// use in SampleEntryOffset tests.
func sampleEntryHeader(code Chunk) []byte {
b := make([]byte, sampleEntryHeaderLen)
binary.BigEndian.PutUint32(b[0:4], minVisualSampleEntrySize)
copy(b[4:8], code.Bytes())
binary.BigEndian.PutUint16(b[14:16], 1)
return b
}
// placeSampleEntry writes a valid visual sample entry into buf so that the
// coding name begins at codingNameOffset, mirroring the on-disk layout where
// the four-byte box size precedes the coding name.
func placeSampleEntry(buf []byte, codingNameOffset int, code Chunk) {
copy(buf[codingNameOffset-4:], sampleEntryHeader(code))
}
func TestChunk_TypeCast(t *testing.T) {
t.Run("String", func(t *testing.T) {
assert.Equal(t, "ftyp", ChunkFTYP.String())
@ -159,6 +179,120 @@ func TestChunks_DataOffset(t *testing.T) {
})
}
func TestIsVisualSampleEntry(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
assert.True(t, isVisualSampleEntry(sampleEntryHeader(ChunkM8RG)))
})
t.Run("TooShort", func(t *testing.T) {
assert.False(t, isVisualSampleEntry(sampleEntryHeader(ChunkM8RG)[:15]))
})
t.Run("SizeTooSmall", func(t *testing.T) {
b := sampleEntryHeader(ChunkM8RG)
binary.BigEndian.PutUint32(b[0:4], minVisualSampleEntrySize-1)
assert.False(t, isVisualSampleEntry(b))
})
t.Run("SizeTooLarge", func(t *testing.T) {
b := sampleEntryHeader(ChunkM8RG)
binary.BigEndian.PutUint32(b[0:4], maxVisualSampleEntrySize+1)
assert.False(t, isVisualSampleEntry(b))
})
t.Run("ReservedNotZero", func(t *testing.T) {
b := sampleEntryHeader(ChunkM8RG)
b[10] = 0x01 // One of the six reserved bytes is nonzero.
assert.False(t, isVisualSampleEntry(b))
})
t.Run("ZeroDataReferenceIndex", func(t *testing.T) {
b := sampleEntryHeader(ChunkM8RG)
binary.BigEndian.PutUint16(b[14:16], 0)
assert.False(t, isVisualSampleEntry(b))
})
}
func TestChunks_SampleEntryOffset(t *testing.T) {
t.Run("RealMagicYuvFile", func(t *testing.T) {
f := openTestFile(t, "testdata/magicyuv.mov")
pos, hit, err := MagicYuvChunks.SampleEntryOffset(f, HeadScanLimit)
require.NoError(t, err)
assert.Equal(t, 3537, pos)
assert.Equal(t, ChunkM8RG, hit)
})
t.Run("RealHevcFile", func(t *testing.T) {
f := openTestFile(t, "testdata/quicktime-hvc1.mov")
pos, hit, err := HevcChunks.SampleEntryOffset(f, HeadScanLimit)
require.NoError(t, err)
assert.Greater(t, pos, 0)
assert.Equal(t, ChunkHVC1, hit)
})
t.Run("RejectsStrayCollision", func(t *testing.T) {
// A four-byte MagicYUV code embedded in raw payload bytes, not framed as
// a sample entry, must not be reported as a codec (issue #5617).
buf := bytes.Repeat([]byte{0xAA}, 4096)
copy(buf[1000:], ChunkM8Y4.Bytes())
pos, hit, err := MagicYuvChunks.SampleEntryOffset(bytes.NewReader(buf), HeadScanLimit)
require.NoError(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
t.Run("AcceptsFramedEntry", func(t *testing.T) {
buf := bytes.Repeat([]byte{0xAA}, 4096)
placeSampleEntry(buf, 2000, ChunkM8Y2)
pos, hit, err := MagicYuvChunks.SampleEntryOffset(bytes.NewReader(buf), HeadScanLimit)
require.NoError(t, err)
assert.Equal(t, 2000, pos)
assert.Equal(t, ChunkM8Y2, hit)
})
t.Run("StrayBeforeRealEntry", func(t *testing.T) {
// An earlier stray collision must not shadow a later valid sample entry;
// the scan continues past invalid candidates.
buf := bytes.Repeat([]byte{0xAA}, 4096)
copy(buf[500:], ChunkM8Y4.Bytes())
placeSampleEntry(buf, 2000, ChunkM8RG)
pos, hit, err := MagicYuvChunks.SampleEntryOffset(bytes.NewReader(buf), HeadScanLimit)
require.NoError(t, err)
assert.Equal(t, 2000, pos)
assert.Equal(t, ChunkM8RG, hit)
})
t.Run("BoundarySpanning", func(t *testing.T) {
// A valid entry whose coding name straddles the internal 128 KiB block
// boundary must still be found via the carry-over between reads.
const codingNameOffset = 128*1024 - 1
buf := bytes.Repeat([]byte{0xAA}, 200000)
placeSampleEntry(buf, codingNameOffset, ChunkM8YA)
pos, hit, err := MagicYuvChunks.SampleEntryOffset(bytes.NewReader(buf), HeadScanLimit)
require.NoError(t, err)
assert.Equal(t, codingNameOffset, pos)
assert.Equal(t, ChunkM8YA, hit)
})
t.Run("MaxOffsetCapsScan", func(t *testing.T) {
buf := bytes.Repeat([]byte{0xAA}, 200000)
placeSampleEntry(buf, 150000, ChunkM8RG)
pos, hit, err := MagicYuvChunks.SampleEntryOffset(bytes.NewReader(buf), 64*1024)
require.NoError(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
t.Run("NotFound", func(t *testing.T) {
f := openTestFile(t, "testdata/mp4v-avc1.mp4")
pos, hit, err := HevcChunks.SampleEntryOffset(f, HeadScanLimit)
require.NoError(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
t.Run("Empty", func(t *testing.T) {
f := openTestFile(t, "testdata/mp4v-avc1.mp4")
pos, hit, err := Chunks{}.SampleEntryOffset(f, HeadScanLimit)
require.NoError(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
t.Run("NilFile", func(t *testing.T) {
pos, hit, err := MagicYuvChunks.SampleEntryOffset(nil, HeadScanLimit)
require.Error(t, err)
assert.Equal(t, -1, pos)
assert.Equal(t, Chunk{}, hit)
})
}
func TestChunks_Contains(t *testing.T) {
t.Run("Found", func(t *testing.T) {
assert.True(t, CompatibleBrands.Contains(ChunkMP41))

View file

@ -163,10 +163,12 @@ func Probe(file io.ReadSeeker) (info Info, err error) {
// If no AVC video was found, search the file head for other recognizable sample-entry
// codes (HEVC, see https://stackoverflow.com/questions/63468587, and MagicYUV) in a single
// pass. The Codecs lookup normalizes each hit, e.g. HVC1/HVC2/HVC3/DVH1 → CodecHvc1 and
// pass. Each hit is validated as a real visual sample entry so a four-byte code colliding
// with raw payload bytes (e.g. DV streams in a QuickTime container) is not misread as a
// codec. The Codecs lookup normalizes each hit, e.g. HVC1/HVC2/HVC3/DVH1 → CodecHvc1 and
// M8RG/M8Y2/… → CodecMagicYUV; the lookup is lowercased because MagicYUV codes are uppercase.
if info.VideoCodec == "" {
if pos, hit, fileErr := HeadScanChunks.DataOffset(file, 0, HeadScanLimit); pos > 0 && fileErr == nil {
if pos, hit, fileErr := HeadScanChunks.SampleEntryOffset(file, HeadScanLimit); pos > 0 && fileErr == nil {
info.VideoCodec = Codecs[strings.ToLower(hit.String())]
}
}

View file

@ -8,14 +8,16 @@ import (
)
// IsHEVC reports whether the reader contains an HEVC video stream by scanning
// the head of the file (up to HeadScanLimit) for any HEVC sample entry
// code in a single pass. Returns false on read errors or empty input.
// the head of the file (up to HeadScanLimit) for a valid HEVC sample entry in
// a single pass. Candidates are validated as real visual sample entries so a
// four-byte code colliding with random payload bytes is not mistaken for HEVC.
// Returns false on read errors or empty input.
func IsHEVC(file io.ReadSeeker) bool {
if file == nil {
return false
}
pos, _, err := HevcChunks.DataOffset(file, 0, HeadScanLimit)
pos, _, err := HevcChunks.SampleEntryOffset(file, HeadScanLimit)
return err == nil && pos > 0
}