summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvadne Wu <ev@radi.ws>2020-05-03 22:58:04 +0100
committerEvadne Wu <ev@radi.ws>2020-05-04 05:04:38 +0100
commit0bc39d9ad11604adafe682dff366b6b8eeb4c541 (patch)
tree0a30e7a33393fe754323dc5f46585789a0bbc0b6
Initial Commit (v1.0.0)master
- History for v0.20.83 and previous versions is moved to archive/v0.20.83
-rw-r--r--.credo.exs86
-rw-r--r--.formatter.exs4
-rw-r--r--.gitignore12
-rw-r--r--.tm_properties2
-rw-r--r--.tool-versions2
-rw-r--r--.travis.yml18
-rw-r--r--CHANGELOG.md42
-rw-r--r--Makefile40
-rw-r--r--README.md130
-rw-r--r--dialyzer-ignore-warnings.exs1
-rw-r--r--lib/gen_magic.ex7
-rw-r--r--lib/gen_magic/config.ex57
-rw-r--r--lib/gen_magic/helpers.ex28
-rw-r--r--lib/gen_magic/result.ex30
-rw-r--r--lib/gen_magic/server.ex268
-rw-r--r--lib/gen_magic/server/data.ex25
-rw-r--r--lib/gen_magic/server/status.ex20
-rw-r--r--mix.exs66
-rw-r--r--mix.lock15
-rw-r--r--src/apprentice.c284
-rw-r--r--test/elixir6
-rw-r--r--test/gen_magic/gen_magic_test.exs53
-rw-r--r--test/gen_magic/helpers_test.exs9
-rw-r--r--test/gen_magic/server_test.exs30
-rw-r--r--test/soak.exs33
-rw-r--r--test/support/magic_case.ex33
-rw-r--r--test/test_helper.exs7
27 files changed, 1308 insertions, 0 deletions
diff --git a/.credo.exs b/.credo.exs
new file mode 100644
index 0000000..0914c13
--- /dev/null
+++ b/.credo.exs
@@ -0,0 +1,86 @@
+%{
+ configs: [
+ %{
+ name: "default",
+ files: %{
+ included: ["lib/", "src/", "test/", "web/", "apps/"],
+ excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
+ },
+ plugins: [],
+ requires: [],
+ strict: true,
+ color: true,
+ checks: [
+ {Credo.Check.Consistency.ExceptionNames, []},
+ {Credo.Check.Consistency.LineEndings, []},
+ {Credo.Check.Consistency.ParameterPatternMatching, []},
+ {Credo.Check.Consistency.SpaceAroundOperators, []},
+ {Credo.Check.Consistency.SpaceInParentheses, []},
+ {Credo.Check.Consistency.TabsOrSpaces, []},
+ {Credo.Check.Design.AliasUsage,
+ [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
+ {Credo.Check.Design.TagTODO, [exit_status: 0]},
+ {Credo.Check.Design.TagFIXME, []},
+ {Credo.Check.Readability.AliasOrder, []},
+ {Credo.Check.Readability.FunctionNames, []},
+ {Credo.Check.Readability.LargeNumbers, []},
+ {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
+ {Credo.Check.Readability.ModuleAttributeNames, []},
+ {Credo.Check.Readability.ModuleDoc, []},
+ {Credo.Check.Readability.ModuleNames, []},
+ {Credo.Check.Readability.ParenthesesInCondition, []},
+ {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
+ {Credo.Check.Readability.PredicateFunctionNames, []},
+ {Credo.Check.Readability.PreferImplicitTry, []},
+ {Credo.Check.Readability.RedundantBlankLines, []},
+ {Credo.Check.Readability.Semicolons, []},
+ {Credo.Check.Readability.SpaceAfterCommas, []},
+ {Credo.Check.Readability.StringSigils, []},
+ {Credo.Check.Readability.TrailingBlankLine, []},
+ {Credo.Check.Readability.TrailingWhiteSpace, []},
+ {Credo.Check.Readability.UnnecessaryAliasExpansion, false},
+ {Credo.Check.Readability.VariableNames, []},
+ {Credo.Check.Refactor.CondStatements, []},
+ {Credo.Check.Refactor.CyclomaticComplexity, []},
+ {Credo.Check.Refactor.FunctionArity, []},
+ {Credo.Check.Refactor.LongQuoteBlocks, []},
+ {Credo.Check.Refactor.MapInto, []},
+ {Credo.Check.Refactor.MatchInCondition, []},
+ {Credo.Check.Refactor.NegatedConditionsInUnless, []},
+ {Credo.Check.Refactor.NegatedConditionsWithElse, []},
+ {Credo.Check.Refactor.Nesting, []},
+ {Credo.Check.Refactor.UnlessWithElse, []},
+ {Credo.Check.Refactor.WithClauses, []},
+ {Credo.Check.Warning.BoolOperationOnSameValues, []},
+ {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
+ {Credo.Check.Warning.IExPry, []},
+ {Credo.Check.Warning.IoInspect, []},
+ {Credo.Check.Warning.LazyLogging, []},
+ {Credo.Check.Warning.OperationOnSameValues, []},
+ {Credo.Check.Warning.OperationWithConstantResult, []},
+ {Credo.Check.Warning.RaiseInsideRescue, []},
+ {Credo.Check.Warning.UnusedEnumOperation, []},
+ {Credo.Check.Warning.UnusedFileOperation, []},
+ {Credo.Check.Warning.UnusedKeywordOperation, []},
+ {Credo.Check.Warning.UnusedListOperation, []},
+ {Credo.Check.Warning.UnusedPathOperation, []},
+ {Credo.Check.Warning.UnusedRegexOperation, []},
+ {Credo.Check.Warning.UnusedStringOperation, []},
+ {Credo.Check.Warning.UnusedTupleOperation, []},
+ {Credo.Check.Consistency.MultiAliasImportRequireUse, false},
+ {Credo.Check.Design.DuplicatedCode, false},
+ {Credo.Check.Readability.MultiAlias, false},
+ {Credo.Check.Readability.Specs, false},
+ {Credo.Check.Readability.SinglePipe, false},
+ {Credo.Check.Refactor.ABCSize, false},
+ {Credo.Check.Refactor.AppendSingleItem, false},
+ {Credo.Check.Refactor.DoubleBooleanNegation, false},
+ {Credo.Check.Refactor.ModuleDependencies, false},
+ {Credo.Check.Refactor.PipeChainStart, false},
+ {Credo.Check.Refactor.VariableRebinding, false},
+ {Credo.Check.Warning.MapGetUnsafePass, false},
+ {Credo.Check.Warning.UnsafeToAtom, false}
+ ]
+ }
+ ]
+}
diff --git a/.formatter.exs b/.formatter.exs
new file mode 100644
index 0000000..d2cda26
--- /dev/null
+++ b/.formatter.exs
@@ -0,0 +1,4 @@
+# Used by "mix format"
+[
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7b641ec
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+/_build
+/cover
+/deps
+/doc
+/.fetch
+erl_crash.dump
+*.ez
+*.beam
+/config/*.secret.exs
+/priv/apprentice
+/src/*.o
+/test/*.mgc
diff --git a/.tm_properties b/.tm_properties
new file mode 100644
index 0000000..de356c1
--- /dev/null
+++ b/.tm_properties
@@ -0,0 +1,2 @@
+excludeDirectories = "{$excludeDirectories,deps,_build,doc,src/*.o}"
+excludeFiles = "{$excludeFiles,*.dump}"
diff --git a/.tool-versions b/.tool-versions
new file mode 100644
index 0000000..0c997ac
--- /dev/null
+++ b/.tool-versions
@@ -0,0 +1,2 @@
+erlang 22.3.3
+elixir 1.10.3
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..1c68e47
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,18 @@
+language: elixir
+
+elixir:
+ - 1.10
+ - 1.9
+ - 1.8
+ - 1.7
+
+otp_release:
+ - '22.2'
+
+before_script:
+ - mix compile –warnings-as-errors
+ - mix credo --strict
+ - if [[ "$TRAVIS_ELIXIR_VERSION" =~ "1.10" ]]; then mix format mix.exs "{config,clients,games,lib,test}/**/*.{ex,exs}" --check-formatted; fi
+
+before_install:
+ - sudo apt-get install -y build-essential erlang-dev libmagic-dev
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6c8ad9a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,42 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog][1], and this project adheres to [Semantic Versioning][2].
+
+[1]: https://keepachangelog.com/en/1.0.0/
+[2]: https://semver.org/spec/v2.0.0.html
+
+## [Unreleased]
+
+### Added
+
+- Added support for process recycling (evadne).
+- Added documentation (evadne).
+
+### Changed
+
+- Replaced GenServer with `:gen_statem` (evadne).
+ - Changed API; added support for customisation.
+
+- Refined tests and other aspects of the library (evadne).
+
+## [0.20.83]
+
+### Added
+
+- Soak testing script (devstopfix)
+
+### Changed
+
+- Replaced Erlexec usage with Port (devstopfix)
+
+## 0.0.1
+
+### Added
+
+- Initial Elixir wrapper with Erlexec (evadne)
+- Intiial C program (evadne)
+
+[unreleased]: https://github.com/evadne/gen_magic/compare/develop
+[0.20.83]: https://github.com/devstopfix/gen_magic/commit/7e27fd094cb462d26ba54fde0205a5be313d12da
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..77e47dc
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,40 @@
+# Apprentice binary
+
+CC = gcc
+CFLAGS = -std=c99 -g -Wall -Wextra -Werror
+LDFLAGS = -lm -lmagic
+HEADER_FILES = src
+C_SOURCE_FILES = src/apprentice.c
+OBJECT_FILES = $(C_SOURCE_FILES:.c=.o)
+EXECUTABLE_DIRECTORY = priv
+EXECUTABLE = $(EXECUTABLE_DIRECTORY)/apprentice
+
+# Unit test custom magic file
+
+MAGIC = file
+TEST_DIRECTORY = test
+TARGET_MAGIC = $(TEST_DIRECTORY)/elixir.mgc
+SOURCE_MAGIC = $(TEST_DIRECTORY)/elixir
+
+# Target
+
+all: $(EXECUTABLE) $(TARGET_MAGIC)
+
+# Compile
+
+$(EXECUTABLE): $(OBJECT_FILES) $(EXECUTABLE_DIRECTORY)
+ $(CC) $(OBJECT_FILES) -o $@ $(LDFLAGS)
+
+$(EXECUTABLE_DIRECTORY):
+ mkdir -p $(EXECUTABLE_DIRECTORY)
+
+.o:
+ $(CC) $(CFLAGS) $< -o $@
+
+# Test case
+
+$(TARGET_MAGIC): $(SOURCE_MAGIC)
+ cd $(TEST_DIRECTORY); $(MAGIC) -C -m elixir
+
+clean:
+ rm -f $(EXECUTABLE) $(OBJECT_FILES) $(BEAM_FILES)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d89e486
--- /dev/null
+++ b/README.md
@@ -0,0 +1,130 @@
+# GenMagic
+
+**GenMagic** provides supervised and customisable access to [libmagic](http://man7.org/linux/man-pages/man3/libmagic.3.html) using a supervised external process.
+
+With this library, you can start an one-off process to run a single check, or run the process as a daemon if you expect to run many checks.
+
+## Installation
+
+The package can be installed by adding `gen_magic` to your list of dependencies in `mix.exs`:
+
+```elixir
+def deps do
+ [
+ {:gen_magic, "~> 1.0.0"}
+ ]
+end
+```
+
+You must also have [libmagic](http://man7.org/linux/man-pages/man3/libmagic.3.html) installed locally with headers, alongside common compilation tools (i.e. build-essential). These can be acquired by apt-get, yum, brew, etc.
+
+Compilation of the underlying C program is automatic and handled by [elixir_make](https://github.com/elixir-lang/elixir_make).
+
+## Usage
+
+Depending on the use case, you may utilise a single (one-off) GenMagic process without reusing it as a daemon, or utilise a connection pool (such as Poolboy) in your application to run multiple persistent GenMagic processes.
+
+To use GenMagic directly, you can use `GenMagic.Helpers.perform_once/1`:
+
+```elixir
+iex(1)> GenMagic.Helpers.perform_once "."
+{:ok,
+ %GenMagic.Result{
+ content: "directory",
+ encoding: "binary",
+ mime_type: "inode/directory"
+ }}
+```
+
+To use the GenMagic server as a daemon, you can start it first, keep a reference, then feed messages to it as you require:
+
+```elixir
+{:ok, pid} = GenMagic.Server.start_link([])
+{:ok, result} = GenMagic.Server.perform(pid, path)
+```
+
+See `GenMagic.Server.start_link/1` and `t:GenMagic.Server.option/0` for more information on startup parameters.
+
+See `GenMagic.Result` for details on the result provided.
+
+## Configuration
+
+When using `GenMagic.Server.start_link/1` to start a persistent server, or `GenMagic.Helpers.perform_once/2` to run an ad-hoc request, you can override specific options to suit your use case.
+
+| Name | Default | Description |
+| - | - | - |
+| `:startup_timeout` | 1000 | Number of milliseconds to wait for client startup |
+| `:process_timeout` | 30000 | Number of milliseconds to process each request |
+| `:recycle_threshold` | 10 | Number of cycles before the C process is replaced |
+| `:database_patterns` | `[:default]` | Databases to load |
+
+See `t:GenMagic.Server.option/0` for details.
+
+### Use Cases
+
+### Ad-Hoc Requests
+
+For ad-hoc requests, you can use the helper method `GenMagic.Helpers.perform_once/2`:
+
+```elixir
+iex(1)> GenMagic.Helpers.perform_once(Path.join(File.cwd!(), "Makefile"))
+{:ok,
+ %GenMagic.Result{
+ content: "makefile script, ASCII text",
+ encoding: "us-ascii",
+ mime_type: "text/x-makefile"
+}}
+```
+
+### Supervised Requests
+
+The Server should be run under a pool which provides concurrency *and* resiliency.
+
+Here we run it under a supervisor:
+
+```elixir
+iex(1)> {:ok, pid} = Supervisor.start_link([{GenMagic.Server, name: :gen_magic}], strategy: :one_for_one)
+{:ok, #PID<0.199.0>}
+```
+
+Now we can ask it to inspect a file:
+
+```elixir
+iex(2)> GenMagic.Server.perform(:gen_magic, Path.expand("~/.bash_history"))
+{:ok, [mime_type: "text/plain", encoding: "us-ascii", content: "ASCII text"]}
+```
+
+Note that in this case we have opted to use a named process.
+
+### Check Uploaded Files
+
+If you use Phoenix, you can inspect the file from your controller:
+
+```elixir
+def upload(conn, %{"upload" => %{path: path}}) do,
+ {:ok, result} = GenMagic.Helpers.perform_once(:gen_magic, path)
+ text(conn, "Received your file containing #{result.content}")
+end
+```
+
+Obviously, it will be more ideal if you have wrapped `GenMagic.Server` in a pool such as Poolboy, to avoid constantly starting and stopping the underlying C program.
+
+## Notes
+
+### Soak Test
+
+Run an endless cycle to prove that the program is resilient:
+
+```bash
+find /usr/share/ -name *png | xargs mix run test/soak.exs
+find . -name *ex | xargs mix run test/soak.exs
+```
+
+## Acknowledgements
+
+During design and prototype development of this library, the Author has drawn inspiration from the following individuals, and therefore thanks all contributors for their generosity:
+
+- Mr [James Every](https://github.com/devstopfix)
+ - Enhanced Elixir Wrapper (based on GenServer)
+ - Initial Hex packaging (v.0.22)
+ - Soak Testing
diff --git a/dialyzer-ignore-warnings.exs b/dialyzer-ignore-warnings.exs
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/dialyzer-ignore-warnings.exs
@@ -0,0 +1 @@
+[]
diff --git a/lib/gen_magic.ex b/lib/gen_magic.ex
new file mode 100644
index 0000000..f598521
--- /dev/null
+++ b/lib/gen_magic.ex
@@ -0,0 +1,7 @@
+defmodule GenMagic do
+ @moduledoc """
+ Top-level namespace for GenMagic, the libmagic client for Elixir.
+
+ See `GenMagic.Server` or the README for usage.
+ """
+end
diff --git a/lib/gen_magic/config.ex b/lib/gen_magic/config.ex
new file mode 100644
index 0000000..c971e7c
--- /dev/null
+++ b/lib/gen_magic/config.ex
@@ -0,0 +1,57 @@
+defmodule GenMagic.Config do
+ @moduledoc false
+ @otp_app Mix.Project.config()[:app]
+ @executable_name "apprentice"
+ @startup_timeout 1_000
+ @process_timeout 30_000
+ @recycle_threshold :infinity
+ @database_patterns [:default]
+
+ def get_port_name do
+ {:spawn_executable, to_charlist(get_executable_name())}
+ end
+
+ def get_port_options(options) do
+ arguments = [:use_stdio, :stderr_to_stdout, :binary, :exit_status]
+
+ case get_executable_arguments(options) do
+ [] -> arguments
+ list -> [{:args, list} | arguments]
+ end
+ end
+
+ def get_startup_timeout(options) do
+ get_value(options, :startup_timeout, @startup_timeout)
+ end
+
+ def get_process_timeout(options) do
+ get_value(options, :process_timeout, @process_timeout)
+ end
+
+ def get_recycle_threshold(options) do
+ get_value(options, :recycle_threshold, @recycle_threshold)
+ end
+
+ defp get_executable_name do
+ Path.join(:code.priv_dir(@otp_app), @executable_name)
+ end
+
+ defp get_executable_arguments(options) do
+ Enum.flat_map(List.wrap(get(options, :database_patterns, @database_patterns)), fn
+ :default -> ["--database-default"]
+ pattern -> pattern |> Path.wildcard() |> Enum.flat_map(&["--database-file", &1])
+ end)
+ end
+
+ defp get(options, key, default) do
+ Keyword.get(options, key, default)
+ end
+
+ defp get_value(options, key, default) do
+ case get(options, key, default) do
+ value when is_integer(value) and value > 0 -> value
+ :infinity -> :infinity
+ _ -> raise ArgumentError, message: "Invalid #{key}"
+ end
+ end
+end
diff --git a/lib/gen_magic/helpers.ex b/lib/gen_magic/helpers.ex
new file mode 100644
index 0000000..183fe31
--- /dev/null
+++ b/lib/gen_magic/helpers.ex
@@ -0,0 +1,28 @@
+defmodule GenMagic.Helpers do
+ @moduledoc """
+ Contains convenience functions for one-off use.
+ """
+
+ alias GenMagic.Result
+ alias GenMagic.Server
+ @spec perform_once(Path.t(), [Server.option()]) :: {:ok, Result.t()} | {:error, term()}
+
+ @doc """
+ Runs a one-shot process without supervision.
+
+ Useful in tests, but not recommended for actual applications.
+
+ ## Example
+
+ iex(1)> {:ok, result} = GenMagic.Helpers.perform_once(".")
+ iex(2)> result
+ %GenMagic.Result{content: "directory", encoding: "binary", mime_type: "inode/directory"}
+ """
+ def perform_once(path, options \\ []) do
+ with {:ok, pid} <- Server.start_link(options),
+ {:ok, result} <- Server.perform(pid, path),
+ :ok <- Server.stop(pid) do
+ {:ok, result}
+ end
+ end
+end
diff --git a/lib/gen_magic/result.ex b/lib/gen_magic/result.ex
new file mode 100644
index 0000000..0cfa361
--- /dev/null
+++ b/lib/gen_magic/result.ex
@@ -0,0 +1,30 @@
+defmodule GenMagic.Result do
+ @moduledoc """
+ Represents the results obtained from libmagic.
+
+ Please note that this struct is only returned if the underlying check has succeeded.
+ """
+
+ @typedoc """
+ Represents the result.
+
+ Contains the MIME type, Encoding and Content fields returned by libmagic, as per the flags:
+
+ - MIME Type: `MAGIC_FLAGS_COMMON|MAGIC_MIME_TYPE`
+ - Encoding: `MAGIC_FLAGS_COMMON|MAGIC_MIME_ENCODING`
+ - Type Name (Content): `MAGIC_FLAGS_COMMON|MAGIC_NONE`
+ """
+ @type t :: %__MODULE__{
+ mime_type: String.t(),
+ encoding: String.t(),
+ content: String.t()
+ }
+
+ @enforce_keys ~w(mime_type encoding content)a
+ defstruct mime_type: nil, encoding: nil, content: nil
+
+ @doc false
+ def build(mime_type, encoding, content) do
+ %__MODULE__{mime_type: mime_type, encoding: encoding, content: content}
+ end
+end
diff --git a/lib/gen_magic/server.ex b/lib/gen_magic/server.ex
new file mode 100644
index 0000000..fc76885
--- /dev/null
+++ b/lib/gen_magic/server.ex
@@ -0,0 +1,268 @@
+defmodule GenMagic.Server do
+ @moduledoc """
+ Provides access to the underlying libmagic client, which performs file introspection.
+
+ The Server needs to be supervised, since it will terminate if it receives any unexpected error.
+ """
+
+ @behaviour :gen_statem
+ alias GenMagic.Result
+ alias GenMagic.Server.Data
+ alias GenMagic.Server.Status
+
+ @typedoc """
+ Represents the reference to the underlying server, as returned by `:gen_statem`.
+ """
+ @type t :: :gen_statem.server_ref()
+
+ @typedoc """
+ Represents values accepted as startup options, which can be passed to `start_link/1`.
+
+ - `:name`: If present, this will be the registered name for the underlying process.
+ Note that `:gen_statem` requires `{:local, name}`, but given widespread GenServer convention,
+ atoms are accepted and will be converted to `{:local, name}`.
+
+ - `:startup_timeout`: Specifies how long the Server waits for the C program to initialise.
+ However, if the underlying C program exits, then the process exits immediately.
+
+ Can be set to `:infinity`.
+
+ - `:process_timeout`: Specifies how long the Server waits for each request to complete.
+
+ Can be set to `:infinity`.
+
+ Please note that, if you have chosen a custom timeout value, you should also pass it when
+ using `GenMagic.Server.perform/3`.
+
+ - `:recycle_threshold`: Specifies the number of requests processed before the underlying C
+ program is recycled.
+
+ Can be set to `:infinity` if you do not wish for the program to be recycled.
+
+ - `:database_patterns`: Specifies what magic databases to load; you can specify a list of either
+ Path Patterns (see `Path.wildcard/2`) or `:default` to instruct the C program to load the
+ appropriate databases.
+
+ For example, if you have had to add custom magics, then you can set this value to:
+
+ [:default, "path/to/my/magic"]
+ """
+ @type option ::
+ {:name, atom() | :gen_statem.server_name()}
+ | {:startup_timeout, timeout()}
+ | {:process_timeout, timeout()}
+ | {:recycle_threshold, non_neg_integer() | :infinity}
+ | {:database_patterns, nonempty_list(:default | Path.t())}
+
+ @typedoc """
+ Current state of the Server:
+
+ - `:pending`: This is the initial state; the Server will attempt to start the underlying Port
+ and the libmagic client, then automatically transition to either Available or Crashed.
+
+ - `:available`: This is the default state. In this state the Server is able to accept requests
+ and they will be replied in the same order.
+
+ - `:processing`: This is the state the Server will be in if it is processing requests. In this
+ state, further requests can still be lodged and they will be processed when the Server is
+ available again.
+
+ For proper concurrency, use a process pool like Poolboy, Sbroker, etc.
+
+ - `:recycling`: This is the state the Server will be in, if its underlying C program needs to be
+ recycled. This state is triggered whenever the cycle count reaches the defined value as per
+ `:recycle_threshold`.
+
+ In this state, the Server is able to accept requests, but they will not be processed until the
+ underlying C server program has been started again.
+ """
+ @type state :: :starting | :processing | :available | :recycling
+
+ @spec child_spec([option()]) :: Supervisor.child_spec()
+ @spec start_link([option()]) :: :gen_statem.start_ret()
+ @spec perform(t(), Path.t(), timeout()) :: {:ok, Result.t()} | {:error, term()}
+ @spec status(t(), timeout()) :: {:ok, Status.t()} | {:error, term()}
+ @spec stop(t(), term(), timeout()) :: :ok
+
+ @doc """
+ Returns the default Child Specification for this Server for use in Supervisors.
+
+ You can override this with `Supervisor.child_spec/2` as required.
+ """
+ def child_spec(options) do
+ %{
+ id: __MODULE__,
+ start: {__MODULE__, :start_link, [options]},
+ type: :worker,
+ restart: :permanent,
+ shutdown: 500
+ }
+ end
+
+ @doc """
+ Starts a new Server.
+
+ See `t:option/0` for further details.
+ """
+ def start_link(options) do
+ {name, options} = Keyword.pop(options, :name)
+
+ case name do
+ nil -> :gen_statem.start_link(__MODULE__, options, [])
+ name when is_atom(name) -> :gen_statem.start_link({:local, name}, __MODULE__, options, [])
+ {:global, _} -> :gen_statem.start_link(name, __MODULE__, options, [])
+ {:via, _, _} -> :gen_statem.start_link(name, __MODULE__, options, [])
+ {:local, _} -> :gen_statem.start_link(name, __MODULE__, options, [])
+ end
+ end
+
+ @doc """
+ Determines the type of the file provided.
+ """
+ def perform(server_ref, path, timeout \\ 5000) do
+ case :gen_statem.call(server_ref, {:perform, path}, timeout) do
+ {:ok, %Result{} = result} -> {:ok, result}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc """
+ Returns status of the Server.
+ """
+ def status(server_ref, timeout \\ 5000) do
+ :gen_statem.call(server_ref, :status, timeout)
+ end
+
+ @doc """
+ Stops the Server with reason `:normal` and timeout `:infinity`.
+ """
+ def stop(server_ref) do
+ :gen_statem.stop(server_ref)
+ end
+
+ @doc """
+ Stops the Server with the specified reason and timeout.
+ """
+ def stop(server_ref, reason, timeout) do
+ :gen_statem.stop(server_ref, reason, timeout)
+ end
+
+ @impl :gen_statem
+ def init(options) do
+ import GenMagic.Config
+
+ data = %Data{
+ port_name: get_port_name(),
+ port_options: get_port_options(options),
+ startup_timeout: get_startup_timeout(options),
+ process_timeout: get_process_timeout(options),
+ recycle_threshold: get_recycle_threshold(options)
+ }
+
+ {:ok, :starting, data}
+ end
+
+ @impl :gen_statem
+ def callback_mode do
+ [:state_functions, :state_enter]
+ end
+
+ @doc false
+ def starting(:enter, _, %{request: nil, port: nil} = data) do
+ port = Port.open(data.port_name, data.port_options)
+ {:keep_state, %{data | port: port}, data.startup_timeout}
+ end
+
+ @doc false
+ def starting({:call, from}, :status, data) do
+ handle_status_call(from, :starting, data)
+ end
+
+ @doc false
+ def starting({:call, _from}, {:perform, _path}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ @doc false
+ def starting(:info, {port, {:data, "ok\n"}}, %{port: port} = data) do
+ {:next_state, :available, data}
+ end
+
+ @doc false
+ def available(:enter, _old_state, %{request: nil}) do
+ :keep_state_and_data
+ end
+
+ @doc false
+ def available({:call, from}, {:perform, path}, data) do
+ data = %{data | cycles: data.cycles + 1, request: {path, from, :erlang.now()}}
+ _ = send(data.port, {self(), {:command, "file; " <> path <> "\n"}})
+ {:next_state, :processing, data}
+ end
+
+ @doc false
+ def available({:call, from}, :status, data) do
+ handle_status_call(from, :available, data)
+ end
+
+ @doc false
+ def processing(:enter, _old_state, %{request: {_path, _from, _time}} = data) do
+ {:keep_state_and_data, data.process_timeout}
+ end
+
+ @doc false
+ def processing({:call, _from}, {:perform, _path}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ @doc false
+ def processing({:call, from}, :status, data) do
+ handle_status_call(from, :processing, data)
+ end
+
+ @doc false
+ def processing(:info, {port, {:data, response}}, %{port: port} = data) do
+ {_, from, _} = data.request
+ data = %{data | request: nil}
+ response = {:reply, from, handle_response(response)}
+ next_state = (data.cycles >= data.recycle_threshold && :recycling) || :available
+ {:next_state, next_state, data, response}
+ end
+
+ @doc false
+ def recycling(:enter, _, %{request: nil, port: port} = data) when is_port(port) do
+ _ = send(data.port, {self(), :close})
+ {:keep_state_and_data, data.startup_timeout}
+ end
+
+ @doc false
+ def recycling({:call, _from}, {:perform, _path}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ @doc false
+ def recycling({:call, from}, :status, data) do
+ handle_status_call(from, :recycling, data)
+ end
+
+ @doc false
+ def recycling(:info, {port, :closed}, %{port: port} = data) do
+ {:next_state, :starting, %{data | port: nil, cycles: 0}}
+ end
+
+ defp handle_response("ok; " <> message) do
+ case message |> String.trim() |> String.split("\t") do
+ [mime_type, encoding, content] -> {:ok, Result.build(mime_type, encoding, content)}
+ _ -> {:error, :malformed_response}
+ end
+ end
+
+ defp handle_response("error; " <> message) do
+ {:error, String.trim(message)}
+ end
+
+ defp handle_status_call(from, state, data) do
+ response = {:ok, %__MODULE__.Status{state: state, cycles: data.cycles}}
+ {:keep_state_and_data, {:reply, from, response}}
+ end
+end
diff --git a/lib/gen_magic/server/data.ex b/lib/gen_magic/server/data.ex
new file mode 100644
index 0000000..6836327
--- /dev/null
+++ b/lib/gen_magic/server/data.ex
@@ -0,0 +1,25 @@
+defmodule GenMagic.Server.Data do
+ @moduledoc false
+
+ @type request :: {Path.t(), {pid(), term()}, requested_at :: integer()}
+
+ @type t :: %__MODULE__{
+ port_name: Port.name(),
+ port_options: list(),
+ port: port(),
+ startup_timeout: timeout(),
+ process_timeout: timeout(),
+ recycle_threshold: non_neg_integer() | :infinity,
+ cycles: non_neg_integer(),
+ request: request | nil
+ }
+
+ defstruct port_name: nil,
+ port_options: nil,
+ port: nil,
+ startup_timeout: :infinity,
+ process_timeout: :infinity,
+ recycle_threshold: :infinity,
+ cycles: 0,
+ request: nil
+end
diff --git a/lib/gen_magic/server/status.ex b/lib/gen_magic/server/status.ex
new file mode 100644
index 0000000..7279b57
--- /dev/null
+++ b/lib/gen_magic/server/status.ex
@@ -0,0 +1,20 @@
+defmodule GenMagic.Server.Status do
+ @moduledoc """
+ Represents Status of the underlying Server.
+ """
+
+ @typedoc """
+ Represnets Staus of the Server.
+
+ - `:state`: Represents the current state of the Server
+
+ - `:cycles`: Represents the number of cycles the Server has run; note that this resets if
+ recycling is enabled.
+ """
+ @type t :: %__MODULE__{
+ state: GenMagic.Server.state(),
+ cycles: non_neg_integer()
+ }
+
+ defstruct state: nil, cycles: 0
+end
diff --git a/mix.exs b/mix.exs
new file mode 100644
index 0000000..11ebdaf
--- /dev/null
+++ b/mix.exs
@@ -0,0 +1,66 @@
+defmodule GenMagic.MixProject do
+ use Mix.Project
+
+ if :erlang.system_info(:otp_release) < '21' do
+ raise "GenMagic requires Erlang/OTP 21 or newer"
+ end
+
+ def project do
+ [
+ app: :gen_magic,
+ version: "1.0.0",
+ elixir: "~> 1.7",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ start_permanent: Mix.env() == :prod,
+ compilers: [:elixir_make] ++ Mix.compilers(),
+ package: package(),
+ deps: deps(),
+ dialyzer: dialyzer(),
+ name: "GenMagic",
+ description: "File introspection with libmagic",
+ source_url: "https://github.com/evadne/gen_magic",
+ docs: docs()
+ ]
+ end
+
+ def application do
+ [extra_applications: [:logger]]
+ end
+
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ defp dialyzer do
+ [
+ plt_add_apps: [:mix, :iex, :ex_unit],
+ flags: ~w(error_handling no_opaque race_conditions underspecs unmatched_returns)a,
+ ignore_warnings: "dialyzer-ignore-warnings.exs",
+ list_unused_filters: true
+ ]
+ end
+
+ defp deps do
+ [
+ {:credo, "~> 1.4.0", only: [:dev, :test], runtime: false},
+ {:dialyxir, "~> 1.0.0-rc.6", only: :dev, runtime: false},
+ {:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
+ {:elixir_make, "~> 0.4", runtime: false}
+ ]
+ end
+
+ defp package do
+ [
+ files: ~w(lib/gen_magic/* src/*.c Makefile),
+ licenses: ["Apache 2.0"],
+ links: %{"GitHub" => "https://github.com/evadne/packmatic"},
+ source_url: "https://github.com/evadne/packmatic"
+ ]
+ end
+
+ defp docs do
+ [
+ main: "readme",
+ extras: ["README.md", "CHANGELOG.md"]
+ ]
+ end
+end
diff --git a/mix.lock b/mix.lock
new file mode 100644
index 0000000..3ebe0c6
--- /dev/null
+++ b/mix.lock
@@ -0,0 +1,15 @@
+%{
+ "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
+ "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"},
+ "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"},
+ "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"},
+ "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
+ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
+ "erlexec": {:hex, :erlexec, "1.10.0", "cba7924cf526097d2082ceb0ec34e7db6bca2624b8f3867fb3fa89c4cf25d227", [:rebar3], [], "hexpm"},
+ "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
+ "exexec": {:hex, :exexec, "0.2.0", "a6ffc48cba3ac9420891b847e4dc7120692fb8c08c9e82220ebddc0bb8d96103", [:mix], [{:erlexec, "~> 1.10", [hex: :erlexec, repo: "hexpm", optional: false]}], "hexpm"},
+ "jason": {:hex, :jason, "1.2.0", "10043418c42d2493d0ee212d3fddd25d7ffe484380afad769a0a38795938e448", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "116747dbe057794c3a3e4e143b7c8390b29f634e16c78a7f59ba75bfa6852e7f"},
+ "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"},
+ "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"},
+ "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"},
+}
diff --git a/src/apprentice.c b/src/apprentice.c
new file mode 100644
index 0000000..8105b14
--- /dev/null
+++ b/src/apprentice.c
@@ -0,0 +1,284 @@
+//
+// The Sorcerer’s Apprentice
+//
+// To use this program, compile it with dynamically linked libmagic, as mirrored
+// at https://github.com/threatstack/libmagic. You may install it with apt-get, yum or brew.
+// Refer to the Makefile for further reference.
+//
+// This program is designed to run interactively as a backend daemon to the GenMagic library,
+// and follows the command line pattern:
+//
+// $ apprentice --database-file <file> --database-default
+//
+// Where each argument either refers to a compiled or uncompiled magic database, or the default
+// database. They will be loaded in the sequence that they were specified. Note that you must
+// specify at least one database.
+//
+// Once the program starts, it will print info statements if run from a terminal, then it will
+// print `ok`. From this point onwards, additional commands can be passed:
+//
+// file; <path>
+//
+// Results will be printed tab-separated, e.g.:
+//
+// ok; application/zip binary Zip archive data, at least v1.0 to extract
+
+#include <errno.h>
+#include <getopt.h>
+#include <libgen.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <unistd.h>
+#include <magic.h>
+
+#define USAGE "[--database-file <path/to/magic.mgc> | --database-default, ...]"
+#define DELIMITER "\t"
+
+#define ERROR_OK 0
+#define ERROR_NO_DATABASE 1
+#define ERROR_NO_ARGUMENT 2
+#define ERROR_MISSING_DATABASE 3
+
+#define ANSI_INFO "\x1b[37m" // gray
+#define ANSI_OK "\x1b[32m" // green
+#define ANSI_ERROR "\x1b[31m" // red
+#define ANSI_IGNORE "\x1b[90m" // red
+#define ANSI_RESET "\x1b[0m"
+
+#define MAGIC_FLAGS_COMMON (MAGIC_CHECK|MAGIC_ERROR)
+magic_t magic_setup(int flags);
+
+void setup_environment();
+void setup_options(int argc, char **argv);
+void setup_options_file(char *optarg);
+void setup_options_default();
+void setup_system();
+void process_line(char *line);
+void process_file(char *path);
+void print_info(const char *format, ...);
+void print_ok(const char *format, ...);
+void print_error(const char *format, ...);
+
+struct magic_file {
+ struct magic_file *prev;
+ struct magic_file *next;
+ char *path;
+};
+
+static struct magic_file* magic_database;
+static magic_t magic_mime_type; // MAGIC_MIME_TYPE
+static magic_t magic_mime_encoding; // MAGIC_MIME_ENCODING
+static magic_t magic_type_name; // MAGIC_NONE
+
+int main (int argc, char **argv) {
+ setup_environment();
+ setup_options(argc, argv);
+ setup_system();
+ printf("ok\n");
+ fflush(stdout);
+
+ char line[4096];
+ while (fgets(line, 4096, stdin)) {
+ process_line(line);
+ }
+
+ return 0;
+}
+
+void setup_environment() {
+ opterr = 0;
+}
+
+void setup_options(int argc, char **argv) {
+ const char *option_string = "f:";
+ static struct option long_options[] = {
+ {"database-file", required_argument, 0, 'f'},
+ {"database-default", no_argument, 0, 'd'},
+ {0, 0, 0, 0}
+ };
+
+ int option_character;
+ while (1) {
+ int option_index = 0;
+ option_character = getopt_long(argc, argv, option_string, long_options, &option_index);
+ if (-1 == option_character) {
+ break;
+ }
+ switch (option_character) {
+ case 'f': {
+ setup_options_file(optarg);
+ break;
+ }
+ case 'd': {
+ setup_options_default();
+ break;
+ }
+ case '?':
+ default: {
+ print_info("%s %s\n", basename(argv[0]), USAGE);
+ exit(ERROR_NO_ARGUMENT);
+ break;
+ }
+ }
+ }
+}
+
+void setup_options_file(char *optarg) {
+ print_info("Requested database %s", optarg);
+ if (0 != access(optarg, R_OK)) {
+ print_error("Missing Database");
+ exit(ERROR_MISSING_DATABASE);
+ }
+
+ struct magic_file *next = malloc(sizeof(struct magic_file));
+ size_t path_length = strlen(optarg) + 1;
+ char *path = malloc(path_length);
+ memcpy(path, optarg, path_length);
+ next->path = path;
+ next->prev = magic_database;
+ if (magic_database) {
+ magic_database->next = next;
+ }
+ magic_database = next;
+}
+
+void setup_options_default() {
+ print_info("requested default database");
+
+ struct magic_file *next = malloc(sizeof(struct magic_file));
+ next->path = NULL;
+ next->prev = magic_database;
+ if (magic_database) {
+ magic_database->next = next;
+ }
+ magic_database = next;
+}
+
+void setup_system() {
+ magic_mime_encoding = magic_setup(MAGIC_FLAGS_COMMON|MAGIC_MIME_ENCODING);
+ magic_mime_type = magic_setup(MAGIC_FLAGS_COMMON|MAGIC_MIME_TYPE);
+ magic_type_name = magic_setup(MAGIC_FLAGS_COMMON|MAGIC_NONE);
+}
+
+magic_t magic_setup(int flags) {
+ print_info("starting libmagic instance for flags %i", flags);
+
+ magic_t magic = magic_open(flags);
+ struct magic_file *current_database = magic_database;
+ if (!current_database) {
+ print_error("no database configured");
+ exit(ERROR_NO_DATABASE);
+ }
+
+ while (current_database->prev) {
+ current_database = current_database->prev;
+ }
+ while (current_database) {
+ if (isatty(STDERR_FILENO)) {
+ fprintf(stderr, ANSI_IGNORE);
+ }
+ if (!current_database->path) {
+ print_info("loading default database");
+ } else {
+ print_info("loading database %s", current_database->path);
+ }
+ magic_load(magic, current_database->path);
+ if (isatty(STDERR_FILENO)) {
+ fprintf(stderr, ANSI_RESET);
+ }
+ current_database = current_database->next;
+ }
+ return magic;
+}
+
+void process_line(char *line) {
+ char path[4096];
+
+ if (0 == strcmp(line, "exit\n")) {
+ exit(ERROR_OK);
+ }
+ if (1 != sscanf(line, "file; %[^\n]s", path)) {
+ print_error("invalid commmand");
+ return;
+ }
+
+ if (0 != access(path, R_OK)) {
+ print_error("unable to access file");
+ return;
+ }
+
+ process_file(path);
+}
+
+void process_file(char *path) {
+ const char *mime_type_result = magic_file(magic_mime_type, path);
+ const char *mime_type_error = magic_error(magic_mime_type);
+ const char *mine_encoding_result = magic_file(magic_mime_encoding, path);
+ const char *mine_encoding_error = magic_error(magic_mime_encoding);
+ const char *type_name_result = magic_file(magic_type_name, path);
+ const char *type_name_error = magic_error(magic_type_name);
+
+ if (mime_type_error) {
+ print_error(mime_type_error);
+ return;
+ }
+
+ if (mine_encoding_error) {
+ print_error(mine_encoding_error);
+ return;
+ }
+
+ if (type_name_error) {
+ print_error(type_name_error);
+ return;
+ }
+
+ print_ok("%s%s%s%s%s", mime_type_result, DELIMITER, mine_encoding_result, DELIMITER, type_name_result);
+}
+
+void print_info(const char *format, ...) {
+ if (!isatty(STDOUT_FILENO)) {
+ return;
+ }
+
+ printf(ANSI_INFO "info; " ANSI_RESET);
+ va_list arguments;
+ va_start(arguments, format);
+ vprintf(format, arguments);
+ va_end(arguments);
+ printf("\n");
+}
+
+void print_ok(const char *format, ...) {
+ if (isatty(STDOUT_FILENO)) {
+ printf(ANSI_OK "ok; " ANSI_RESET);
+ } else {
+ printf("ok; ");
+ }
+
+ va_list arguments;
+ va_start(arguments, format);
+ vprintf(format, arguments);
+ va_end(arguments);
+ printf("\n");
+ fflush(stdout);
+}
+
+void print_error(const char *format, ...) {
+ if (isatty(STDERR_FILENO)) {
+ fprintf(stderr, ANSI_ERROR "error; " ANSI_RESET);
+ } else {
+ fprintf(stderr, "error; ");
+ }
+
+ va_list arguments;
+ va_start(arguments, format);
+ vfprintf(stderr, format, arguments);
+ va_end(arguments);
+ fprintf(stderr, "\n");
+ fflush(stderr);
+}
diff --git a/test/elixir b/test/elixir
new file mode 100644
index 0000000..8e62729
--- /dev/null
+++ b/test/elixir
@@ -0,0 +1,6 @@
+#------------------------------------------------------------------------------
+# file: file(1) magic for Elixir
+# URL: https://elixir-lang.org/
+
+0 string/w defmodule Elixir module source text
+!:mime text/x-elixir
diff --git a/test/gen_magic/gen_magic_test.exs b/test/gen_magic/gen_magic_test.exs
new file mode 100644
index 0000000..dbd287c
--- /dev/null
+++ b/test/gen_magic/gen_magic_test.exs
@@ -0,0 +1,53 @@
+defmodule GenMagicTest do
+ use GenMagic.MagicCase
+ alias GenMagic.Result
+
+ doctest GenMagic
+ @iterations 100
+
+ test "Makefile is text file" do
+ {:ok, pid} = GenMagic.Server.start_link([])
+ path = absolute_path("Makefile")
+ assert {:ok, %{mime_type: "text/x-makefile"}} = GenMagic.Server.perform(pid, path)
+ end
+
+ @tag external: true
+ test "Load test local files" do
+ {:ok, pid} = GenMagic.Server.start_link([])
+
+ files_stream()
+ |> Stream.cycle()
+ |> Stream.take(@iterations)
+ |> Stream.map(&assert {:ok, %Result{}} = GenMagic.Server.perform(pid, &1))
+ |> Enum.all?()
+ |> assert
+ end
+
+ test "Non-existent file" do
+ Process.flag(:trap_exit, true)
+ {:ok, pid} = GenMagic.Server.start_link([])
+ path = missing_filename()
+ assert_no_file(GenMagic.Server.perform(pid, path))
+ end
+
+ test "Named process" do
+ {:ok, pid} = GenMagic.Server.start_link(name: :gen_magic)
+ path = absolute_path("Makefile")
+ assert {:ok, %{cycles: 0}} = GenMagic.Server.status(:gen_magic)
+ assert {:ok, %{cycles: 0}} = GenMagic.Server.status(pid)
+ assert {:ok, %Result{} = result} = GenMagic.Server.perform(:gen_magic, path)
+ assert {:ok, %{cycles: 1}} = GenMagic.Server.status(:gen_magic)
+ assert {:ok, %{cycles: 1}} = GenMagic.Server.status(pid)
+ assert "text/x-makefile" = result.mime_type
+ end
+
+ test "Custom database file recognises Elixir files" do
+ database = absolute_path("test/elixir.mgc")
+ {:ok, pid} = GenMagic.Server.start_link(database_patterns: [database])
+ path = absolute_path("mix.exs")
+ assert {:ok, %Result{} = result} = GenMagic.Server.perform(pid, path)
+ assert "text/x-elixir" = result.mime_type
+ assert "us-ascii" = result.encoding
+ assert "Elixir module source text" = result.content
+ end
+end
diff --git a/test/gen_magic/helpers_test.exs b/test/gen_magic/helpers_test.exs
new file mode 100644
index 0000000..d2690ef
--- /dev/null
+++ b/test/gen_magic/helpers_test.exs
@@ -0,0 +1,9 @@
+defmodule GenMagic.HelpersTest do
+ use GenMagic.MagicCase
+ doctest GenMagic.Helpers
+
+ test "perform_once" do
+ path = absolute_path("Makefile")
+ assert {:ok, %{mime_type: "text/x-makefile"}} = GenMagic.Helpers.perform_once(path)
+ end
+end
diff --git a/test/gen_magic/server_test.exs b/test/gen_magic/server_test.exs
new file mode 100644
index 0000000..e152145
--- /dev/null
+++ b/test/gen_magic/server_test.exs
@@ -0,0 +1,30 @@
+defmodule GenMagic.ServerTest do
+ use GenMagic.MagicCase
+ doctest GenMagic.Server
+
+ describe "recycle_threshold" do
+ test "resets" do
+ {:ok, pid} = GenMagic.Server.start_link(recycle_threshold: 3)
+ path = absolute_path("Makefile")
+ assert {:ok, %{cycles: 0}} = GenMagic.Server.status(pid)
+ assert {:ok, _} = GenMagic.Server.perform(pid, path)
+ assert {:ok, %{cycles: 1}} = GenMagic.Server.status(pid)
+ assert {:ok, _} = GenMagic.Server.perform(pid, path)
+ assert {:ok, %{cycles: 2}} = GenMagic.Server.status(pid)
+ assert {:ok, _} = GenMagic.Server.perform(pid, path)
+ assert {:ok, %{cycles: 0}} = GenMagic.Server.status(pid)
+ end
+
+ test "resets before reply" do
+ {:ok, pid} = GenMagic.Server.start_link(recycle_threshold: 1)
+ path = absolute_path("Makefile")
+ assert {:ok, %{cycles: 0}} = GenMagic.Server.status(pid)
+ assert {:ok, _} = GenMagic.Server.perform(pid, path)
+ assert {:ok, %{cycles: 0}} = GenMagic.Server.status(pid)
+ assert {:ok, _} = GenMagic.Server.perform(pid, path)
+ assert {:ok, %{cycles: 0}} = GenMagic.Server.status(pid)
+ assert {:ok, _} = GenMagic.Server.perform(pid, path)
+ assert {:ok, %{cycles: 0}} = GenMagic.Server.status(pid)
+ end
+ end
+end
diff --git a/test/soak.exs b/test/soak.exs
new file mode 100644
index 0000000..87a40a2
--- /dev/null
+++ b/test/soak.exs
@@ -0,0 +1,33 @@
+defmodule Soak do
+ @moduledoc """
+ Run with a list of files to inspect:
+
+ find /usr/share/ -name *png | xargs mix run test/soak.exs
+ """
+
+ def perform_infinite([]), do: false
+
+ def perform_infinite(paths) do
+ {:ok, pid} = GenMagic.Server.start_link(database_patterns: ["/usr/local/share/misc/*.mgc"])
+
+ perform_infinite(paths, [], pid, 0)
+ end
+
+ defp perform_infinite([], done, pid, count) do
+ perform_infinite(done, [], pid, count)
+ end
+
+ defp perform_infinite([path | paths], done, pid, count) do
+ if rem(count, 1000) == 0, do: IO.puts(Integer.to_string(count))
+ {:ok, %GenMagic.Result{}} = GenMagic.Server.perform(pid, path)
+ perform_infinite(paths, [path | done], pid, count + 1)
+ end
+end
+
+# Run with a list of files to inspect
+#
+# find /usr/share/ -name *png | xargs mix run test/soak.exs
+
+System.argv()
+|> Enum.filter(&File.exists?/1)
+|> Soak.perform_infinite()
diff --git a/test/support/magic_case.ex b/test/support/magic_case.ex
new file mode 100644
index 0000000..4915861
--- /dev/null
+++ b/test/support/magic_case.ex
@@ -0,0 +1,33 @@
+defmodule GenMagic.MagicCase do
+ @moduledoc false
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ import unquote(__MODULE__)
+ end
+ end
+
+ def missing_filename do
+ :crypto.strong_rand_bytes(8) |> Base.url_encode64()
+ end
+
+ def files_stream do
+ Path.join(File.cwd!(), "deps/**/*")
+ |> Path.wildcard()
+ |> Stream.reject(&File.dir?/1)
+ |> Stream.chunk_every(10)
+ |> Stream.flat_map(&Enum.shuffle/1)
+ end
+
+ def assert_no_file({:error, message}) do
+ assert "unable to access file" = message
+ end
+
+ def absolute_path(path) do
+ __ENV__.file
+ |> Path.join("../../..")
+ |> Path.join(path)
+ |> Path.expand()
+ end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
new file mode 100644
index 0000000..0b54ebe
--- /dev/null
+++ b/test/test_helper.exs
@@ -0,0 +1,7 @@
+ExUnit.start()
+
+if System.get_env("TEAMCITY_VERSION") do
+ ExUnit.configure(formatters: [TeamCityFormatter])
+end
+
+ExUnit.configure(exclude: [external: true])