commit a89e51f5e7ff75a05b5da1352bf679b686785fd1 Author: H. Utku Maden Date: Sat May 25 09:01:52 2024 +0300 ReMime - File type detector from hell. (I just wanted to reference the first commit message from the git source code.) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..662cfe1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +**/.vs +**/.vscode +**/.atom +**/.rider +**/bin +**/obj + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f69aa3c --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +ReMime - Simple Media Type Resolution +===================================== +ReMime is a very humble library that can identify IANA media types of file +from their file extension and its content. While being fully extensible +with your own resolvers, ReMime will also refer to your operating system's +file type database when resolving files. + +Platform Caveats +---------------- +* On Windows, the default resolver assumes your application has read access to + the registry. +* On Linux, not all `/etc/mime.types` syntax is supported. +* None of this was written with MacOS in mind. But maybe it'll work? + +Refer to `ReMime.Cli` as an example of how to use the library. Refer to in line +documentation and the given default resolvers as an example resolver to +implementations. + +Contributing +------------ +Feel free to contribute your own file type resolvers and bug fixes. The more +file types that can be detected accurately, the better. diff --git a/ReMime.Cli/Program.cs b/ReMime.Cli/Program.cs new file mode 100644 index 0000000..77fabd3 --- /dev/null +++ b/ReMime.Cli/Program.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace ReMime.Cli +{ + public static class Program + { + private const string USAGE = "remime [-r] file/directory/-...\n" + + "remime --help for more help."; + + private const string HELP = + "ReMime Command Line Tool - Determine file Media Type\n" + + "\n" + + " remime [-r] file/directory/-...\n" + + "\n" + + " file infer a file\n"+ + " directory infer files in directory. Requires -r\n"+ + " - infer from standard input.\n"+ + " -r search files and folders recursively.\n"+ + " -a include hidden files.\n" + + " -v verbose mode, use full paths.\n" + + " --list list known mime types. Will ignore files.\n" + + " --help show this help text."; + + private static bool HiddenFiles = false; + private static bool FullPaths = false; + private static bool Recursive = false; + private static bool MinorError = false; + private static bool MajorError = false; + + [DoesNotReturn] + private static void Usage() + { + Console.WriteLine(USAGE); + Environment.Exit(0); + } + + [DoesNotReturn] + private static void Help() + { + Console.WriteLine(HELP); + Environment.Exit(0); + } + + [DoesNotReturn] + private static void ListTypes() + { + foreach (MediaType type in MediaTypeResolver.KnownTypes) + { + Console.WriteLine("{0}\t{1}", type.FullTypeNoParameters, string.Join(' ', type.Extensions)); + } + + Environment.Exit(0); + } + + private static string GetPath(FileSystemInfo info) + { + return FullPaths ? info.FullName : Path.GetRelativePath(Environment.CurrentDirectory, info.FullName); + } + + private static void PrintInferenceResult(MediaTypeResult result, string file, MediaType type) + { + Console.WriteLine("{0}{1}\t{2}\t{3}", + result.HasFlag(MediaTypeResult.Extension) ? 'e' : '-', + result.HasFlag(MediaTypeResult.Content) ? 'c' : '-', + file, + type.FullTypeNoParameters); + } + private static void PrintInferenceResult(MediaTypeResult result, FileInfo file, MediaType type) + => PrintInferenceResult(result, GetPath(file), type); + + + private static void InferStdin() + { + using MemoryStream ms = new MemoryStream(1024); + using Stream stdin = Console.OpenStandardInput(); + stdin.CopyTo(ms); + ms.Seek(0, SeekOrigin.Begin); + MediaTypeResult result = MediaTypeResolver.TryResolve(ms, out MediaType mediaType) ? MediaTypeResult.Content : 0; + PrintInferenceResult(result, "", mediaType); + } + + private static void InferFile(FileInfo file) + { + if (file.Attributes.HasFlag(FileAttributes.Hidden) && !HiddenFiles) + return; + + MediaTypeResult result = MediaTypeResolver.TryResolve(file, out MediaType type); + PrintInferenceResult(result, file, type); + } + + private static void InferDirectory(DirectoryInfo directory) + { + if (directory.Attributes.HasFlag(FileAttributes.Hidden) && !HiddenFiles) + { + return; + } + + foreach (var node in directory.GetFileSystemInfos()) + { + if (node.Attributes.HasFlag(FileAttributes.Directory)) + { + if (Recursive) + { + InferDirectory((DirectoryInfo)node); + } + else + { + Console.WriteLine("# Skipping directory {0}, set -r to traverse.", GetPath(node)); + MinorError = true; + } + } + else + { + InferFile((FileInfo)node); + } + } + } + + public static void Main(string[] args) + { + if (args.Length == 0) + { + Usage(); + } + + List nodes = new List(); + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--help": + case "/?": + Help(); + return; + case "--list": + ListTypes(); + return; + case "-a": + HiddenFiles = true; + break; + case "-v": + FullPaths = true; + break; + case "-r": + Recursive = true; + break; + case "-": + InferStdin(); + break; + default: + if (Directory.Exists(args[i])) + { + nodes.Add(new DirectoryInfo(args[i])); + } + else if (File.Exists(args[i])) + { + nodes.Add(new FileInfo(args[i])); + } + else + { + Console.WriteLine("# Path {0} does not exist. Skipping...", args[i]); + MajorError = true; + } + break; + } + } + + foreach (var node in nodes) + { + try + { + if (node.Attributes.HasFlag(FileAttributes.Directory)) + { + InferDirectory((DirectoryInfo)node); + } + else + { + InferFile((FileInfo)node); + } + } + catch (Exception ex) + { + Console.WriteLine("# Error while processing {0}: {1}", node.FullName, ex.Message); + MajorError = true; + } + } + + Environment.Exit(MajorError ? 1 : (MinorError ? 2 : 0)); + } + } +} \ No newline at end of file diff --git a/ReMime.Cli/ReMime.Cli.csproj b/ReMime.Cli/ReMime.Cli.csproj new file mode 100644 index 0000000..00de7e5 --- /dev/null +++ b/ReMime.Cli/ReMime.Cli.csproj @@ -0,0 +1,17 @@ + + + + + + + + Exe + net6.0 + disable + enable + true + true + refile + + + diff --git a/ReMime.Tests/GlobalUsings.cs b/ReMime.Tests/GlobalUsings.cs new file mode 100644 index 0000000..d8624cd --- /dev/null +++ b/ReMime.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using Microsoft.VisualStudio.TestTools.UnitTesting; +global using ReMime; diff --git a/ReMime.Tests/MediaTypesByExtension.cs b/ReMime.Tests/MediaTypesByExtension.cs new file mode 100644 index 0000000..d40e781 --- /dev/null +++ b/ReMime.Tests/MediaTypesByExtension.cs @@ -0,0 +1,77 @@ +using System.Runtime.CompilerServices; +using ReMime.Platform; + +namespace ReMime.Tests +{ + public abstract class MediaTypesByExtension where T : IMediaTypeResolver, new() + { + private T CIT; + + protected MediaTypesByExtension() + { + Unsafe.SkipInit(out CIT); + } + + [TestInitialize] + public virtual void Initialize() + { + CIT = new T(); + } + + readonly (string extension, string type)[] ExampleMimeTypes = new (string, string)[] { + ("png", "image/png"), + ("gif", "image/gif"), + ("jpeg", "image/jpeg"), + ("jpg", "image/jpeg"), + ("txt", "text/plain"), + ("css", "text/css"), + ("mp4", "video/mp4"), + ("ttf", "font/ttf") + }; + + [TestMethod] + public void PassKnownTypes() + { + foreach (var(ext, type) in ExampleMimeTypes) + { + Assert.IsTrue(CIT.TryResolve(ext, out MediaType? result)); + Assert.AreEqual(result!.FullType, type); + Assert.IsTrue(result.Extensions.Contains(ext)); + } + } + } + + [TestClass] + public class UnixMediaTypes : MediaTypesByExtension + { + [TestInitialize] + public override void Initialize() + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD()) + { + base.Initialize(); + } + else + { + Assert.Inconclusive("Cannot test this in this platform."); + } + } + } + + [TestClass] + public class Win32MediaTypes : MediaTypesByExtension + { + [TestInitialize] + public override void Initialize() + { + if (OperatingSystem.IsWindows()) + { + base.Initialize(); + } + else + { + Assert.Inconclusive("Cannot test this in this platform."); + } + } + } +} diff --git a/ReMime.Tests/ReMime.Tests.csproj b/ReMime.Tests/ReMime.Tests.csproj new file mode 100644 index 0000000..b4a4fd8 --- /dev/null +++ b/ReMime.Tests/ReMime.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + diff --git a/ReMime.sln b/ReMime.sln new file mode 100644 index 0000000..5c75a23 --- /dev/null +++ b/ReMime.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReMime", "ReMime\ReMime.csproj", "{05FAB3CF-78AF-4D34-97D1-C3AB24D4C59F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReMime.Tests", "ReMime.Tests\ReMime.Tests.csproj", "{FEEB5BAD-3B18-4A88-A212-32EC9DA93BDE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReMime.Cli", "ReMime.Cli\ReMime.Cli.csproj", "{51AB44A2-D4EB-4CC8-BE4E-EF1912350629}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {05FAB3CF-78AF-4D34-97D1-C3AB24D4C59F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05FAB3CF-78AF-4D34-97D1-C3AB24D4C59F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05FAB3CF-78AF-4D34-97D1-C3AB24D4C59F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05FAB3CF-78AF-4D34-97D1-C3AB24D4C59F}.Release|Any CPU.Build.0 = Release|Any CPU + {FEEB5BAD-3B18-4A88-A212-32EC9DA93BDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEEB5BAD-3B18-4A88-A212-32EC9DA93BDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEEB5BAD-3B18-4A88-A212-32EC9DA93BDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEEB5BAD-3B18-4A88-A212-32EC9DA93BDE}.Release|Any CPU.Build.0 = Release|Any CPU + {51AB44A2-D4EB-4CC8-BE4E-EF1912350629}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51AB44A2-D4EB-4CC8-BE4E-EF1912350629}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51AB44A2-D4EB-4CC8-BE4E-EF1912350629}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51AB44A2-D4EB-4CC8-BE4E-EF1912350629}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/ReMime/ContentResolvers/Audio/.gitkeep b/ReMime/ContentResolvers/Audio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ReMime/ContentResolvers/Font/.gitkeep b/ReMime/ContentResolvers/Font/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ReMime/ContentResolvers/Image/.gitkeep b/ReMime/ContentResolvers/Image/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ReMime/ContentResolvers/Message/.gitkeep b/ReMime/ContentResolvers/Message/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ReMime/ContentResolvers/Model/.gitkeep b/ReMime/ContentResolvers/Model/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ReMime/ContentResolvers/README.md b/ReMime/ContentResolvers/README.md new file mode 100644 index 0000000..ce23a5b --- /dev/null +++ b/ReMime/ContentResolvers/README.md @@ -0,0 +1,3 @@ +If you would like to contribute a file type resolver please add them +to the appropriate namespace under this folder. This namespace should +match with the IANA namespaces of the media type in question. diff --git a/ReMime/ContentResolvers/Text/.gitkeep b/ReMime/ContentResolvers/Text/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ReMime/ContentResolvers/Video/.gitkeep b/ReMime/ContentResolvers/Video/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ReMime/IMediaContentResolver.cs b/ReMime/IMediaContentResolver.cs new file mode 100644 index 0000000..927ce2b --- /dev/null +++ b/ReMime/IMediaContentResolver.cs @@ -0,0 +1,25 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +namespace ReMime +{ + public interface IMediaContentResolver : IMediaTypeResolver + { + /// + /// Resolve media type via file extension. + /// + /// The stream to match the content with. + /// The media type for this extension. + /// True if content matched. + bool TryResolve(Stream str, [NotNullWhen(true)] out MediaType? mediaType); + + /// + /// Resolve media type via file extension. + /// + /// The stream to match the content with. + /// The media type for this extension. + /// True if content matched. + bool TryResolve(ReadOnlySpan content, [NotNullWhen(true)] out MediaType? mediaType); + } +} \ No newline at end of file diff --git a/ReMime/IMediaTypeResolver.cs b/ReMime/IMediaTypeResolver.cs new file mode 100644 index 0000000..6942739 --- /dev/null +++ b/ReMime/IMediaTypeResolver.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace ReMime +{ + public interface IMediaTypeResolver + { + /// + /// Known media types. + /// + IReadOnlyCollection MediaTypes { get; } + + /// + /// Resolve media type via file extension. + /// + /// The file extension of the file without the leading dot. + /// The media type for this extension. + /// True if a type matched. + bool TryResolve(string extension, out MediaType? mediaType); + } +} \ No newline at end of file diff --git a/ReMime/MediaType.cs b/ReMime/MediaType.cs new file mode 100644 index 0000000..24fa35a --- /dev/null +++ b/ReMime/MediaType.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace ReMime +{ + /// + /// An IANA media type. + /// + [DebuggerDisplay("{FullType}")] + public class MediaType + { + /// + /// Full media type string, including parameters. + /// + public string FullType { get; } + + /// + /// Media type string excluding parameters. + /// + public string FullTypeNoParameters { get; } + + /// + /// Main media type, e.g. image + /// + public string Type { get; } + + /// + /// Vendor tree, e.g. vnd.microsoft in application/vnd.microsoft.portable-executable + /// + public string? Tree { get; } + + /// + /// Subtype, e.g. png in image/png + /// + public string SubType { get; } + + /// + /// Media type suffix, e.g. json in model/gltf+json + /// + public string? Suffix { get; } + + /// + /// Media type parameters, e.g. encoding=utf-8 in text/plain;encoding=utf-8 + /// + public string? Parameters { get; } + + /// + /// Valid or common file extensions for this media type, excluding the dot. May be empty. + /// + public IReadOnlyCollection Extensions { get; } + + /// + /// Create a media type descriptor. + /// + /// The full media type string. + /// The file extensions if any. + /// Malformed media type string in . + public MediaType(string fullType, IEnumerable? extensions = null) + { + FullType = fullType; + + ReadOnlySpan str = fullType.AsSpan(); + + int slash = str.IndexOf('/'); + int plus = str.IndexOf('+'); + int semicolon = str.IndexOf(';'); + + if (slash == -1) + { + throw new Exception("Malformed media type string."); + } + + Type = new string(str.Slice(0, slash)); + + ReadOnlySpan typeTree; + if (plus != -1) + { + typeTree = str.Slice(slash+1, plus - slash - 1); + } + else if (semicolon != -1) + { + typeTree = str.Slice(slash+1, semicolon - slash - 1); + } + else + { + typeTree = str.Slice(slash+1); + } + + int dot = typeTree.LastIndexOf('.'); + if (dot == -1) + { + SubType = new string(typeTree); + } + else + { + SubType = new string(typeTree.Slice(dot+1)); + Tree = new string(typeTree.Slice(0, dot)); + } + + if (plus != -1) + { + if (semicolon != -1) + { + Suffix = new string(str.Slice(plus+1, semicolon - plus - 1)); + } + else + { + Suffix = new string(str.Slice(plus + 1)); + } + } + + if (semicolon != -1) + { + Parameters = new string(str.Slice(semicolon+1)); + FullTypeNoParameters = new string(str.Slice(0, semicolon)); + } + else + { + FullTypeNoParameters = FullType; + } + + Extensions = (extensions ?? Enumerable.Empty()).ToArray(); + } + + /// + /// Convert Media type to its string. + /// + /// Include media type parameters. + /// A string. + public string ToString(bool includeParameters) => includeParameters ? FullType : FullTypeNoParameters; + + /// + public override string ToString() => ToString(false); + + /// + public override int GetHashCode() + { + return FullType.GetHashCode(); + } + + /// + public override bool Equals(object? obj) + { + return FullType == (obj as MediaType)?.FullType; + } + + public static bool operator==(MediaType a, MediaType b) + { + return a.FullType == b.FullType; + } + + public static bool operator!=(MediaType a, MediaType b) + { + return a.FullType != b.FullType; + } + + /// + /// application/octet-stream is the default for unknown media types. + /// + public static readonly MediaType OctetStream = new MediaType( + "application/octet-stream", + new[] {"bin", "lha", "lzh", "exe", "class", "so", "dll", "img", "iso"}); + } +} \ No newline at end of file diff --git a/ReMime/MediaTypeResolver.cs b/ReMime/MediaTypeResolver.cs new file mode 100644 index 0000000..a84329f --- /dev/null +++ b/ReMime/MediaTypeResolver.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using ReMime.Platform; + +namespace ReMime +{ + public static class MediaTypeResolver + { + private static readonly SortedList s_resolvers = new SortedList(); + private static IReadOnlyList? s_mediaTypes = null; + + public static IEnumerable Resolvers => s_resolvers.Values; + + public static IEnumerable KnownTypes + { + get + { + if (s_mediaTypes != null) + { + return s_mediaTypes; + } + + IEnumerable? x = null; + foreach (IEnumerable types in s_resolvers.Values.Select(x => x.MediaTypes)) + { + x = (x == null) ? types : x.Concat(types); + } + + if (x == null) + { + return Enumerable.Empty(); + } + + return s_mediaTypes = x.DistinctBy(x => x.FullTypeNoParameters).ToImmutableList(); + } + } + + static MediaTypeResolver() + { + if (OperatingSystem.IsWindows()) + { + AddResolver(new Win32MediaTypeResolver()); + } + else if (OperatingSystem.IsLinux()) + { + AddResolver(new UnixMediaTypeResolver()); + // TODO: add freedesktop mime type database. + } + else if (OperatingSystem.IsMacOS()) + { + AddResolver(new UnixMediaTypeResolver()); //? + } + } + + public static void AddResolver(IMediaTypeResolver resolver, int priority = 9999) + { + s_resolvers.Add(priority, resolver); + } + + public static bool TryResolve(ReadOnlySpan path, out MediaType mediaType) + { + path = Path.GetFileName(path); + if (path.Length == 0) + { + throw new ArgumentException("Path is not a file path."); + } + + ReadOnlySpan span = path; + Stack indices = new Stack(); + + for (int i = 0; i < span.Length; i++) + { + if (span[i] == '.') + indices.Push(i); + } + + while (indices.TryPop(out int dot)) + { + string value = new string(path.Slice(dot+1)).ToLowerInvariant(); + + foreach (IMediaTypeResolver resolver in Resolvers) + { + if (resolver.TryResolve(value, out mediaType!)) + { + return true; + } + } + } + + // Could not resolve media type, oh well. + mediaType = MediaType.OctetStream; + return false; + } + + public static bool TryResolve(Stream stream, out MediaType mediaType) + { + if (!stream.CanSeek) + { + throw new Exception("This stream is not seekable, cannot resolve unseekable streams."); + } + + foreach (IMediaTypeResolver resolver in Resolvers) + { + if (resolver is not IMediaContentResolver contentResolver) + { + continue; + } + + stream.Seek(0, SeekOrigin.Begin); + if (contentResolver.TryResolve(stream, out mediaType!)) + { + return true; + } + } + + mediaType = MediaType.OctetStream; + return false; + } + + public static bool TryResolve(ReadOnlySpan bytes, out MediaType mediaType) + { + foreach (IMediaTypeResolver resolver in Resolvers) + { + if (resolver is not IMediaContentResolver contentResolver) + { + continue; + } + + if (contentResolver.TryResolve(bytes, out mediaType!)) + { + return true; + } + } + + mediaType = MediaType.OctetStream; + return false; + } + + public static MediaTypeResult TryResolve(ReadOnlySpan path, ReadOnlySpan bytes, out MediaType mediaType) + { + if (TryResolve(bytes, out mediaType)) + { + // Only return both matched if the media types agree. + return + (!TryResolve(path, out MediaType mt2) || mt2.FullTypeNoParameters != mediaType.FullTypeNoParameters) + ? MediaTypeResult.Content + : MediaTypeResult.Extension | MediaTypeResult.Content; + } + else if (TryResolve(path, out mediaType)) + { + return MediaTypeResult.Extension; + } + else + { + mediaType = MediaType.OctetStream; + return MediaTypeResult.None; + } + } + + public static MediaTypeResult TryResolve(ReadOnlySpan path, Stream stream, out MediaType mediaType) + { + if (TryResolve(stream, out mediaType)) + { + // Only return both matched if the media types agree. + return + (!TryResolve(path, out MediaType mt2) || mt2.FullTypeNoParameters != mediaType.FullTypeNoParameters) + ? MediaTypeResult.Content + : MediaTypeResult.Extension | MediaTypeResult.Content; + } + else if (TryResolve(path, out mediaType)) + { + return MediaTypeResult.Extension; + } + else + { + mediaType = MediaType.OctetStream; + return MediaTypeResult.None; + } + } + + public static MediaTypeResult TryResolve(FileInfo fileInfo, out MediaType mediaType, bool open = true) + { + if (open) + { + using Stream str = fileInfo.OpenRead(); + return TryResolve(fileInfo.Name, str, out mediaType); + } + else + { + return TryResolve(fileInfo.Name, out mediaType) ? MediaTypeResult.Extension : 0; + } + } + + } +} \ No newline at end of file diff --git a/ReMime/MediaTypeResult.cs b/ReMime/MediaTypeResult.cs new file mode 100644 index 0000000..839c41a --- /dev/null +++ b/ReMime/MediaTypeResult.cs @@ -0,0 +1,23 @@ +using System; + +namespace ReMime +{ + [Flags] + public enum MediaTypeResult + { + /// + /// No match was found. + /// + None = 0, + + /// + /// Matched via file extension. + /// + Extension = 1 << 0, + + /// + /// Matched via both file extension and file contents. + /// + Content = 1 << 1, + } +} \ No newline at end of file diff --git a/ReMime/Platform/UnixMediaTypeResolver.cs b/ReMime/Platform/UnixMediaTypeResolver.cs new file mode 100644 index 0000000..e133106 --- /dev/null +++ b/ReMime/Platform/UnixMediaTypeResolver.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; + +namespace ReMime.Platform +{ + public class UnixMediaTypeResolver : IMediaTypeResolver + { + private readonly Dictionary _extensionsMap = new Dictionary(); + public IReadOnlyCollection MediaTypes { get; } + + public UnixMediaTypeResolver() + { + { + bool valid = OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD(); + if (!valid) + throw new PlatformNotSupportedException("This media type resolver is only for *nix systems."); + } + + List mediaTypes = new List(); + + { + using Stream str = File.OpenRead("/etc/mime.types"); + StreamReader reader = new StreamReader(str); + DigestMimeDatabase(reader, mediaTypes); + } + + string localPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".mime.types"); + if (File.Exists(localPath)) + { + using Stream str = File.OpenRead(localPath); + StreamReader reader = new StreamReader(str); + DigestMimeDatabase(reader, mediaTypes); + } + + foreach (MediaType type in mediaTypes) + { + foreach (string extension in type.Extensions) + { + _extensionsMap[extension] = type; + } + } + + MediaTypes = mediaTypes.ToImmutableList(); + } + + public bool TryResolve(string extension, out MediaType? mediaType) + { + if (_extensionsMap.TryGetValue(extension, out mediaType)) + return true; + else + return false; + } + + private static readonly char[] s_delimeters = new char[] { '\t', ' ' }; + + private static void DigestMimeDatabase(TextReader reader, List types) + { + string? line; + string type = string.Empty; + List extensions = new List(); + + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + line = line.Trim(); + + if (line.StartsWith('#')) + continue; + + + extensions.Clear(); + string[] parts = line.Split(s_delimeters, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < parts.Length; i++) + { + if (i == 0) + { + type = parts[0]; + } + else + { + extensions.Add(parts[i]); + } + } + types.Add(new MediaType(type!, extensions)); + } + } + } +} \ No newline at end of file diff --git a/ReMime/Platform/Win32MediaTypeResolver.cs b/ReMime/Platform/Win32MediaTypeResolver.cs new file mode 100644 index 0000000..bb5ddc4 --- /dev/null +++ b/ReMime/Platform/Win32MediaTypeResolver.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Win32; + +namespace ReMime.Platform +{ + public class Win32MediaTypeResolver : IMediaTypeResolver + { + private readonly Dictionary _extensionsMap = new Dictionary(); + public IReadOnlyCollection MediaTypes { get; } + + public Win32MediaTypeResolver() + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException(); + + Dictionary> map = new Dictionary>(); + List list = new List(); + + foreach (var name in Registry.ClassesRoot.GetSubKeyNames().Where(x => x.StartsWith('.'))) + { + RegistryKey? key = Registry.ClassesRoot.OpenSubKey(name); + string? value = key?.GetValue("Content Type") as string; + + if (value == null) + continue; + + if (!map.TryGetValue(value, out List? extensions)) + { + extensions = new List(); + map[value] = extensions; + } + + extensions.Add(name.Substring(1)); + key!.Dispose(); + } + + foreach (var(type, extensions) in map) + { + MediaType mediaType = new MediaType(type, extensions); + list.Add(mediaType); + + foreach (string extension in extensions) + { + _extensionsMap[extension] = mediaType; + } + } + + MediaTypes = list.AsReadOnly(); + } + + public bool TryResolve(string extension, out MediaType? mediaType) + { + if (_extensionsMap.TryGetValue(extension, out mediaType)) + { + return true; + } + else + { + mediaType = null; + return true; + } + } + } +} \ No newline at end of file diff --git a/ReMime/ReMime.csproj b/ReMime/ReMime.csproj new file mode 100644 index 0000000..bdafc84 --- /dev/null +++ b/ReMime/ReMime.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + disable + enable + + +