mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Video: Validate ISO BMFF sample-entry framing in codec head scan #5617
This commit is contained in:
parent
6d692cf9dc
commit
23802d4fd9
4 changed files with 277 additions and 7 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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())]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue