defmodule MagicBytes do @moduledoc """ Detect MIME types from binary content using magic byte signatures. Only the first 16 bytes of input are examined. Three entry points cover the common cases — file path, raw binary, and streaming data: iex> MagicBytes.from_binary(<<0xFF, 0xD8, 0xFF, 0xE0>>) {:ok, "image/jpeg"} iex> MagicBytes.from_binary(<<0x00, 0x00, 0x00, 0x00>>) {:error, :unknown} ## Guards For prefix-based signatures a corresponding guard macro is generated, named `is_`. These expand to pure boolean expressions and can be used in `when` clauses or regular code after `require MagicBytes`: iex> require MagicBytes iex> MagicBytes.is_image_jpeg(<<0xFF, 0xD8, 0xFF, 0xE0>>) true iex> require MagicBytes iex> MagicBytes.is_application_pdf(<>) true Guards are not generated for container-format signatures that require inspecting bytes beyond a fixed prefix (WebP, WAV, AVI, AIFF, MP4, HEIC, AVIF, QuickTime). Use `from_binary/1` for those. ## Supported formats | Category | MIME types | |------------|------------| | Images | `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/bmp`, | `image/tiff`, `image/x-icon`, `image/vnd.adobe.photoshop`, `image/heic`, `image/avif` | | Audio | `audio/mpeg`, `audio/flac`, `audio/ogg`, `audio/wav`, `audio/aiff`, `audio/mp4` | | Video | `video/mp4`, `video/quicktime`, `video/x-matroska`, `video/x-flv`, `video/x-msvideo` | | Documents | `application/pdf`, `application/zip`, `application/x-cfb`, `application/rtf` | | Archives | `application/x-rar-compressed`, `application/x-7z-compressed`, `application/gzip`, | | `application/x-bzip2`, `application/x-xz`, `application/zstd` | | Executable | `application/x-elf`, `application/x-msdownload`, `application/x-mach-binary`, `application/wasm` | | Fonts | `font/woff`, `font/woff2`, `font/otf`, `font/ttf` | | Database | `application/x-sqlite3` | """ require MagicBytes.DefineSignatures require MagicBytes.FileSignatures alias MagicBytes.FileSignatures @type mime_type :: String.t() @type error :: {:error, :unreadable | :unknown} MagicBytes.DefineSignatures.generate_guards(MagicBytes.FileSignatures) @doc """ Detects the MIME type of the file at `path` by reading its first 16 bytes. Returns `{:error, :unreadable}` if the file cannot be opened. ## Examples iex> MagicBytes.from_binary("image_file.jpg") iex> {:ok, "image/jpg"} iex> MagicBytes.from_binary("pdf_file.pdf") iex> {:ok, "application/pdf"} iex> MagicBytes.from_path("/nonexistent/file.jpg") {:error, :unreadable} """ @spec from_path(Path.t()) :: {:ok, mime_type()} | error() def from_path(path) do case File.open(path, [:read, :binary]) do {:ok, file} -> result = file |> IO.binread(16) |> FileSignatures.match() File.close(file) result {:error, _} -> {:error, :unreadable} end end @doc """ Detects the MIME type from a binary. Only the first 16 bytes are examined; passing the full file content is fine but unnecessary. ## Examples iex> MagicBytes.from_binary(<<0xFF, 0xD8, 0xFF, 0xE0>>) {:ok, "image/jpeg"} iex> MagicBytes.from_binary(<<0x89, "PNG", 0x0D, 0x0A, 0x1A, 0x0A>>) {:ok, "image/png"} iex> MagicBytes.from_binary(<>) {:ok, "application/pdf"} iex> MagicBytes.from_binary(<<0x1F, 0x8B>>) {:ok, "application/gzip"} iex> MagicBytes.from_binary(<<0x00, 0x00, 0x00, 0x00>>) {:error, :unknown} """ @spec from_binary(binary()) :: {:ok, mime_type()} | error() def from_binary(data) when is_binary(data), do: FileSignatures.match(data) @doc """ Detects the MIME type from a stream of binaries. Chunks are accumulated until at least 16 bytes are available, then detection runs on the combined header. The stream is not fully consumed. Returns `{:error, :unreadable}` if the stream is empty. ## Examples iex> MagicBytes.from_stream([<<0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>]) {:ok, "image/jpeg"} iex> MagicBytes.from_stream([<<0xFF, 0xD8>>, <<0xFF, 0xE0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>]) {:ok, "image/jpeg"} iex> MagicBytes.from_stream([]) {:error, :unreadable} """ @spec from_stream(Enumerable.t()) :: {:ok, mime_type()} | error() def from_stream(stream) do Enum.reduce_while(stream, <<>>, fn chunk, acc -> combined = acc <> chunk if byte_size(combined) >= 16, do: {:halt, combined}, else: {:cont, combined} end) |> case do <<>> -> {:error, :unreadable} data -> FileSignatures.match(data) end end end