diff --git a/CHANGES.txt b/CHANGES.txt index 80e898aa721..981d4be904c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,11 @@ Release 4.1.0 - unreleased + * A plain MP4 is typed by the tracks it holds rather than by its brand: + MP4TrackDetector reads the handler types in the movie box and returns + video/mp4, audio/mp4 or application/mp4. The brand of most MP4 files, + isom, says nothing about their content, so the mime magic could only + fall back to video/quicktime (TIKA-3646, TIKA-2935). + * Raster previews for the vector thumbnails of Office documents: the new poi-metafile-renderer draws EMF and WMF images through POI (a PNG of a configurable width; Word's bitmap-in-WMF thumbnails from the bitmap diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/detect/mp4/MP4TrackDetector.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/detect/mp4/MP4TrackDetector.java new file mode 100644 index 00000000000..7d5612b8ab1 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/detect/mp4/MP4TrackDetector.java @@ -0,0 +1,310 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.detect.mp4; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.apache.commons.io.IOUtils; + +import org.apache.tika.annotation.TikaComponent; +import org.apache.tika.detect.Detector; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.mime.MediaType; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.mp4.Mp4Boxes; + +/** + * Types a plain MP4 file by what it contains, as RFC 4337 asks: video/mp4 + * when it has a video track, audio/mp4 when it has only audio, and + * application/mp4 when it has neither (TIKA-2935, TIKA-3646). + *

+ * The mime magic cannot do this. It only knows the brand in the + * {@code ftyp} box, and the brand of the great majority of MP4 files, + * {@code isom}, says nothing about their content: Tika's own + * {@code testMP4Video.mp4} and {@code testMP4AudioOnly.mp4} both carry it. + * Brands that name a format of their own (M4A, 3GP, HEIC, AVIF, CR3, ...) + * keep their magic and are left alone here. + *

+ * The detector walks the top level boxes by their size fields, which skips + * the media data rather than reading it, and reads the handler type of each + * track in the movie box. A file whose movie box is unreachable (missing, + * beyond the limits below, or not backed by a file) is left to the magic. + */ +@TikaComponent +public class MP4TrackDetector implements Detector { + + private static final long serialVersionUID = 1L; + + static final MediaType MP4_VIDEO = MediaType.video("mp4"); + static final MediaType MP4_AUDIO = MediaType.audio("mp4"); + static final MediaType MP4_APPLICATION = MediaType.application("mp4"); + + /** + * The brands of a plain MP4. A brand naming a specific format is not + * here: those files are typed by their own magic. + */ + private static final Set BRANDS = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("isom", "iso2", "iso4", "iso5", "iso6", "mp41", "mp42", "avc1", + "mmp4"))); + + /** + * Bounds the walk over a crafted file: a real one has a handful of top + * level boxes and a handful of tracks. + */ + private static final int MAX_BOXES = 256; + + private static final int MAX_TRACKS = 64; + + /** + * How much of a movie box is read for its track handlers. + */ + private static final int MAX_MOOV_BYTES = 8 * 1024 * 1024; + + /** + * How far into a stream the movie box is looked for before the stream is + * spooled: a file written for streaming has it right behind the file type + * box, and answering those costs nothing. + */ + private static final int MAX_PREFIX_BYTES = 256 * 1024; + + @Override + public MediaType detect(TikaInputStream tis, Metadata metadata, ParseContext parseContext) + throws IOException { + if (tis == null) { + return MediaType.OCTET_STREAM; + } + tis.mark(16); + byte[] header = new byte[16]; + int read; + try { + read = IOUtils.read(tis, header, 0, 16); + } finally { + tis.reset(); + } + if (read < 12 || !isFtyp(header) || !BRANDS.contains(brand(header))) { + return MediaType.OCTET_STREAM; + } + byte[] moov = movieBox(tis); + if (moov == null) { + return MediaType.OCTET_STREAM; + } + return byTracks(moov); + } + + private static boolean isFtyp(byte[] header) { + return header[4] == 'f' && header[5] == 't' && header[6] == 'y' && header[7] == 'p'; + } + + private static String brand(byte[] header) { + return new String(header, 8, 4, StandardCharsets.US_ASCII); + } + + /** + * The movie box, found by walking the top level boxes; null when there + * is none within the limits. + */ + private static byte[] movieBox(TikaInputStream tis) throws IOException { + if (!tis.hasFile()) { + //a movie box at the front is answered without touching the disk + tis.mark(MAX_PREFIX_BYTES); + byte[] prefix = new byte[MAX_PREFIX_BYTES]; + int length; + try { + length = IOUtils.read(tis, prefix, 0, MAX_PREFIX_BYTES); + } finally { + tis.reset(); + } + byte[] fromPrefix = movieBox(new ArrayBoxes(prefix, Math.max(length, 0))); + if (fromPrefix != null) { + return fromPrefix; + } + if (length < MAX_PREFIX_BYTES) { + //the whole file was in the prefix, so there is nothing further in + return null; + } + } + //a movie box behind the media data, where recorders that do not write + //for streaming put it, is only reachable by seeking; the spooled file + //is the one the parse reads afterwards, so it is not read twice + try (SeekableByteChannel channel = Files.newByteChannel(tis.getPath())) { + return movieBox(new ChannelBoxes(channel)); + } + } + + private static byte[] movieBox(Boxes boxes) throws IOException { + long position = 0; + for (int i = 0; i < MAX_BOXES; i++) { + byte[] header = boxes.read(position, 16); + if (header == null || header.length < 8) { + return null; + } + long size = uint32(header, 0); + int headerSize = 8; + if (size == 1) { + if (header.length < 16) { + return null; + } + size = uint64(header, 8); + headerSize = 16; + } else if (size == 0) { + size = boxes.length() - position; + } + if (size < headerSize || position > boxes.length() - size) { + return null; + } + if ("moov".equals(Mp4Boxes.fourCC(header, 4))) { + long payload = size - headerSize; + if (payload <= 0 || payload > MAX_MOOV_BYTES) { + return null; + } + return boxes.read(position + headerSize, (int) payload); + } + position += size; + } + return null; + } + + /** + * The type of the movie: video where a track says so, else audio, else + * neither. The handlers are read where a track keeps them, + * {@code moov/trak/mdia/hdlr}, so the handler of the metadata in + * {@code moov/udta/meta} is not mistaken for a track. + */ + private static MediaType byTracks(byte[] moov) { + boolean audio = false; + int trak = Mp4Boxes.findBox(moov, 0, moov.length, "trak", MAX_BOXES); + for (int track = 0; trak >= 0 && track < MAX_TRACKS; track++) { + int trakEnd = Mp4Boxes.boxEnd(moov, trak, moov.length); + if (trakEnd < 0) { + break; + } + String handler = handler(moov, trak, trakEnd); + if ("vide".equals(handler)) { + return MP4_VIDEO; + } + audio |= "soun".equals(handler); + trak = Mp4Boxes.findBox(moov, trakEnd, moov.length, "trak", MAX_BOXES); + } + return audio ? MP4_AUDIO : MP4_APPLICATION; + } + + /** + * The handler type of a track, from {@code mdia/hdlr}, or null. + */ + private static String handler(byte[] moov, int trak, int trakEnd) { + int payload = Mp4Boxes.payloadStart(moov, trak, trakEnd); + int mdia = payload < 0 ? -1 + : Mp4Boxes.findBox(moov, payload, trakEnd, "mdia", MAX_BOXES); + if (mdia < 0) { + return null; + } + int mdiaEnd = Mp4Boxes.boxEnd(moov, mdia, trakEnd); + int mdiaPayload = mdiaEnd < 0 ? -1 : Mp4Boxes.payloadStart(moov, mdia, mdiaEnd); + int hdlr = mdiaPayload < 0 ? -1 + : Mp4Boxes.findBox(moov, mdiaPayload, mdiaEnd, "hdlr", MAX_BOXES); + if (hdlr < 0) { + return null; + } + //hdlr: version and flags, pre_defined, then the handler type + int handler = hdlr + Mp4Boxes.HEADER + 8; + return handler + 4 <= Mp4Boxes.boxEnd(moov, hdlr, mdiaEnd) + ? Mp4Boxes.fourCC(moov, handler) : null; + } + + private static long uint32(byte[] b, int off) { + return ByteBuffer.wrap(b, off, 4).getInt() & 0xFFFFFFFFL; + } + + private static long uint64(byte[] b, int off) { + return ByteBuffer.wrap(b, off, 8).getLong(); + } + + /** + * The bytes the walk reads from, so the same walk serves a file and a + * prefix held in memory. + */ + private interface Boxes { + /** + * @return the bytes at the offset, or null if they are not there + */ + byte[] read(long offset, int length) throws IOException; + + long length() throws IOException; + } + + private static final class ArrayBoxes implements Boxes { + private final byte[] bytes; + private final int length; + + ArrayBoxes(byte[] bytes, int length) { + this.bytes = bytes; + this.length = length; + } + + @Override + public byte[] read(long offset, int size) { + if (offset < 0 || offset > length - Math.min(size, 8)) { + return null; + } + int available = (int) Math.min(size, length - offset); + return Arrays.copyOfRange(bytes, (int) offset, (int) offset + available); + } + + @Override + public long length() { + return length; + } + } + + private static final class ChannelBoxes implements Boxes { + private final SeekableByteChannel channel; + + ChannelBoxes(SeekableByteChannel channel) { + this.channel = channel; + } + + @Override + public byte[] read(long offset, int size) throws IOException { + if (offset < 0 || offset >= channel.size()) { + return null; + } + int available = (int) Math.min(size, channel.size() - offset); + ByteBuffer buffer = ByteBuffer.allocate(available); + channel.position(offset); + while (buffer.hasRemaining() && channel.read(buffer) > 0) { + //read to the end of the buffer + } + //only what was actually read: a short read must not look like data + return buffer.position() == available ? buffer.array() + : Arrays.copyOf(buffer.array(), buffer.position()); + } + + @Override + public long length() throws IOException { + return channel.size(); + } + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4Boxes.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4Boxes.java new file mode 100644 index 00000000000..bfb4663732b --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4Boxes.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.mp4; + +import java.nio.charset.StandardCharsets; + +import org.apache.tika.io.EndianUtils; + +/** + * The boxes an ISO base media file is made of, as far as reading them from a + * byte array goes (ISO/IEC 14496-12, 4.2): a 32 bit size and a FourCC, where + * a size of 1 announces a 64 bit largesize after the FourCC and a size of 0 + * extends the box to the end of what encloses it. + *

+ * Shared by the parts of Tika that read these files without handing them to + * a full parser: {@link Mp4SampleEntries} and the MP4 detector. Every method + * takes the region the boxes live in and returns -1 rather than reading + * outside it, so a crafted size ends a walk instead of a parse. + */ +public final class Mp4Boxes { + + /** + * The header of a box with a 32 bit size: the size and the FourCC. + */ + public static final int HEADER = 8; + + /** + * The header of a box with a 64 bit largesize. + */ + public static final int LARGE_HEADER = 16; + + private Mp4Boxes() { + } + + /** + * The offset one past the box at {@code pos}, or -1 when its size is + * invalid or reaches past {@code end}. + */ + public static int boxEnd(byte[] b, int pos, int end) { + if (pos < 0 || pos > end - HEADER || end > b.length) { + return -1; + } + long size = EndianUtils.getUIntBE(b, pos); + if (size == 1) { + if (pos > end - LARGE_HEADER) { + return -1; + } + //a largesize beyond 63 bits goes negative and fails the check below + size = (EndianUtils.getUIntBE(b, pos + HEADER) << 32) + + EndianUtils.getUIntBE(b, pos + HEADER + 4); + if (size < LARGE_HEADER) { + return -1; + } + } else if (size == 0) { + //the box extends to the end of what encloses it + size = end - pos; + } else if (size < HEADER) { + return -1; + } + if (size > end - pos) { + return -1; + } + return pos + (int) size; + } + + /** + * The offset where the payload of the box at {@code pos} starts, which + * follows the largesize where there is one, or -1 for an invalid box. + */ + public static int payloadStart(byte[] b, int pos, int end) { + if (boxEnd(b, pos, end) < 0) { + return -1; + } + return EndianUtils.getUIntBE(b, pos) == 1 ? pos + LARGE_HEADER : pos + HEADER; + } + + /** + * The offset of the first box of the given type among the boxes in + * {@code [pos, end)}, or -1 if there is none. + * + * @param maxBoxes how many boxes to look at before giving up + */ + public static int findBox(byte[] b, int pos, int end, String type, int maxBoxes) { + for (int box = 0; box < maxBoxes && pos >= 0 && pos <= end - HEADER; box++) { + int boxEnd = boxEnd(b, pos, end); + if (boxEnd < 0 || boxEnd <= pos) { + return -1; + } + if (type.equals(fourCC(b, pos + 4))) { + return pos; + } + pos = boxEnd; + } + return -1; + } + + /** + * Reads a FourCC as it is, for comparing against known box types. + */ + public static String fourCC(byte[] b, int pos) { + return new String(b, pos, 4, StandardCharsets.ISO_8859_1); + } + + /** + * Reads a FourCC for exposing it as a metadata value: null unless all four + * bytes are printable ASCII, with trailing spaces trimmed (QuickTime pads + * short codes such as 'raw ' and 'rle ' with spaces). Codes that are blank + * after trimming are null as well. + */ + public static String printableFourCC(byte[] b, int pos) { + int len = 4; + while (len > 0 && b[pos + len - 1] == ' ') { + len--; + } + if (len == 0) { + return null; + } + for (int i = 0; i < len; i++) { + int c = b[pos + i] & 0xFF; + if (c < 0x20 || c > 0x7E) { + return null; + } + } + return new String(b, pos, len, StandardCharsets.US_ASCII); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4SampleEntries.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4SampleEntries.java index 91bc9ff2fa8..b277270b00b 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4SampleEntries.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4SampleEntries.java @@ -16,7 +16,6 @@ */ package org.apache.tika.parser.mp4; -import java.nio.charset.StandardCharsets; import org.apache.tika.io.EndianUtils; @@ -36,6 +35,12 @@ final class Mp4SampleEntries { */ static final int SAMPLE_ENTRY_FIELDS = 8; + /** + * How many child boxes of an entry are looked at when resolving a + * protected entry's original format. + */ + private static final int MAX_CHILD_BOXES = 64; + interface Visitor { /** * @param fourCC the entry's FourCC, or null if it is not printable @@ -74,7 +79,7 @@ static void walk(byte[] b, Visitor visitor) { return; } int end = pos + (int) size; - visitor.entry(printableFourCC(b, pos + 4), b, pos + header, end); + visitor.entry(Mp4Boxes.printableFourCC(b, pos + 4), b, pos + header, end); pos = end; } } @@ -99,76 +104,18 @@ static boolean isProtected(String fourCC) { * @param end offset one past the entry's last byte */ static String originalFormat(byte[] b, int pos, int end) { - int sinf = findBox(b, pos, end, "sinf"); + int sinf = Mp4Boxes.findBox(b, pos, end, "sinf", MAX_CHILD_BOXES); if (sinf < 0) { return null; } - int sinfEnd = boxEnd(b, sinf, end); - int frma = findBox(b, sinf + 8, sinfEnd, "frma"); - if (frma < 0 || boxEnd(b, frma, sinfEnd) < frma + 12) { + int sinfEnd = Mp4Boxes.boxEnd(b, sinf, end); + int frma = sinfEnd < 0 ? -1 + : Mp4Boxes.findBox(b, sinf + 8, sinfEnd, "frma", MAX_CHILD_BOXES); + if (frma < 0 || Mp4Boxes.boxEnd(b, frma, sinfEnd) < frma + 12) { //the box must hold its 4 byte payload, not borrow it from the next box return null; } - return printableFourCC(b, frma + 8); - } - - /** - * Returns the offset of the first box of the given type among the boxes - * in [pos, end), or -1. - */ - private static int findBox(byte[] b, int pos, int end, String type) { - while (pos >= 0 && pos + 8 <= end) { - int boxEnd = boxEnd(b, pos, end); - if (boxEnd < 0) { - return -1; - } - if (type.equals(fourCC(b, pos + 4))) { - return pos; - } - pos = boxEnd; - } - return -1; + return Mp4Boxes.printableFourCC(b, frma + 8); } - /** - * Returns the offset one past the box starting at pos, or -1 if its size - * is invalid or runs past end. - */ - private static int boxEnd(byte[] b, int pos, int end) { - long size = EndianUtils.getUIntBE(b, pos); - if (size < 8 || size > end - pos) { - return -1; - } - return pos + (int) size; - } - - /** - * Reads a FourCC as it is, for comparing against known box types. - */ - static String fourCC(byte[] b, int pos) { - return new String(b, pos, 4, StandardCharsets.ISO_8859_1); - } - - /** - * Reads a FourCC for exposing it as a metadata value: null unless all four - * bytes are printable ASCII, with trailing spaces trimmed (QuickTime pads - * short codes such as 'raw ' and 'rle ' with spaces). Codes that are blank - * after trimming are null as well. - */ - static String printableFourCC(byte[] b, int pos) { - int len = 4; - while (len > 0 && b[pos + len - 1] == ' ') { - len--; - } - if (len == 0) { - return null; - } - for (int i = 0; i < len; i++) { - int c = b[pos + i] & 0xFF; - if (c < 0x20 || c > 0x7E) { - return null; - } - } - return new String(b, pos, len, StandardCharsets.US_ASCII); - } } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java index b8bdc87458f..acab3cc99e2 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java @@ -121,7 +121,7 @@ private static int findEsdsAverageBitRate(byte[] b, int pos, int end, int depth) if (size < 8 || size > end - pos) { return 0; } - String type = Mp4SampleEntries.fourCC(b, pos + 4); + String type = Mp4Boxes.fourCC(b, pos + 4); if ("esds".equals(type)) { return readEsdsAverageBitRate(b, pos + 8, pos + (int) size); } diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4VideoHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4VideoHandler.java index 247f41c912c..566fe135acc 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4VideoHandler.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4VideoHandler.java @@ -93,7 +93,7 @@ private static int findBtrtAverageBitRate(byte[] b, int pos, int end) { if (size < 8 || size > end - pos) { return 0; } - if ("btrt".equals(Mp4SampleEntries.fourCC(b, pos + 4)) && pos + 20 <= end) { + if ("btrt".equals(Mp4Boxes.fourCC(b, pos + 4)) && pos + 20 <= end) { long averageBitRate = EndianUtils.getUIntBE(b, pos + 16); return averageBitRate > 0 && averageBitRate <= Integer.MAX_VALUE ? (int) averageBitRate : 0; diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/detect/mp4/MP4TrackDetectorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/detect/mp4/MP4TrackDetectorTest.java new file mode 100644 index 00000000000..379b6bc8239 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/detect/mp4/MP4TrackDetectorTest.java @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.detect.mp4; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Random; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import org.apache.tika.TikaTest; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; + +/** + * An MP4 is typed by the tracks it holds, not by its brand: Tika's own + * fixtures for video and for audio both carry the isom brand (TIKA-3646). + */ +public class MP4TrackDetectorTest extends TikaTest { + + private final MP4TrackDetector detector = new MP4TrackDetector(); + + @ParameterizedTest + @CsvSource({"testMP4Video.mp4, video/mp4", "testMP4AudioOnly.mp4, audio/mp4"}) + public void testTypeFollowsTheTracks(String file, String expected) throws Exception { + try (InputStream is = getResourceAsStream("/test-documents/" + file); + TikaInputStream tis = TikaInputStream.get(is)) { + assertEquals(expected, detect(tis)); + } + } + + /** + * The movie box may sit behind the media data; walking the box sizes + * skips over that rather than reading it. + */ + @Test + public void testMovieBoxAfterTheMediaData(@TempDir Path tmp) throws Exception { + Path file = tmp.resolve("moov-last.mp4"); + Files.write(file, mp4(box("free", new byte[8]), mdat(1024 * 1024), moov("soun"))); + try (TikaInputStream tis = TikaInputStream.get(file)) { + assertEquals("audio/mp4", detect(tis)); + } + } + + /** + * A movie box further in than the prefix is reached by spooling the + * stream, which is what the parse would do next anyway. + */ + @Test + public void testMovieBoxBeyondTheStreamPrefix() throws Exception { + byte[] mp4 = mp4(mdat(1024 * 1024), moov("soun")); + try (TikaInputStream tis = TikaInputStream.get(mp4)) { + assertEquals("audio/mp4", detect(tis)); + } + } + + /** + * A video track anywhere in the movie makes it a video. + */ + @Test + public void testAudioAndVideoTracks() throws Exception { + byte[] mp4 = mp4(box("moov", concat(track("soun"), track("vide")))); + try (TikaInputStream tis = TikaInputStream.get(mp4)) { + assertEquals("video/mp4", detect(tis)); + } + } + + /** + * A movie whose tracks are neither audio nor video is neither. + */ + @Test + public void testTrackless() throws Exception { + byte[] mp4 = mp4(moov("hint")); + try (TikaInputStream tis = TikaInputStream.get(mp4)) { + assertEquals("application/mp4", detect(tis)); + } + } + + /** + * The handler of the metadata is not a track handler: it lives in + * moov/udta/meta, not in moov/trak/mdia. + */ + @Test + public void testMetadataHandlerIsNotATrack() throws Exception { + byte[] meta = box("udta", box("meta", hdlr("vide"))); + byte[] mp4 = mp4(box("moov", concat(track("soun"), meta))); + try (TikaInputStream tis = TikaInputStream.get(mp4)) { + assertEquals("audio/mp4", detect(tis)); + } + } + + /** + * Brands that name a format of their own keep their own magic, and a + * file without a reachable movie box is left to it as well. + */ + @ParameterizedTest + @CsvSource({"M4A , true", "heic, true", "isom, false"}) + public void testUnclaimedFiles(String brand, boolean hasMoov) throws Exception { + byte[] mp4 = hasMoov ? mp4(brand, moov("soun")) : mp4(brand, mdat(64)); + try (TikaInputStream tis = TikaInputStream.get(mp4)) { + assertEquals("application/octet-stream", detect(tis)); + } + } + + /** + * Every truncation of a well formed file, and random bytes behind a + * valid header, must end the walk rather than the detection. + */ + @Test + public void testMalformedFilesAreHarmless() throws Exception { + byte[] mp4 = mp4(box("free", new byte[8]), mdat(64), moov("soun")); + for (int length = 0; length <= mp4.length; length++) { + try (TikaInputStream tis = TikaInputStream.get(Arrays.copyOf(mp4, length))) { + detect(tis); + } + } + Random random = new Random(42); + byte[] header = mp4(); + for (int i = 0; i < 200; i++) { + byte[] noise = new byte[256]; + random.nextBytes(noise); + System.arraycopy(header, 0, noise, 0, Math.min(header.length, noise.length)); + try (TikaInputStream tis = TikaInputStream.get(noise)) { + detect(tis); + } + } + } + + /** + * A box declaring more than the file holds ends the walk. + */ + @Test + public void testOversizedBox() throws Exception { + byte[] mp4 = mp4(box("mdat", new byte[8])); + int mdat = mp4.length - 16; + mp4[mdat] = 0x7F; + mp4[mdat + 1] = (byte) 0xFF; + mp4[mdat + 2] = (byte) 0xFF; + mp4[mdat + 3] = (byte) 0xFF; + try (TikaInputStream tis = TikaInputStream.get(mp4)) { + assertEquals("application/octet-stream", detect(tis)); + } + } + + private String detect(TikaInputStream tis) throws IOException { + return detector.detect(tis, new Metadata(), new ParseContext()).toString(); + } + + private static byte[] mp4(byte[]... boxes) throws IOException { + return mp4("isom", boxes); + } + + private static byte[] mp4(String brand, byte[]... boxes) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(box("ftyp", (brand + " " + brand).getBytes(StandardCharsets.US_ASCII))); + for (byte[] b : boxes) { + out.write(b); + } + return out.toByteArray(); + } + + private static byte[] mdat(int size) throws IOException { + return box("mdat", new byte[size]); + } + + private static byte[] moov(String handler) throws IOException { + return box("moov", track(handler)); + } + + /** + * A track box holding the handler where a real one has it: trak, mdia, + * hdlr. + */ + private static byte[] track(String handler) throws IOException { + return box("trak", box("mdia", hdlr(handler))); + } + + private static byte[] hdlr(String handler) throws IOException { + return box("hdlr", + concat(new byte[8], handler.getBytes(StandardCharsets.US_ASCII), new byte[12])); + } + + private static byte[] box(String type, byte[] payload) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int size = 8 + payload.length; + out.write(new byte[]{(byte) (size >>> 24), (byte) (size >>> 16), (byte) (size >>> 8), + (byte) size}); + out.write(type.getBytes(StandardCharsets.US_ASCII)); + out.write(payload); + return out.toByteArray(); + } + + private static byte[] concat(byte[]... parts) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] p : parts) { + out.write(p); + } + return out.toByteArray(); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/Mp4SampleEntriesTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/Mp4SampleEntriesTest.java index 9b3a2499924..0d486f7971e 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/Mp4SampleEntriesTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/Mp4SampleEntriesTest.java @@ -68,14 +68,14 @@ public void testTruncatedAndUndersizedEntriesStopTheWalk() { @Test public void testPrintableFourCC() { - assertEquals("mp4a", Mp4SampleEntries.printableFourCC(ascii("mp4a"), 0)); + assertEquals("mp4a", Mp4Boxes.printableFourCC(ascii("mp4a"), 0)); //QuickTime pads short codes with spaces - assertEquals("raw", Mp4SampleEntries.printableFourCC(ascii("raw "), 0)); - assertEquals("rle", Mp4SampleEntries.printableFourCC(ascii("rle "), 0)); - assertNull(Mp4SampleEntries.printableFourCC(ascii(" "), 0)); - assertNull(Mp4SampleEntries.printableFourCC(new byte[]{0, 1, 2, 3}, 0)); - assertNull(Mp4SampleEntries.printableFourCC(new byte[]{'a', 'v', 'c', 0x7F}, 0)); - assertNull(Mp4SampleEntries.printableFourCC(new byte[]{(byte) 0xE4, 'v', 'c', '1'}, 0)); + assertEquals("raw", Mp4Boxes.printableFourCC(ascii("raw "), 0)); + assertEquals("rle", Mp4Boxes.printableFourCC(ascii("rle "), 0)); + assertNull(Mp4Boxes.printableFourCC(ascii(" "), 0)); + assertNull(Mp4Boxes.printableFourCC(new byte[]{0, 1, 2, 3}, 0)); + assertNull(Mp4Boxes.printableFourCC(new byte[]{'a', 'v', 'c', 0x7F}, 0)); + assertNull(Mp4Boxes.printableFourCC(new byte[]{(byte) 0xE4, 'v', 'c', '1'}, 0)); //an unprintable FourCC reaches the visitor as null but does not stop the walk byte[] stsd = stsd(entry(24, "\u0001vc1", 16), entry(24, "mp4a", 16)); assertEquals(List.of("null:16:32", "mp4a:40:56"), walk(stsd));