11using System ;
22using System . Buffers ;
3+ using System . Collections . Generic ;
34using System . IO ;
45using System . Linq ;
56using System . Text ;
@@ -10,9 +11,9 @@ public static partial class MimeHelper
1011{
1112 private readonly record struct MagicSignature ( byte [ ] Signature , string Mime , int Offset = 0 ) ;
1213
13- private static readonly MagicSignature [ ] MagicSignatures =
14+ private static readonly MagicSignature [ ] BuiltInMagicSignatures =
1415 {
15- new ( [ 0x25 , 0x50 , 0x44 , 0x46 ] , "application/pdf" ) ,
16+ new ( [ 0x25 , 0x50 , 0x44 , 0x46 , 0x2D ] , "application/pdf" ) ,
1617 new ( [ 0xFF , 0xD8 , 0xFF ] , "image/jpeg" ) ,
1718 new ( [ 0x89 , 0x50 , 0x4E , 0x47 , 0x0D , 0x0A , 0x1A , 0x0A ] , "image/png" ) ,
1819 new ( [ 0x47 , 0x49 , 0x46 , 0x38 ] , "image/gif" ) ,
@@ -38,7 +39,9 @@ public static partial class MimeHelper
3839 new ( [ 0x3C , 0x00 , 0x3F , 0x00 ] , "text/xml" , 0 )
3940 } ;
4041
41- private static readonly int MaxSignatureLength = MagicSignatures . Max ( static signature => signature . Offset + signature . Signature . Length ) ;
42+ private static readonly int BuiltInMaxSignatureLength = BuiltInMagicSignatures . Max ( static signature => signature . Offset + signature . Signature . Length ) ;
43+ private static MagicSignature [ ] RegistryMagicSignatures = Array . Empty < MagicSignature > ( ) ;
44+ private static int MaxContentSniffLength = Math . Max ( BuiltInMaxSignatureLength , ZipProbeLength ) ;
4245 private static readonly byte [ ] RiffSignature = [ 0x52 , 0x49 , 0x46 , 0x46 ] ;
4346 private static readonly byte [ ] WebpFourCC = [ 0x57 , 0x45 , 0x42 , 0x50 ] ;
4447 private static readonly byte [ ] AviFourCC = [ 0x41 , 0x56 , 0x49 , 0x20 ] ;
@@ -65,7 +68,18 @@ public static partial class MimeHelper
6568 private static readonly byte [ ] RarSignature = [ 0x52 , 0x61 , 0x72 , 0x21 ] ;
6669 private static readonly byte [ ] MzSignature = [ 0x4D , 0x5A , 0x90 , 0x00 ] ;
6770 private static readonly byte [ ] RtfSignature = [ 0x7B , 0x5C , 0x72 , 0x74 ] ;
68- private static readonly int MaxContentSniffLength = Math . Max ( MaxSignatureLength , ZipProbeLength ) ;
71+
72+ private static void RefreshContentDetectionSignatures ( )
73+ {
74+ var registrySignatures = BuildRegistryMagicSignatures ( ) ;
75+ RegistryMagicSignatures = registrySignatures ;
76+
77+ var maxRegistryLength = registrySignatures . Length == 0
78+ ? 0
79+ : registrySignatures . Max ( static signature => signature . Offset + signature . Signature . Length ) ;
80+
81+ MaxContentSniffLength = Math . Max ( Math . Max ( BuiltInMaxSignatureLength , maxRegistryLength ) , ZipProbeLength ) ;
82+ }
6983
7084 /// <summary>
7185 /// Detects the MIME type of a file by inspecting its binary signature.
@@ -74,14 +88,26 @@ public static partial class MimeHelper
7488 /// <returns>The detected MIME type or <see cref="DefaultMimeType"/> when no signature matches.</returns>
7589 /// <exception cref="ArgumentNullException">Thrown when <paramref name="filePath"/> is null.</exception>
7690 public static string GetMimeTypeByContent ( string filePath )
91+ {
92+ return TryGetMimeTypeByContent ( filePath , out var mime ) ? mime : DefaultMimeType ;
93+ }
94+
95+ /// <summary>
96+ /// Attempts to detect the MIME type of a file by inspecting its binary signature.
97+ /// </summary>
98+ /// <param name="filePath">The path to the file whose content should be analysed.</param>
99+ /// <param name="mime">The detected MIME type when the call succeeds; otherwise <see cref="DefaultMimeType"/>.</param>
100+ /// <returns><c>true</c> when a known content signature matches; otherwise <c>false</c>.</returns>
101+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="filePath"/> is null.</exception>
102+ public static bool TryGetMimeTypeByContent ( string filePath , out string mime )
77103 {
78104 if ( filePath == null )
79105 {
80106 throw new ArgumentNullException ( nameof ( filePath ) ) ;
81107 }
82108
83109 using var fileStream = new FileStream ( filePath , FileMode . Open , FileAccess . Read , FileShare . ReadWrite ) ;
84- return GetMimeTypeByContent ( fileStream ) ;
110+ return TryGetMimeTypeByContent ( fileStream , out mime ) ;
85111 }
86112
87113 /// <summary>
@@ -91,12 +117,25 @@ public static string GetMimeTypeByContent(string filePath)
91117 /// <returns>The detected MIME type or <see cref="DefaultMimeType"/> when no signature matches.</returns>
92118 /// <exception cref="ArgumentNullException">Thrown when <paramref name="fileStream"/> is null.</exception>
93119 public static string GetMimeTypeByContent ( Stream fileStream )
120+ {
121+ return TryGetMimeTypeByContent ( fileStream , out var mime ) ? mime : DefaultMimeType ;
122+ }
123+
124+ /// <summary>
125+ /// Attempts to detect the MIME type of a stream by inspecting its initial bytes.
126+ /// </summary>
127+ /// <param name="fileStream">The stream whose content should be analysed.</param>
128+ /// <param name="mime">The detected MIME type when the call succeeds; otherwise <see cref="DefaultMimeType"/>.</param>
129+ /// <returns><c>true</c> when a known content signature matches; otherwise <c>false</c>.</returns>
130+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="fileStream"/> is null.</exception>
131+ public static bool TryGetMimeTypeByContent ( Stream fileStream , out string mime )
94132 {
95133 if ( fileStream == null )
96134 {
97135 throw new ArgumentNullException ( nameof ( fileStream ) ) ;
98136 }
99137
138+ mime = DefaultMimeType ;
100139 var buffer = ArrayPool < byte > . Shared . Rent ( MaxContentSniffLength ) ;
101140 long ? position = null ;
102141
@@ -116,12 +155,18 @@ public static string GetMimeTypeByContent(Stream fileStream)
116155
117156 if ( bytesRead <= 0 )
118157 {
119- return DefaultMimeType ;
158+ return false ;
120159 }
121160
122161 var header = new ReadOnlySpan < byte > ( buffer , 0 , bytesRead ) ;
123162 var detected = DetectMimeType ( header ) ;
124- return detected ?? DefaultMimeType ;
163+ if ( detected == null )
164+ {
165+ return false ;
166+ }
167+
168+ mime = detected ;
169+ return true ;
125170 }
126171 finally
127172 {
@@ -134,6 +179,132 @@ public static string GetMimeTypeByContent(Stream fileStream)
134179 }
135180 }
136181
182+ /// <summary>
183+ /// Determines whether a file's content signature matches the expected MIME type.
184+ /// </summary>
185+ /// <param name="filePath">The path to the file whose content should be analysed.</param>
186+ /// <param name="expectedMime">The MIME type expected for the file content.</param>
187+ /// <returns><c>true</c> when content detection succeeds and matches <paramref name="expectedMime"/>; otherwise <c>false</c>.</returns>
188+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="filePath"/> is null.</exception>
189+ public static bool MatchesMimeTypeByContent ( string filePath , string expectedMime )
190+ {
191+ if ( filePath == null )
192+ {
193+ throw new ArgumentNullException ( nameof ( filePath ) ) ;
194+ }
195+
196+ using var fileStream = new FileStream ( filePath , FileMode . Open , FileAccess . Read , FileShare . ReadWrite ) ;
197+ return MatchesMimeTypeByContent ( fileStream , expectedMime ) ;
198+ }
199+
200+ /// <summary>
201+ /// Determines whether a stream's content signature matches the expected MIME type.
202+ /// </summary>
203+ /// <param name="fileStream">The stream whose content should be analysed.</param>
204+ /// <param name="expectedMime">The MIME type expected for the stream content.</param>
205+ /// <returns><c>true</c> when content detection succeeds and matches <paramref name="expectedMime"/>; otherwise <c>false</c>.</returns>
206+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="fileStream"/> is null.</exception>
207+ public static bool MatchesMimeTypeByContent ( Stream fileStream , string expectedMime )
208+ {
209+ if ( fileStream == null )
210+ {
211+ throw new ArgumentNullException ( nameof ( fileStream ) ) ;
212+ }
213+
214+ return ! string . IsNullOrWhiteSpace ( expectedMime ) &&
215+ TryGetMimeTypeByContent ( fileStream , out var detectedMime ) &&
216+ IsSameMimeType ( detectedMime , expectedMime ) ;
217+ }
218+
219+ /// <summary>
220+ /// Determines whether a file's content signature matches the MIME type implied by its extension.
221+ /// </summary>
222+ /// <param name="filePath">The file path whose extension and content should be compared.</param>
223+ /// <returns><c>true</c> when the file has a known extension and its content signature matches that MIME type; otherwise <c>false</c>.</returns>
224+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="filePath"/> is null.</exception>
225+ public static bool MatchesExtensionByContent ( string filePath )
226+ {
227+ if ( filePath == null )
228+ {
229+ throw new ArgumentNullException ( nameof ( filePath ) ) ;
230+ }
231+
232+ return TryGetMappedMimeType ( filePath , out var expectedMime ) &&
233+ MatchesMimeTypeByContent ( filePath , expectedMime ) ;
234+ }
235+
236+ /// <summary>
237+ /// Determines whether a stream's content signature matches the MIME type implied by a file name or extension.
238+ /// </summary>
239+ /// <param name="fileName">A file name, URI, or extension used to resolve the expected MIME type.</param>
240+ /// <param name="fileStream">The stream whose content should be analysed.</param>
241+ /// <returns><c>true</c> when the name has a known extension and the content signature matches that MIME type; otherwise <c>false</c>.</returns>
242+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="fileStream"/> is null.</exception>
243+ public static bool MatchesExtensionByContent ( string ? fileName , Stream fileStream )
244+ {
245+ if ( fileStream == null )
246+ {
247+ throw new ArgumentNullException ( nameof ( fileStream ) ) ;
248+ }
249+
250+ return TryGetMappedMimeType ( fileName , out var expectedMime ) &&
251+ MatchesMimeTypeByContent ( fileStream , expectedMime ) ;
252+ }
253+
254+ private static MagicSignature [ ] BuildRegistryMagicSignatures ( )
255+ {
256+ var candidates = new List < MagicSignature > ( ) ;
257+ foreach ( var info in MimeTypeInfos . Values )
258+ {
259+ foreach ( var signature in info . MagicSignatures )
260+ {
261+ if ( signature . Offset < 0 || ! signature . IsBytePrefix )
262+ {
263+ continue ;
264+ }
265+
266+ candidates . Add ( new MagicSignature ( signature . Bytes . ToArray ( ) , info . Mime , signature . Offset ) ) ;
267+ }
268+ }
269+
270+ if ( candidates . Count == 0 )
271+ {
272+ return Array . Empty < MagicSignature > ( ) ;
273+ }
274+
275+ var mimeBySignature = new Dictionary < string , string > ( StringComparer . Ordinal ) ;
276+ var conflictingSignatures = new HashSet < string > ( StringComparer . Ordinal ) ;
277+ foreach ( var signature in candidates )
278+ {
279+ var key = CreateSignatureKey ( signature ) ;
280+ if ( mimeBySignature . TryGetValue ( key , out var existingMime ) )
281+ {
282+ if ( ! string . Equals ( existingMime , signature . Mime , StringComparison . OrdinalIgnoreCase ) )
283+ {
284+ conflictingSignatures . Add ( key ) ;
285+ }
286+
287+ continue ;
288+ }
289+
290+ mimeBySignature . Add ( key , signature . Mime ) ;
291+ }
292+
293+ var seen = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
294+ return candidates
295+ . Where ( signature => ! conflictingSignatures . Contains ( CreateSignatureKey ( signature ) ) )
296+ . Where ( signature => seen . Add ( CreateSignatureKey ( signature ) + "|" + signature . Mime ) )
297+ . OrderByDescending ( static signature => signature . Signature . Length )
298+ . ThenByDescending ( static signature => signature . Offset )
299+ . ThenBy ( static signature => signature . Mime , StringComparer . OrdinalIgnoreCase )
300+ . ToArray ( ) ;
301+ }
302+
303+ private static string CreateSignatureKey ( MagicSignature signature )
304+ {
305+ return string . Concat ( signature . Offset . ToString ( System . Globalization . CultureInfo . InvariantCulture ) , ":" , Convert . ToHexString ( signature . Signature ) ) ;
306+ }
307+
137308 private static int ReadUpTo ( Stream stream , byte [ ] buffer , int count )
138309 {
139310 var totalRead = 0 ;
@@ -153,7 +324,14 @@ private static int ReadUpTo(Stream stream, byte[] buffer, int count)
153324
154325 private static string ? DetectMimeType ( ReadOnlySpan < byte > header )
155326 {
156- foreach ( var signature in MagicSignatures )
327+ return DetectMagicSignature ( header , BuiltInMagicSignatures ) ??
328+ DetectComplexSignature ( header ) ??
329+ DetectMagicSignature ( header , RegistryMagicSignatures ) ;
330+ }
331+
332+ private static string ? DetectMagicSignature ( ReadOnlySpan < byte > header , IReadOnlyList < MagicSignature > signatures )
333+ {
334+ foreach ( var signature in signatures )
157335 {
158336 if ( header . Length < signature . Offset + signature . Signature . Length )
159337 {
@@ -166,7 +344,12 @@ private static int ReadUpTo(Stream stream, byte[] buffer, int count)
166344 }
167345 }
168346
169- return DetectComplexSignature ( header ) ;
347+ return null ;
348+ }
349+
350+ private static bool IsSameMimeType ( string detectedMime , string expectedMime )
351+ {
352+ return string . Equals ( detectedMime . Trim ( ) , expectedMime . Trim ( ) , StringComparison . OrdinalIgnoreCase ) ;
170353 }
171354
172355 private static string ? DetectComplexSignature ( ReadOnlySpan < byte > header )
0 commit comments