Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
50.00% covered (danger)
50.00%
1 / 2
CRAP
96.43% covered (success)
96.43%
81 / 84
ImageMimeTypeSniffer
0.00% covered (danger)
0.00%
0 / 1
50.00% covered (danger)
50.00%
1 / 2
31
96.43% covered (success)
96.43%
81 / 84
 checkFilePathIsRegularFile
100.00% covered (success)
100.00%
1 / 1
9
100.00% covered (success)
100.00%
17 / 17
 detect
0.00% covered (danger)
0.00%
0 / 1
22
95.52% covered (success)
95.52%
64 / 67
<?php
namespace ImageMimeTypeSniffer;
class ImageMimeTypeSniffer
{
    private static function checkFilePathIsRegularFile($input)
    {
        if (gettype($input) !== 'string') {
            throw new \Exception('File path must be string');
        }
        if (strpos($input, chr(0)) !== false) {
            throw new \Exception('NUL character is not allowed in file path!');
        }
        if (preg_match('#[\x{0}-\x{1f}]#', $input)) {
            // prevents line feed, new line, tab, charater return, tab, ets.
            throw new \Exception('Control characters #0-#20 not allowed in file path!');
        }
        // Prevent phar stream wrappers (security threat)
        if (preg_match('#^phar://#', $input)) {
            throw new \Exception('phar stream wrappers are not allowed in file path');
        }
        if (preg_match('#^(php|glob)://#', $input)) {
            throw new \Exception('php and glob stream wrappers are not allowed in file path');
        }
        if (empty($input)) {
            throw new \Exception('File path is empty!');
        }
        if (!@file_exists($input)) {
            throw new \Exception('File does not exist');
        }
        if (@is_dir($input)) {
            throw new \Exception('Expected a regular file, not a dir');
        }
    }
    /**
     * Try to detect mime type by sniffing the signature
     *
     * Returns:
     * - mime type (string) (if it is in fact an image, and type could be determined)
     * - null  (if nothing can be determined)
     *
     * @param  string  $filePath  The path to the file
     * @return string|null  mimetype (if it is an image, and type could be determined),
     *    or null (if the file does not match any of the signatures tested)
     * @throws \Exception  if file cannot be opened/read
     */
    public static function detect($filePath)
    {
        self::checkFilePathIsRegularFile($filePath);
        $handle = @fopen($filePath, 'r');
        if ($handle === false) {
            throw new \Exception('File could not be opened');
        }
        // 20 bytes is sufficient for all our sniffers, except image/svg+xml.
        // The svg sniffer takes care of reading more
        $sampleBin = @fread($handle, 20);
        if ($sampleBin === false) {
            throw new \Exception('File could not be read');
        }
        if (strlen($sampleBin) < 20) {
            return null;  // File is too small for us to deal with
        }
        $firstByte = $sampleBin[0];
        $sampleHex = strtoupper(bin2hex($sampleBin));
        $hexPatterns = [];
        $binPatterns = [];
        //$hexPatterns[] = ['image/heic', "/667479706865(6963|6978|7663|696D|6973|766D|7673)/"];
        //$hexPatterns[] = ['image/heif', "/667479706D(69|73)6631)/"];
        // heic:
        // HEIC signature: https://github.com/strukturag/libheif/issues/83#issuecomment-421427091
        // https://nokiatech.github.io/heif/technical.html
        // https://perkeep.org/internal/magic/magic.go
        // https://www.file-recovery.com/mp4-signature-format.htm
        $binPatterns[] = ['image/heic', "/^(.{4}|.{8})ftyphe(ic|ix|vc|im|is|vm|vs)/"];
        //$binPatterns[] = ['image/heif', "/^(.{4}|.{8})ftypm(i|s)f1/"];
        // https://www.rapidtables.com/convert/number/hex-to-ascii.html
        switch ($firstByte) {
            case "\x00":
                $hexPatterns[] = ['image/x-icon', "/^00000(1?2)00/"];
                $binPatterns[] = ['image/avif', "/^(.{4}|.{8})ftypavif/"];
                if (preg_match("/^.{8}6A502020/", $sampleHex) === 1) {
                    // jpeg-2000 - a bit more complex, as block size may vary
                    // https://www.file-recovery.com/jp2-signature-format.htm
                    $block1Size = hexdec("0x" . substr($sampleHex, 0, 8));
                    $moreBytes = @fread($handle, $block1Size + 4 + 8);
                    if ($moreBytes !== false) {
                        $sampleBin .= $moreBytes;
                    }
                    if (substr($sampleBin, $block1Size + 4, 4) == 'ftyp') {
                        $subtyp = substr($sampleBin, $block1Size + 8, 4);
                        // "jp2 " (.JP2), "jp20" (.JPA), "jpm " (.JPM), "jpx " (.JPX).
                        if ($subtyp == 'mjp2') {
                            return 'video/mj2';
                        } else {
                            return 'image/' . rtrim($subtyp);
                        }
                    }
                }
                break;
            case "8":
                $binPatterns[] = ['application/psd', "/^8BPS/"];
                break;
            case "B":
                $binPatterns[] = ['image/bmp', "/^BM/"];
                break;
            case "G":
                $binPatterns[] = ['image/gif', "/^GIF8(7|9)a/"];
                break;
            case "I":
                $hexPatterns[] = ['image/tiff', "/^(49492A00|4D4D002A)/"];
                break;
            case "R":
                // PS: Another library is more specific: /^RIFF.{4}WEBPVP/
                // Is "VP" always there?
                $binPatterns[] = ['image/webp', "/^RIFF.{4}WEBP/"];
                break;
            case "<":
                // Another library looks for end bracket for svg.
                // We do not, as it requires more bytes read.
                // Note that <xml> tag might be big too... - so we read in 200 extra
                $moreBytes = @fread($handle, 200);
                if ($moreBytes !== false) {
                    $sampleBin .= $moreBytes;
                }
                $binPatterns[] = ['image/svg+xml', "/^(<\?xml[^>]*\?>.*)?<svg/is"];
                break;
            case "\x89":
                $hexPatterns[] = ['image/png', "/^89504E470D0A1A0A/"];
                break;
            case "\xFF":
                $hexPatterns[] = ['image/jpeg', "/^FFD8FF(DB|E0|EE|E1)/"];
                break;
        }
        foreach ($hexPatterns as list($mime, $pattern)) {
            if (preg_match($pattern, $sampleHex) === 1) {
                return $mime;
            }
        }
        foreach ($binPatterns as list($mime, $pattern)) {
            if (preg_match($pattern, $sampleBin) === 1) {
                return $mime;
            }
        }
        return null;
        /*
        https://en.wikipedia.org/wiki/List_of_file_signatures
        https://github.com/zjsxwc/mime-type-sniffer/blob/master/src/MimeTypeSniffer/MimeTypeSniffer.php
        http://phil.lavin.me.uk/2011/12/php-accurately-detecting-the-type-of-a-file/
*/
    }
}