diff options
author | Evadne Wu <ev@radi.ws> | 2020-05-03 22:58:04 +0100 |
---|---|---|
committer | Evadne Wu <ev@radi.ws> | 2020-05-04 05:04:38 +0100 |
commit | 0bc39d9ad11604adafe682dff366b6b8eeb4c541 (patch) | |
tree | 0a30e7a33393fe754323dc5f46585789a0bbc0b6 |
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.exs | 86 | ||||
-rw-r--r-- | .formatter.exs | 4 | ||||
-rw-r--r-- | .gitignore | 12 | ||||
-rw-r--r-- | .tm_properties | 2 | ||||
-rw-r--r-- | .tool-versions | 2 | ||||
-rw-r--r-- | .travis.yml | 18 | ||||
-rw-r--r-- | CHANGELOG.md | 42 | ||||
-rw-r--r-- | Makefile | 40 | ||||
-rw-r--r-- | README.md | 130 | ||||
-rw-r--r-- | dialyzer-ignore-warnings.exs | 1 | ||||
-rw-r--r-- | lib/gen_magic.ex | 7 | ||||
-rw-r--r-- | lib/gen_magic/config.ex | 57 | ||||
-rw-r--r-- | lib/gen_magic/helpers.ex | 28 | ||||
-rw-r--r-- | lib/gen_magic/result.ex | 30 | ||||
-rw-r--r-- | lib/gen_magic/server.ex | 268 | ||||
-rw-r--r-- | lib/gen_magic/server/data.ex | 25 | ||||
-rw-r--r-- | lib/gen_magic/server/status.ex | 20 | ||||
-rw-r--r-- | mix.exs | 66 | ||||
-rw-r--r-- | mix.lock | 15 | ||||
-rw-r--r-- | src/apprentice.c | 284 | ||||
-rw-r--r-- | test/elixir | 6 | ||||
-rw-r--r-- | test/gen_magic/gen_magic_test.exs | 53 | ||||
-rw-r--r-- | test/gen_magic/helpers_test.exs | 9 | ||||
-rw-r--r-- | test/gen_magic/server_test.exs | 30 | ||||
-rw-r--r-- | test/soak.exs | 33 | ||||
-rw-r--r-- | test/support/magic_case.ex | 33 | ||||
-rw-r--r-- | test/test_helper.exs | 7 |
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 @@ -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]) |