Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
76 / 78
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageMimeTypeSniffer
97.44% covered (success)
97.44%
76 / 78
50.00% covered (danger)
50.00%
1 / 2
31
0.00% covered (danger)
0.00%
0 / 1
 checkFilePathIsRegularFile
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 detect
96.77% covered (success)
96.77%
60 / 62
0.00% covered (danger)
0.00%
0 / 1
22
1<?php
2
3namespace ImageMimeTypeSniffer;
4
5class ImageMimeTypeSniffer
6{
7
8    private static function checkFilePathIsRegularFile($input)
9    {
10        if (gettype($input) !== 'string') {
11            throw new \Exception('File path must be string');
12        }
13        if (strpos($input, chr(0)) !== false) {
14            throw new \Exception('NUL character is not allowed in file path!');
15        }
16        if (preg_match('#[\x{0}-\x{1f}]#', $input)) {
17            // prevents line feed, new line, tab, charater return, tab, ets.
18            throw new \Exception('Control characters #0-#20 not allowed in file path!');
19        }
20        // Prevent phar stream wrappers (security threat)
21        if (preg_match('#^phar://#', $input)) {
22            throw new \Exception('phar stream wrappers are not allowed in file path');
23        }
24        if (preg_match('#^(php|glob)://#', $input)) {
25            throw new \Exception('php and glob stream wrappers are not allowed in file path');
26        }
27        if (empty($input)) {
28            throw new \Exception('File path is empty!');
29        }
30        if (!@file_exists($input)) {
31            throw new \Exception('File does not exist');
32        }
33        if (@is_dir($input)) {
34            throw new \Exception('Expected a regular file, not a dir');
35        }
36    }
37
38
39    /**
40     * Try to detect mime type by sniffing the signature
41     *
42     * Returns:
43     * - mime type (string) (if it is in fact an image, and type could be determined)
44     * - null  (if nothing can be determined)
45     *
46     * @param  string  $filePath  The path to the file
47     * @return string|null  mimetype (if it is an image, and type could be determined),
48     *    or null (if the file does not match any of the signatures tested)
49     * @throws \Exception  if file cannot be opened/read
50     */
51    public static function detect($filePath)
52    {
53        self::checkFilePathIsRegularFile($filePath);
54
55
56        $handle = @fopen($filePath, 'r');
57        if ($handle === false) {
58            throw new \Exception('File could not be opened');
59        }
60        // 20 bytes is sufficient for all our sniffers, except image/svg+xml.
61        // The svg sniffer takes care of reading more
62        $sampleBin = @fread($handle, 20);
63        if ($sampleBin === false) {
64            throw new \Exception('File could not be read');
65        }
66        if (strlen($sampleBin) < 20) {
67            return null;  // File is too small for us to deal with
68        }
69        $firstByte = $sampleBin[0];
70        $sampleHex = strtoupper(bin2hex($sampleBin));
71
72        $hexPatterns = [];
73        $binPatterns = [];
74
75        //$hexPatterns[] = ['image/heic', "/667479706865(6963|6978|7663|696D|6973|766D|7673)/"];
76        //$hexPatterns[] = ['image/heif', "/667479706D(69|73)6631)/"];
77        // heic:
78
79        // HEIC signature: https://github.com/strukturag/libheif/issues/83#issuecomment-421427091
80        // https://nokiatech.github.io/heif/technical.html
81        // https://perkeep.org/internal/magic/magic.go
82        // https://www.file-recovery.com/mp4-signature-format.htm
83        $binPatterns[] = ['image/heic', "/^(.{4}|.{8})ftyphe(ic|ix|vc|im|is|vm|vs)/"];
84        //$binPatterns[] = ['image/heif', "/^(.{4}|.{8})ftypm(i|s)f1/"];
85
86        // https://www.rapidtables.com/convert/number/hex-to-ascii.html
87        switch ($firstByte) {
88            case "\x00":
89                $hexPatterns[] = ['image/x-icon', "/^00000(1?2)00/"];
90
91                $binPatterns[] = ['image/avif', "/^(.{4}|.{8})ftypavif/"];
92
93                if (preg_match("/^.{8}6A502020/", $sampleHex) === 1) {
94                    // jpeg-2000 - a bit more complex, as block size may vary
95                    // https://www.file-recovery.com/jp2-signature-format.htm
96                    $block1Size = hexdec("0x" . substr($sampleHex, 0, 8));
97
98                    $moreBytes = @fread($handle, $block1Size + 4 + 8);
99                    if ($moreBytes !== false) {
100                        $sampleBin .= $moreBytes;
101                    }
102                    if (substr($sampleBin, $block1Size + 4, 4) == 'ftyp') {
103                        $subtyp = substr($sampleBin, $block1Size + 8, 4);
104                        // "jp2 " (.JP2), "jp20" (.JPA), "jpm " (.JPM), "jpx " (.JPX).
105
106                        if ($subtyp == 'mjp2') {
107                            return 'video/mj2';
108                        } else {
109                            return 'image/' . rtrim($subtyp);
110                        }
111                    }
112                }
113
114                break;
115
116            case "8":
117                $binPatterns[] = ['application/psd', "/^8BPS/"];
118                break;
119
120            case "B":
121                $binPatterns[] = ['image/bmp', "/^BM/"];
122                break;
123
124            case "G":
125                $binPatterns[] = ['image/gif', "/^GIF8(7|9)a/"];
126                break;
127
128            case "I":
129                $hexPatterns[] = ['image/tiff', "/^(49492A00|4D4D002A)/"];
130                break;
131
132            case "R":
133                // PS: Another library is more specific: /^RIFF.{4}WEBPVP/
134                // Is "VP" always there?
135                $binPatterns[] = ['image/webp', "/^RIFF.{4}WEBP/"];
136                break;
137
138            case "<":
139                // Another library looks for end bracket for svg.
140                // We do not, as it requires more bytes read.
141                // Note that <xml> tag might be big too... - so we read in 200 extra
142                $moreBytes = @fread($handle, 200);
143                if ($moreBytes !== false) {
144                    $sampleBin .= $moreBytes;
145                }
146                $binPatterns[] = ['image/svg+xml', "/^(<\?xml[^>]*\?>.*)?<svg/is"];
147                break;
148
149            case "\x89":
150                $hexPatterns[] = ['image/png', "/^89504E470D0A1A0A/"];
151                break;
152
153            case "\xFF":
154                $hexPatterns[] = ['image/jpeg', "/^FFD8FF(DB|E0|EE|E1)/"];
155                break;
156        }
157
158        foreach ($hexPatterns as list($mime, $pattern)) {
159            if (preg_match($pattern, $sampleHex) === 1) {
160                return $mime;
161            }
162        }
163        foreach ($binPatterns as list($mime, $pattern)) {
164            if (preg_match($pattern, $sampleBin) === 1) {
165                return $mime;
166            }
167        }
168        return null;
169
170        /*
171        https://en.wikipedia.org/wiki/List_of_file_signatures
172        https://github.com/zjsxwc/mime-type-sniffer/blob/master/src/MimeTypeSniffer/MimeTypeSniffer.php
173        http://phil.lavin.me.uk/2011/12/php-accurately-detecting-the-type-of-a-file/
174
175*/
176    }
177}