Skip to content

Commit e89698e

Browse files
committed
Add registry-backed content validation
1 parent 2f9b70b commit e89698e

5 files changed

Lines changed: 304 additions & 30 deletions

File tree

ManagedCode.MimeTypes.Tests/ContentDetectionTests.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ public void PdfHeader_ShouldBeDetected()
1616
Detect(pdfBytes).ShouldBe("application/pdf");
1717
}
1818

19+
[Fact]
20+
public void IncompletePdfHeader_ShouldNotBeDetected()
21+
{
22+
var bytes = Encoding.ASCII.GetBytes("%PDFx");
23+
24+
Detect(bytes).ShouldBe(MimeHelper.BIN);
25+
}
26+
27+
[Fact]
28+
public void MetadataMagicSignature_ShouldBeDetected()
29+
{
30+
var fdfBytes = Encoding.ASCII.GetBytes("%FDF-1.2\n");
31+
32+
Detect(fdfBytes).ShouldBe("application/fdf");
33+
}
34+
1935
[Fact]
2036
public void PngHeader_ShouldBeDetected()
2137
{
@@ -77,6 +93,26 @@ public void EmptyStream_ShouldReturnDefault()
7793
MimeHelper.GetMimeTypeByContent(stream).ShouldBe(MimeHelper.BIN);
7894
}
7995

96+
[Fact]
97+
public void TryGetMimeTypeByContent_ShouldReturnFalseWhenNoSignatureMatches()
98+
{
99+
using var stream = new MemoryStream(new byte[] { 0x01, 0x02 });
100+
101+
MimeHelper.TryGetMimeTypeByContent(stream, out var mime).ShouldBeFalse();
102+
mime.ShouldBe(MimeHelper.BIN);
103+
stream.Position.ShouldBe(0);
104+
}
105+
106+
[Fact]
107+
public void TryGetMimeTypeByContent_ShouldReturnTrueWhenSignatureMatches()
108+
{
109+
using var stream = new MemoryStream(Encoding.ASCII.GetBytes("%PDF-2.0\n"));
110+
111+
MimeHelper.TryGetMimeTypeByContent(stream, out var mime).ShouldBeTrue();
112+
mime.ShouldBe(MimeHelper.PDF);
113+
stream.Position.ShouldBe(0);
114+
}
115+
80116
[Fact]
81117
public void FilePathOverload_ShouldDetect()
82118
{
@@ -93,6 +129,49 @@ public void FilePathOverload_ShouldDetect()
93129
}
94130
}
95131

132+
[Fact]
133+
public void MatchesMimeTypeByContent_ShouldValidateExpectedMime()
134+
{
135+
using var stream = new MemoryStream(Encoding.ASCII.GetBytes("%PDF-2.0\n"));
136+
137+
MimeHelper.MatchesMimeTypeByContent(stream, MimeHelper.PDF).ShouldBeTrue();
138+
stream.Position.ShouldBe(0);
139+
140+
MimeHelper.MatchesMimeTypeByContent(stream, MimeHelper.PNG).ShouldBeFalse();
141+
stream.Position.ShouldBe(0);
142+
}
143+
144+
[Fact]
145+
public void MatchesExtensionByContent_ShouldValidateFileNameAgainstStream()
146+
{
147+
using var stream = new MemoryStream(Encoding.ASCII.GetBytes("%PDF-2.0\n"));
148+
149+
MimeHelper.MatchesExtensionByContent("report.pdf", stream).ShouldBeTrue();
150+
stream.Position.ShouldBe(0);
151+
152+
MimeHelper.MatchesExtensionByContent("report.png", stream).ShouldBeFalse();
153+
stream.Position.ShouldBe(0);
154+
}
155+
156+
[Fact]
157+
public void MatchesExtensionByContent_ShouldValidateFilePathAgainstContent()
158+
{
159+
var tempFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".pdf");
160+
try
161+
{
162+
File.WriteAllBytes(tempFile, Encoding.ASCII.GetBytes("%PDF-2.0\n"));
163+
164+
MimeHelper.MatchesExtensionByContent(tempFile).ShouldBeTrue();
165+
166+
File.WriteAllBytes(tempFile, new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A });
167+
MimeHelper.MatchesExtensionByContent(tempFile).ShouldBeFalse();
168+
}
169+
finally
170+
{
171+
File.Delete(tempFile);
172+
}
173+
}
174+
96175
private static string Detect(byte[] bytes)
97176
{
98177
using var stream = new MemoryStream(bytes);

ManagedCode.MimeTypes/MimeHelper.ContentDetection.cs

Lines changed: 192 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Buffers;
3+
using System.Collections.Generic;
34
using System.IO;
45
using System.Linq;
56
using 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)

ManagedCode.MimeTypes/MimeHelper.Core.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public static partial class MimeHelper
3030
static MimeHelper()
3131
{
3232
Init();
33+
RefreshContentDetectionSignatures();
3334
Volatile.Write(ref _defaultMimeType, string.Intern(BIN));
3435
RefreshScriptMimeSet();
3536
}

0 commit comments

Comments
 (0)