Page MenuHomePhorge

No OneTemporary

Size
81 KB
Referenced Files
None
Subscribers
None
diff --git a/.gitignore b/.gitignore
index 6356d56..81037b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,12 @@
/_build
/cover
/deps
/doc
/.fetch
erl_crash.dump
*.ez
*.beam
/config/*.secret.exs
-/priv/libmagic_port
-/test/*.mgc
+/priv/
+*.mgc
core.*
diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml
new file mode 100644
index 0000000..1855ad7
--- /dev/null
+++ b/.woodpecker/lint.yaml
@@ -0,0 +1,60 @@
+when:
+ - event: pull_request
+ path: [ "**/*.ex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
+ - event: push
+ branch: ${CI_REPO_DEFAULT_BRANCH}
+ path: [ "**/*.ex", "**/*.exs", "mix.lock", ".woodpecker/**" ]
+
+labels:
+ platform: linux/amd64
+
+variables:
+ script_file_entrypoint: &script_file_entrypoint
+ - /bin/sh
+ - -c
+ - 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
+
+steps:
+ mix-format:
+ image: elixir:1.17-alpine
+ entrypoint: *script_file_entrypoint
+ failure: ignore
+ commands:
+ - apk add --no-cache build-base file-dev git
+ - mix local.hex --force
+ - mix local.rebar --force
+ - mix deps.get --only dev
+ - |
+ if ! mix format --check-formatted; then
+ touch fail.stamp
+ exit 1
+ fi
+
+ credo:
+ image: elixir:1.17-alpine
+ entrypoint: *script_file_entrypoint
+ failure: ignore
+ environment:
+ MIX_ENV: test
+ commands:
+ - apk add --no-cache build-base file-dev git
+ - mix local.hex --force
+ - mix local.rebar --force
+ - mix deps.get
+ - |
+ if ! mix credo; then
+ touch fail.stamp
+ exit 1
+ fi
+
+ ensure-status:
+ image: elixir:1.17-alpine
+ entrypoint: *script_file_entrypoint
+ commands: |
+ if test -f fail.stamp; then
+ echo "One or more previous steps failed. Failing workflow..."
+ exit 1
+ else
+ echo "All steps passed"
+ exit 0
+ fi
diff --git a/.woodpecker/unit-testing.yaml b/.woodpecker/unit-testing.yaml
new file mode 100644
index 0000000..8d2628f
--- /dev/null
+++ b/.woodpecker/unit-testing.yaml
@@ -0,0 +1,32 @@
+when:
+ - event: pull_request
+ path: [ "**/*.ex", "**/*.exs", "mix.lock", "src/**", "Makefile", ".woodpecker/**" ]
+ - event: push
+ branch: ${CI_REPO_DEFAULT_BRANCH}
+ path: [ "**/*.ex", "**/*.exs", "mix.lock", "src/**", "Makefile", ".woodpecker/**" ]
+
+depends_on:
+ - lint
+
+labels:
+ platform: linux/amd64
+
+variables:
+ script_file_entrypoint: &script_file_entrypoint
+ - /bin/sh
+ - -c
+ - 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
+
+steps:
+ unit-testing:
+ image: elixir:1.17-alpine
+ entrypoint: *script_file_entrypoint
+ environment:
+ MIX_ENV: test
+ commands:
+ - apk add --no-cache build-base file file-dev git
+ - rm -f priv/libmagic_port _build/test/lib/majic/priv/libmagic_port
+ - mix local.hex --force
+ - mix local.rebar --force
+ - mix deps.get
+ - mix test --trace --preload-modules
diff --git a/CHANGELOG.md b/CHANGELOG.md
index da4209c..dc57c00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,64 +1,82 @@
# 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
## majic [Unreleased]
## Added
- Forked gen_magic.
- Pool: `Majic.Pool`, using [nimble_pool](https://hex.pm/packages/nimble_pool).
- Plug: `Majic.Plug`.
- Unified API: `Majic.perform/1,2,3`.
- Builds an up-to-date and patched magic database.
-- `Majic.compile/2`
- `Majic.Server.reload/2,3`
- `Majic.Server.recycle/2,3`
+- Woodpecker CI pipeline
## Changed
- Improved C port, now using erl_interface
-- Builds on Musl
+- Builds on Musl and macOS (Homebrew)
- Better error and timeout handling
- Bytes support: `Majic.Server.perform(ref, {:bytes, <<>>})`
- Renamed `priv/apprentice` to `priv/libmagic_port` to be more obvious in `ps`
- Renamed `Majic.Helpers.perform_once` to `Majic.Once.perform`
+## Fixed
+
+- Use pkg-config to discover libmagic paths (fixes macOS Homebrew build)
+- Remove `ei_init()` call that crashes on macOS with unlimited fd limit
+- Redirect C port stderr to /dev/null unless MAJIC_DEBUG is set
+- `Once.perform/3` now cleans up server process on error
+- `Pool.terminate_worker/3` now stops server processes
+- `Extension.fix/3` preserves directory paths in output
+- `Extension.fix/3` handles uppercase extensions correctly
+- `Extension.fix/3` no longer produces trailing dot with no candidates
+- `Majic.Plug` rewritten with recursive transform for nested uploads
+- `Majic.Plug` no longer raises on majic errors, falls back to original upload
+- `Majic.perform/2` with `once: true` now passes `:timeout` option
+- `Majic.Server` postpones reload/recycle calls in non-available states
+- `Majic.Server` handles late port data in recycling state
+- Fix `:eaccess` typo to `:eacces` in errno map
+- Guard `terminate/3` against nil port
+
## gen_majic [1.0]
### 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
index edfdf18..87d4c70 100644
--- a/Makefile
+++ b/Makefile
@@ -1,25 +1,25 @@
CFLAGS += -std=c99 -g -Wall
-CPPFLAGS += -I$(ERL_EI_INCLUDE_DIR) -I/usr/local/include
-LDFLAGS += -L$(ERL_EI_LIBDIR) -L/usr/local/lib
+CPPFLAGS += -I$(ERL_EI_INCLUDE_DIR) $(shell pkg-config --cflags libmagic 2>/dev/null || echo -I/usr/local/include)
+LDFLAGS += -L$(ERL_EI_LIBDIR) $(shell pkg-config --libs-only-L libmagic 2>/dev/null || echo -L/usr/local/lib)
LDLIBS = -lpthread
PRIV = priv/
RM = rm -Rf
ifeq ($(EI_INCOMPLETE),YES)
LDLIBS += -lerl_interface
CFLAGS += -DEI_INCOMPLETE
endif
LDLIBS += -lei -lm -lmagic
all: priv/libmagic_port
priv/libmagic_port: src/libmagic_port.c
mkdir -p priv
$(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) $< $(LDLIBS) -o $@
clean:
$(RM) $(PRIV)
.PHONY: clean
diff --git a/README.md b/README.md
index 7eced3d..30274f6 100644
--- a/README.md
+++ b/README.md
@@ -1,192 +1,195 @@
# Majic
**Majic** provides a robust integration of [libmagic](http://man7.org/linux/man-pages/man3/libmagic.3.html) for Elixir.
-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
+With this library, you can start a one-off process to run a single check, or run the process as a daemon if you expect to run
many checks.
It is a friendly fork of [gen_magic](https://github.com/evadne/gen_magic) featuring a (arguably) more robust C-code
using erl_interface, built in pooling, unified/clean API, and an optional Plug.
-This package is regulary tested on multiple platforms (Debian, macOS, Fedora, Alpine, FreeBSD) to ensure it'll work fine
+This package is regularly tested on multiple platforms (Debian, macOS, Fedora, Alpine, FreeBSD) to ensure it'll work fine
in any environment.
## Installation
The package can be installed by adding `majic` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:majic, "~> 1.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) Majic process without reusing it as a daemon, or utilise a connection pool (such as Poolboy) in your application to run multiple persistent Majic processes.
-To use Majic directly, you can use `Majic.Once.perform/1`:
+To use Majic directly, you can pass `once: true` to `Majic.perform/2`:
```elixir
iex(1)> Majic.perform(".", once: true)
{:ok,
%Majic.Result{
content: "directory",
encoding: "binary",
mime_type: "inode/directory"
}}
```
To use the Majic server as a daemon, you can start it first, keep a reference, then feed messages to it as you require:
```elixir
{:ok, pid} = Majic.Server.start_link([])
{:ok, result} = Majic.perform(path, server: pid)
```
-See `Majic.Server.start_link/1` and `t:Majic.Server.option/0` for more information on startup parameters.
+See `Majic.Server.start_link/1` and `t:Majic.Server.start_option/0` for more information on startup parameters.
See `Majic.Result` for details on the result provided.
## Configuration
-When using `Majic.Server.start_link/1` to start a persistent server, or `Majic.Helpers.perform_once/2` to run an ad-hoc request, you can override specific options to suit your use case.
+When using `Majic.Server.start_link/1` to start a persistent server, or `Majic.perform/2` with `once: true` 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:Majic.Server.option/0` for details.
+See `t:Majic.Server.start_option/0` for details.
-__Note__ `:recycle_thresold` is only useful if you are using a libmagic `<5.29`, where it was susceptible to memleaks
-([details](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=840754)]). In future versions of `majic` this option could
+__Note__ `:recycle_threshold` is only useful if you are using a libmagic `<5.29`, where it was susceptible to memleaks
+([details](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=840754)). In future versions of `majic` this option could
be ignored.
### Reloading / Altering databases
If you want `majic` to reload its database(s), run `Majic.Server.reload(ref)`.
If you want to add or remove databases to a running server, you would have to run `Majic.Server.reload(ref, databases)`
where databases being the same argument as `database_patterns` on start. `Majic` does not support adding/removing
databases at runtime without a port reload.
### Use Cases
#### Ad-Hoc Requests
-For ad-hoc requests, you can use the helper method `Majic.Once.perform_once/2`:
+For ad-hoc requests, you can pass `once: true` to `Majic.perform/2`:
```elixir
iex(1)> Majic.perform(Path.join(File.cwd!(), "Makefile"), once: true)
{:ok,
%Majic.Result{
content: "makefile script, ASCII text",
encoding: "us-ascii",
mime_type: "text/x-makefile"
}}
```
#### Supervised Requests
The Server should be run under a supervisor which provides resiliency.
Here we run it under a supervisor in an application:
```elixir
children =
[
# ...
{Majic.Server, [name: YourApp.Majic]}
]
opts = [strategy: :one_for_one, name: YourApp.Supervisor]
Supervisor.start_link(children, opts)
```
Now we can ask it to inspect a file:
```elixir
iex(2)> Majic.perform(Path.expand("~/.bash_history"), server: YourApp.Majic)
{:ok, %Majic.Result{mime_type: "text/plain", encoding: "us-ascii", content: "ASCII text"}}
```
Note that in this case we have opted to use a named process.
#### Pool
For concurrency *and* resiliency, you may start the `Majic.Pool`. By default, it will start a `Majic.Server`
worker per online scheduler:
You can add a pool in your application supervisor by adding it as a child:
```elixir
children =
[
# ...
{Majic.Pool, [name: YourApp.MajicPool, pool_size: 2]}
]
opts = [strategy: :one_for_one, name: YourApp.Supervisor]
Supervisor.start_link(children, opts)
```
And then you can use it with `Majic.perform/2` with `pool: YourApp.MajicPool` option:
```elixir
iex(1)> Majic.perform(Path.expand("~/.bash_history"), pool: YourApp.MajicPool)
{:ok, %Majic.Result{mime_type: "text/plain", encoding: "us-ascii", content: "ASCII text"}}
```
#### Fixing extensions
You may also want to fix the user-provided filename according to its detected MIME type. To do this, you can use `Majic.Extension.fix/3`:
```elixir
iex(1)> {:ok, result} = Majic.perform("cat.jpeg", once: true)
{:ok, %Majic.Result{mime_type: "image/webp", ...}}
iex(1)> Majic.Extension.fix("cat.jpeg", result)
"cat.webp"
```
#### Use with Plug.Upload
If you use Plug or Phoenix, you may want to automatically verify the content type of every `Plug.Upload`. The
`Majic.Plug` is there for this.
Enable it by using `plug Majic.Plug, pool: YourApp.MajicPool` in your pipeline or controller. Then, every `Plug.Upload`
-in `conn.params` and `conn.body_params` is now verified. The filename is also altered with an extension matching its
-content-type, using `Majic.Extension`.
+in `conn.params` and `conn.body_params` is now verified. To also fix the filename extension, pass `fix_extension: true`:
+
+```elixir
+plug Majic.Plug, pool: YourApp.MajicPool, fix_extension: true
+```
## 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:
- [Evadne Wu](https://github.com/evadne)
- Original [gen_magic](https://github.com/evadne/gen_magic) author.
- [James Every](https://github.com/devstopfix)
- Enhanced Elixir Wrapper (based on GenServer)
- Initial Hex packaging (v.0.22)
- Soak Testing
- Matthias and Ced for helping the author with C oddities
- [Hecate](https://github.com/Kleidukos) for laughing at aforementionned oddities
- majic for giving inspiration for the lib name (magic, majic, get it? hahaha..)
diff --git a/lib/majic.ex b/lib/majic.ex
index 6f8d530..b4cecbc 100644
--- a/lib/majic.ex
+++ b/lib/majic.ex
@@ -1,51 +1,52 @@
defmodule Majic do
alias Majic.{Once, Pool, Result, Server}
@moduledoc """
Robust libmagic integration for Elixir.
"""
@doc """
Perform on `path`.
An option of `server: ServerName`, `pool: PoolName` or `once: true` must be passed.
"""
@type target :: Path.t() | {:bytes, binary()}
@type result :: {:ok, Result.t()} | {:error, term() | String.t()}
@type name :: {:pool, atom()} | {:server, Server.t()} | {:once, true}
@type option :: name | Server.start_option() | Pool.option()
@spec perform(target(), [option()]) :: result()
def perform(path, opts) do
mod =
cond do
Keyword.has_key?(opts, :pool) -> {Pool, Keyword.get(opts, :pool)}
Keyword.has_key?(opts, :server) -> {Server, Keyword.get(opts, :server)}
Keyword.has_key?(opts, :once) -> {Once, nil}
true -> nil
end
opts =
opts
|> Keyword.drop([:pool, :server, :once])
if mod do
do_perform(mod, path, opts)
else
{:error, :no_method}
end
end
defp do_perform({Server = mod, name}, path, opts) do
timeout = Keyword.get(opts, :timeout, Majic.Config.default_process_timeout())
mod.perform(name, path, timeout)
end
defp do_perform({Once = mod, _}, path, opts) do
- mod.perform(path, opts)
+ timeout = Keyword.get(opts, :timeout, Majic.Config.default_process_timeout())
+ mod.perform(path, opts, timeout)
end
defp do_perform({Pool = mod, name}, path, opts) do
mod.perform(name, path, opts)
end
end
diff --git a/lib/majic/extension.ex b/lib/majic/extension.ex
index 3bbdeba..e701f79 100644
--- a/lib/majic/extension.ex
+++ b/lib/majic/extension.ex
@@ -1,112 +1,146 @@
defmodule Majic.Extension do
@moduledoc """
Helper module to fix extensions. Uses [MIME](https://hexdocs.pm/mime/MIME.html).
"""
@typedoc """
If an extension is defined for a given MIME type, append it to the previous extension.
If no extension could be found for the MIME type, and `subtype_as_extension: false`, the returned filename will have no extension.
"""
@type option_append :: {:append, false | true}
@typedoc "If no extension is defined for a given MIME type, use the subtype as its extension."
@type option_subtype_as_extension :: {:subtype_as_extension, false | true}
@spec fix(Path.t(), Majic.Result.t() | String.t(), [
option_append() | option_subtype_as_extension()
]) :: Path.t()
@doc """
Fix `name`'s extension according to `result_or_mime_type`.
```elixir
iex(1)> {:ok, result} = Majic.perform("cat.jpeg", once: true)
{:ok, %Majic.Result{mime_type: "image/webp", ...}}
iex(1)> Majic.Extension.fix("cat.jpeg", result)
"cat.webp"
```
The `append: true` option will append the correct extension to the user-provided one, if there's an extension for the
type:
```
iex(1)> Majic.Extension.fix("cat.jpeg", result, append: true)
"cat.jpeg.webp"
iex(2)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", append: true)
"Makefile"
```
The `subtype_as_extension: true` option will use the subtype part of the MIME type as an extension for the ones that
don't have any:
```elixir
iex(1)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true)
"Makefile.x-makefile"
iex(1)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true, append: true)
"Makefile.txt.x-makefile"
```
"""
def fix(name, result_or_mime_type, options \\ [])
def fix(name, %Majic.Result{mime_type: mime_type}, options) do
do_fix(name, mime_type, options)
end
def fix(name, mime_type, options) do
do_fix(name, mime_type, options)
end
defp do_fix(name, mime_type, options) do
append? = Keyword.get(options, :append, false)
subtype? = Keyword.get(options, :subtype_as_extension, false)
ext_candidates = MIME.extensions(mime_type)
- old_ext = String.downcase(Path.extname(name))
- old_ext_bare = String.trim_leading(old_ext, ".")
+ old_ext = Path.extname(name)
+ old_ext_bare = String.trim_leading(String.downcase(old_ext), ".")
+ dir = Path.dirname(name)
basename = Path.basename(name, old_ext)
+ full_basename = Path.basename(name)
+ has_ext? = not match?("", old_ext)
+
+ join = fn filename ->
+ case dir do
+ "." -> filename
+ _ -> Path.join(dir, filename)
+ end
+ end
cond do
- # extension already in candidate list, so no-op
old_ext_bare in ext_candidates ->
name
- # has extension, append the subtype
- not match?("", old_ext) && append? && subtype? ->
- Enum.join([name, subtype_extension(subtype?, mime_type)], ".")
-
- # has extension, change to subtype
- not match?("", old_ext) && subtype? ->
- Enum.join([basename, subtype_extension(subtype?, mime_type)], ".")
+ has_ext? ->
+ fix_existing_ext(
+ basename,
+ full_basename,
+ append?,
+ subtype?,
+ ext_candidates,
+ mime_type,
+ join
+ )
- # no extension, append
- match?("", old_ext) && append? ->
- Enum.join([basename, List.first(ext_candidates)], ".")
+ true ->
+ fix_no_ext(name, basename, append?, subtype?, ext_candidates, mime_type, join)
+ end
+ end
- # no candidates, so strip extension
- match?([], ext_candidates) ->
- basename
+ defp fix_existing_ext(
+ basename,
+ full_basename,
+ append?,
+ subtype?,
+ ext_candidates,
+ mime_type,
+ join
+ ) do
+ cond do
+ append? && subtype? ->
+ join.(Enum.join([full_basename, subtype_extension(subtype?, mime_type)], "."))
- # no extension but no appending, so no-op
- match?("", old_ext) ->
- name
+ subtype? ->
+ join.(Enum.join([basename, subtype_extension(subtype?, mime_type)], "."))
- # append first candidate
- not Enum.empty?(ext_candidates) && append? ->
- Enum.join([name, List.first(ext_candidates)], ".")
+ append? && not Enum.empty?(ext_candidates) ->
+ join.(Enum.join([full_basename, List.first(ext_candidates)], "."))
- # change extension to first candidate
not Enum.empty?(ext_candidates) ->
- Enum.join([basename, List.first(ext_candidates)], ".")
+ join.(Enum.join([basename, List.first(ext_candidates)], "."))
+
+ true ->
+ join.(basename)
+ end
+ end
+
+ defp fix_no_ext(name, basename, append?, subtype?, ext_candidates, mime_type, join) do
+ cond do
+ append? && subtype? ->
+ join.(Enum.join([basename, subtype_extension(subtype?, mime_type)], "."))
+
+ append? && not Enum.empty?(ext_candidates) ->
+ join.(Enum.join([basename, List.first(ext_candidates)], "."))
+
+ append? ->
+ name
- # do nothing
true ->
name
end
end
defp subtype_extension(true, type) do
[_type, sub] = String.split(type, "/", parts: 2)
[sub]
end
defp subtype_extension(_, _), do: []
end
diff --git a/lib/majic/once.ex b/lib/majic/once.ex
index 9bf91c0..2460b1c 100644
--- a/lib/majic/once.ex
+++ b/lib/majic/once.ex
@@ -1,30 +1,32 @@
defmodule Majic.Once do
@moduledoc """
Contains convenience functions for one-off use.
"""
alias Majic.Server
@process_timeout Majic.Config.default_process_timeout()
@spec perform(Majic.target(), [Server.start_option()], timeout()) :: Majic.result()
@doc """
Runs a one-shot process without supervision.
Useful in tests, but not recommended for actual applications.
## Example
iex(1)> {:ok, result} = Majic.Once.perform(".")
iex(2)> result
%Majic.Result{content: "directory", encoding: "binary", mime_type: "inode/directory"}
"""
def perform(path, options \\ [], timeout \\ @process_timeout) do
- with {:ok, pid} <- Server.start_link(options),
- {:ok, result} <- Server.perform(pid, path, timeout),
- :ok <- Server.stop(pid) do
- {:ok, result}
+ {:ok, pid} = Server.start_link(options)
+
+ try do
+ Server.perform(pid, path, timeout)
+ after
+ if Process.alive?(pid), do: Server.stop(pid)
end
end
end
diff --git a/lib/majic/plug.ex b/lib/majic/plug.ex
index 24f2395..9a7233d 100644
--- a/lib/majic/plug.ex
+++ b/lib/majic/plug.ex
@@ -1,141 +1,116 @@
if Code.ensure_loaded?(Plug) do
defmodule Majic.PlugError do
defexception [:message]
end
defmodule Majic.Plug do
@moduledoc """
A `Plug` to automatically set the `content_type` of every `Plug.Upload`.
One of the required option of `pool`, `server` or `once` must be set.
Additional options:
* `fix_extension`, default false: enable use of `Majic.Extension`,
* options for `Majic.Extension`.
To use a majic pool:
```
plug Majic.Plug, pool: MyApp.MajicPool
```
To use a single majic server:
```
plug Majic.Plug, server: MyApp.MajicServer
```
To start a majic process at each file (not recommended):
```
plug Majic.Plug, once: true
```
"""
@behaviour Plug
@impl Plug
def init(opts) do
cond do
Keyword.has_key?(opts, :pool) -> true
Keyword.has_key?(opts, :server) -> true
Keyword.has_key?(opts, :once) -> true
true -> raise(Majic.PlugError, "No server/pool/once option defined")
end
opts
|> Keyword.put_new(:fix_extension, false)
|> Keyword.put_new(:append, false)
|> Keyword.put_new(:subtype_as_extension, false)
end
@impl Plug
def call(conn, opts) do
- collected = collect_uploads([], conn.body_params, [])
-
- Enum.reduce(collected, conn, fn {param_path, upload}, conn ->
- {array_index, param_path} =
- case param_path do
- [index, :array | path] ->
- {index, path}
+ body_params = transform_uploads(conn.body_params, opts)
+ params = merge_params(conn.query_params, body_params)
+ %{conn | body_params: body_params, params: params}
+ end
- path ->
- {nil, path}
- end
+ defp merge_params(query_params, body_params)
+ when is_map(query_params) and is_map(body_params) do
+ Map.merge(query_params, body_params, fn _k, qv, bv ->
+ merge_values(qv, bv)
+ end)
+ end
- param_path = Enum.reverse(param_path)
+ defp merge_values(qv, bv) when is_map(qv) and is_map(bv) do
+ Map.merge(qv, bv, fn _k, qv2, bv2 -> merge_values(qv2, bv2) end)
+ end
- upload =
- case Majic.perform(upload.path, opts) do
- {:ok, magic} -> fix_upload(upload, magic, opts)
- {:error, error} -> raise(Majic.PlugError, "Failed to majic: #{inspect(error)}")
- end
+ defp merge_values(_qv, bv), do: bv
- conn
- |> put_in_if_exists(:params, param_path, upload, array_index)
- |> put_in_if_exists(:body_params, param_path, upload, array_index)
- end)
+ defp transform_uploads(params, opts) when is_map(params) do
+ Map.new(params, fn {k, v} -> {k, transform_upload_value(v, opts)} end)
end
- defp collect_uploads(path, params, acc) do
- Enum.reduce(params, acc, fn value, acc -> collect_upload(path, value, acc) end)
+ defp transform_uploads(params, opts) when is_list(params) do
+ Enum.map(params, &transform_upload_value(&1, opts))
end
- # An upload!
- defp collect_upload(path, {k, %{__struct__: Plug.Upload} = upload}, acc) do
- [{[k | path], upload} | acc]
+ defp transform_upload_value(%{__struct__: Plug.Upload} = upload, opts) do
+ case Majic.perform(upload.path, opts) do
+ {:ok, magic} -> fix_upload(upload, magic, opts)
+ {:error, _error} -> upload
+ end
end
- # Ignore structs.
- defp collect_upload(_path, {_, %{__struct__: _}}, acc) do
- acc
+ defp transform_upload_value(%{__struct__: _} = struct, _opts) do
+ struct
end
- # Nested map.
- defp collect_upload(path, {k, v}, acc) when is_map(v) do
- collect_uploads([k | path], v, acc)
+ defp transform_upload_value(v, opts) when is_map(v) do
+ transform_uploads(v, opts)
end
- defp collect_upload(path, {k, v}, acc) when is_list(v) do
- Enum.reduce(Enum.with_index(v), acc, fn {item, index}, acc ->
- collect_upload([:array, k | path], {index, item}, acc)
- end)
+ defp transform_upload_value(v, opts) when is_list(v) do
+ transform_uploads(v, opts)
end
- defp collect_upload(_path, _, acc) do
- acc
+ defp transform_upload_value(v, _opts) do
+ v
end
defp fix_upload(upload, magic, opts) do
filename =
if Keyword.get(opts, :fix_extension) do
ext_opts = [
append: Keyword.get(opts, :append, false),
subtype_as_extension: Keyword.get(opts, :subtype_as_extension, false)
]
Majic.Extension.fix(upload.filename, magic, ext_opts)
end
%{upload | content_type: magic.mime_type, filename: filename || upload.filename}
end
-
- # put value at path in conn.
- defp put_in_if_exists(conn, key, path, value, nil) do
- if get_in(Map.get(conn, key), path) do
- Map.put(conn, key, put_in(Map.get(conn, key), path, value))
- else
- conn
- end
- end
-
- # change value at index in list at path in conn.
- defp put_in_if_exists(conn, key, path, value, index) do
- if array = get_in(Map.get(conn, key), path) do
- array = List.replace_at(array, index, value)
- Map.put(conn, key, put_in(Map.get(conn, key), path, array))
- else
- conn
- end
- end
end
end
diff --git a/lib/majic/pool.ex b/lib/majic/pool.ex
index 079f50b..0c7ef0f 100644
--- a/lib/majic/pool.ex
+++ b/lib/majic/pool.ex
@@ -1,71 +1,75 @@
defmodule Majic.Pool do
@behaviour NimblePool
@moduledoc "Pool of `Majic.Server`"
@type name :: atom()
@type option :: {:pool_timeout, timeout()} | {:timeout, timeout()}
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 500
}
end
def start_link(options) do
{pool_size, options} = Keyword.pop(options, :pool_size, System.schedulers_online())
NimblePool.start_link(worker: {__MODULE__, options}, pool_size: pool_size)
end
@spec perform(name(), Majic.target(), [option()]) :: Majic.result()
def perform(pool, path, opts \\ []) do
pool_timeout = Keyword.get(opts, :pool_timeout, Majic.Config.default_process_timeout())
timeout = Keyword.get(opts, :timeout, Majic.Config.default_process_timeout())
NimblePool.checkout!(
pool,
:checkout,
fn _, server ->
{Majic.Server.perform(server, path, timeout), server}
end,
pool_timeout
)
end
@impl NimblePool
def init_pool(options) do
{name, options} =
case Keyword.pop(options, :name) do
{name, options} when is_atom(name) -> {name, options}
{nil, options} -> {__MODULE__, options}
{_, options} -> {nil, options}
end
if name, do: Process.register(self(), name)
{:ok, options}
end
@impl NimblePool
def init_worker(options) do
{:ok, server} = Majic.Server.start_link(options || [])
{:ok, server, options}
end
@impl NimblePool
def handle_checkout(:checkout, _from, server, pool) do
{:ok, server, server, pool}
end
@impl NimblePool
def handle_checkin(_, _, server, pool) do
{:ok, server, pool}
end
@impl NimblePool
- def terminate_worker(_reason, _worker, state) do
+ def terminate_worker(_reason, server, state) do
+ if is_pid(server) and Process.alive?(server) do
+ Majic.Server.stop(server)
+ end
+
{:ok, state}
end
end
diff --git a/lib/majic/server.ex b/lib/majic/server.ex
index f339b4d..a5c3aa7 100644
--- a/lib/majic/server.ex
+++ b/lib/majic/server.ex
@@ -1,446 +1,491 @@
defmodule Majic.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 Majic.Result
alias Majic.Server.Data
alias Majic.Server.Status
import Kernel, except: [send: 2]
@database_patterns [:default]
@process_timeout Majic.Config.default_process_timeout()
@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 `Majic.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 files, or of
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 start_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([start_option()]) :: Supervisor.child_spec()
@spec start_link([start_option()]) :: :gen_statem.start_ret()
@spec perform(t(), Majic.target(), timeout()) :: Majic.result()
@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 \\ @process_timeout) do
case :gen_statem.call(server_ref, {:perform, path}, timeout) do
{:ok, %Result{} = result} -> {:ok, result}
{:error, reason} -> {:error, reason}
end
end
@doc """
Reloads a Server with a new set of databases.
"""
def reload(server_ref, database_patterns \\ nil, timeout \\ @process_timeout) do
:gen_statem.call(server_ref, {:reload, database_patterns}, timeout)
end
@doc """
Same as `reload/2,3` but with a full restart of the underlying C port.
"""
def recycle(server_ref, database_patterns \\ nil, timeout \\ @process_timeout) do
:gen_statem.call(server_ref, {:recycle, database_patterns}, timeout)
end
@doc """
Returns status of the Server.
"""
def status(server_ref, timeout \\ @process_timeout) 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 Majic.Config
data = %Data{
port_name: get_port_name(),
database_patterns: Keyword.get(options, :database_patterns),
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, _, %{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(:enter, _, data) do
{:keep_state_and_data, 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({:call, _from}, {:reload, _}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ @doc false
+ def starting({:call, _from}, {:recycle, _}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
@doc false
def starting(:info, {port, {:data, ready}}, %{port: port} = data) do
case :erlang.binary_to_term(ready) do
:ready -> {:next_state, :loading, data}
end
end
@doc false
def starting(:info, {port, {:exit_status, code}}, %{port: port} = data) do
error =
case code do
1 -> :bad_db
2 -> :ei_error
3 -> :ei_bad_term
4 -> :magic_error
code -> {:unexpected_error, code}
end
{:stop, {:error, error}, data}
end
@doc false
def loading(:enter, _old_state, data) do
databases =
Enum.flat_map(List.wrap(data.database_patterns || @database_patterns), fn
:default -> [:default]
pattern -> Path.wildcard(pattern)
end)
if databases == [] do
{:stop, {:error, :no_databases_to_load}, data}
else
{:keep_state, {databases, data}, {:state_timeout, 0, :load}}
end
end
@doc false
def loading(:state_timeout, :load_timeout, {[database | _], data}) do
{:stop, {:error, {:database_loading_timeout, database}}, data}
end
@doc false
def loading(:state_timeout, :load, {[], data}) do
{:next_state, :available, data}
end
@doc false
def loading(:state_timeout, :load, {[database | _databases], data} = state) do
command =
case database do
:default -> {:add_database, :default}
path when is_binary(path) -> {:add_database, path}
end
send(data.port, command)
{:keep_state, state, {:state_timeout, data.startup_timeout, :load_timeout}}
end
@doc false
def loading(:info, {port, {:data, response}}, {[database | databases], %{port: port} = data}) do
case :erlang.binary_to_term(response) do
{:ok, :loaded} ->
{:keep_state, {databases, data}, {:state_timeout, 0, :load}}
{:error, :not_loaded} ->
{:stop, {:error, {:database_load_failed, database}}, data}
end
end
@doc false
def loading({:call, from}, :status, {_, data}) do
handle_status_call(from, :loading, data)
end
@doc false
def loading({:call, _from}, {:perform, _path}, _data) do
{:keep_state_and_data, :postpone}
end
+ @doc false
+ def loading({:call, _from}, {:reload, _}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ @doc false
+ def loading({:call, _from}, {:recycle, _}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
@doc false
def available(:enter, _old_state, %{request: {:reload, from, _}}) do
response = {:reply, from, :ok}
{:keep_state_and_data, response}
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.timestamp()}}
arg =
case path do
path when is_binary(path) -> {:file, path}
# Truncate to 50 bytes
{:bytes, <<bytes::binary-size(50), _::binary>>} -> {:bytes, bytes}
{:bytes, bytes} -> {:bytes, bytes}
end
send(data.port, arg)
{:next_state, :processing, data}
end
@doc false
def available({:call, from}, {:reload, databases}, data) do
send(data.port, {:reload, :reload})
{:next_state, :starting,
%{
data
| database_patterns: databases || data.database_patterns,
request: {:reload, from, :reload}
}}
end
@doc false
def available({:call, from}, {:recycle, databases}, data) do
{:next_state, :recycling,
%{
data
| database_patterns: databases || data.database_patterns,
request: {:reload, from, :recycle}
}}
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}, {:reload, _}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ @doc false
+ def processing({:call, _from}, {:recycle, _}, _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(:state_timeout, _, %{request: {_, from, _}} = data) do
response = {:reply, from, {:error, :timeout}}
{:next_state, :recycling, %{data | request: nil}, [response, :hibernate]}
end
@doc false
def processing(:info, {port, {:data, response}}, %{port: port, request: {_, from, _}} = data) do
response = {:reply, from, handle_response(response)}
next_state = (data.cycles >= data.recycle_threshold && :recycling) || :available
{:next_state, next_state, %{data | request: nil}, [response, :hibernate]}
end
@doc false
def recycling(:enter, _, %{port: port} = data) when is_port(port) do
send(data.port, {:stop, :recycle})
{:keep_state_and_data, {:state_timeout, data.startup_timeout, :stop}}
end
@doc false
def recycling({:call, _from}, {:perform, _path}, _data) do
{:keep_state_and_data, :postpone}
end
+ @doc false
+ def recycling({:call, _from}, {:reload, _}, _data) do
+ {:keep_state_and_data, :postpone}
+ end
+
+ @doc false
+ def recycling({:call, _from}, {:recycle, _}, _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
# In case of timeout, force close.
def recycling(:state_timeout, :stop, data) do
Kernel.send(data.port, {self(), :close})
{:keep_state_and_data, {:state_timeout, data.startup_timeout, :close}}
end
@doc false
def recycling(:state_timeout, :close, data) do
{:stop, {:error, :port_close_failed}, data}
end
@doc false
def recycling(:info, {port, :closed}, %{port: port} = data) do
{:next_state, :starting, %{data | port: nil, cycles: 0}}
end
@doc false
def recycling(:info, {port, {:exit_status, _}}, %{port: port} = data) do
{:next_state, :starting, %{data | port: nil, cycles: 0}}
end
+ @doc false
+ def recycling(:info, {port, {:data, _}}, %{port: port} = _data) do
+ :keep_state_and_data
+ end
+
@doc false
@impl :gen_statem
- def terminate(_, _, %{port: port}) do
+ def terminate(_, _, %{port: port}) when is_port(port) do
Kernel.send(port, {self(), :close})
end
@doc false
def terminate(_, _, _) do
:ok
end
defp send(port, command) do
Kernel.send(port, {self(), {:command, :erlang.term_to_binary(command)}})
end
@errnos %{
2 => :enoent,
- 13 => :eaccess,
+ 13 => :eacces,
20 => :enotdir,
12 => :enomem,
24 => :emfile,
36 => :enametoolong
}
@errno Map.keys(@errnos)
defp handle_response(data) do
case :erlang.binary_to_term(data) do
{:ok, {mime_type, encoding, content}} -> {:ok, Result.build(mime_type, encoding, content)}
{:error, {errno, _}} when errno in @errno -> {:error, @errnos[errno]}
{:error, {errno, string}} -> {:error, "#{errno}: #{string}"}
{:error, _} = error -> error
end
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/src/libmagic_port.c b/src/libmagic_port.c
index 84199e3..04d9e84 100644
--- a/src/libmagic_port.c
+++ b/src/libmagic_port.c
@@ -1,449 +1,446 @@
//
// libmagic_port: The Sorcerer’s Apprentice
//
// To use this program, compile it with dynamically linked libmagic, as mirrored
// at https://github.com/file/file. 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.
//
// Communication is done over STDIN/STDOUT as binary packets of 2 bytes length
// plus X bytes payload, where the payload is an erlang term encoded with
// :erlang.term_to_binary/1 and decoded with :erlang.binary_to_term/1.
//
// Once the program is ready, it sends the `:ready` atom.
//
// It is then up to the Erlang side to load databases, by sending messages:
// - `{:add_database, :default | path}`
//
// If the requested database have been loaded, an `{:ok, :loaded}` message will
// follow. Otherwise, the process will exit (exit code 1).
//
// Commands are sent to the program STDIN as an erlang term of `{Operation,
// Argument}`, and response of `{:ok | :error, Response}`.
//
// The program may exit with the following exit codes:
// - 1 if libmagic handles could not be opened,
// - 2 if something went wrong with ei_*,
// - 3 if you sent invalid term format,
// - 255 if the loop exited unexpectedly.
//
// Invalid packets will cause the program to exit (exit code 3). This will
// happen if your Erlang Term format doesn't match the version the program has
// been compiled with.
//
// Commands:
// {:reload, _} :: :ready
// {:add_database, :default | String.t()} :: {:ok, _} | {:error, _}
// {:file, path :: String.t()} :: {:ok, {type, encoding, name}} | {:error,
// :badarg} | {:error, {errno :: integer(), String.t()}}
// {:bytes, binary()} :: same as :file
// {:stop, reason :: atom()} :: exit 0
#include <arpa/inet.h>
#include <ei.h>
#ifdef EI_INCOMPLETE
#include <erl_interface.h>
#endif
#include <errno.h>
#include <getopt.h>
#include <libgen.h>
#include <magic.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#define ERROR_OK 0
#define ERROR_MAGIC 1
#define ERROR_EI 2
#define ERROR_BAD_TERM 3
// We use a bigger than possible valid command length (around 4111 bytes) to
// allow more precise errors when using too long paths.
#define COMMAND_LEN 8000
#define COMMAND_BUFFER_SIZE COMMAND_LEN + 1
#define MAGIC_FLAGS_COMMON (MAGIC_CHECK | MAGIC_ERROR)
magic_t magic_setup(int flags);
#define EI_ENSURE(result) \
do { \
if (result != 0) { \
fprintf(stderr, "EI ERROR, line: %d", __LINE__); \
exit(ERROR_EI); \
} \
} while (0);
typedef char byte;
void setup_environment();
void magic_close_all();
void magic_open_all();
int magic_load_all(char *path);
void process_command(uint16_t len, byte *buf);
void process_command_file(byte *buf, int index, ei_x_buff *result);
void process_command_bytes(byte *buf, int index, ei_x_buff *result);
void process_command_load(byte *buf, int index, ei_x_buff *result);
void process_file(char *path, ei_x_buff *result);
void process_bytes(char *bytes, int size, ei_x_buff *result);
void process_load(ei_x_buff *result, char *path);
void send_and_free(ei_x_buff *result);
size_t read_cmd(byte *buf);
size_t write_cmd(byte *buf, size_t len);
void error(ei_x_buff *result, const char *error);
void handle_magic_error(magic_t handle, int errn, ei_x_buff *result);
void fdseek(uint16_t count);
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
bool magic_loaded = false;
int main(int argc, char **argv) {
-#ifdef EI_INCOMPLETE
- erl_init(NULL, -1);
-#else
- EI_ENSURE(ei_init());
-#endif
+ if (!getenv("MAJIC_DEBUG"))
+ freopen("/dev/null", "w", stderr);
setup_environment();
magic_open_all();
byte buf[COMMAND_BUFFER_SIZE];
uint16_t len;
while ((len = read_cmd(buf)) > 0) {
process_command(len, buf);
}
return 255;
}
void process_command(uint16_t len, byte *buf) {
ei_x_buff result;
char atom[128];
int index, version, arity;
index = 0;
// Initialize result
EI_ENSURE(ei_x_new_with_version(&result));
EI_ENSURE(ei_x_encode_tuple_header(&result, 2));
if (len >= COMMAND_LEN)
return error(&result, "badarg");
if (ei_decode_version(buf, &index, &version) != 0)
exit(ERROR_BAD_TERM);
if (ei_decode_tuple_header(buf, &index, &arity) != 0)
return error(&result, "badarg");
if (arity != 2)
return error(&result, "badarg");
if (ei_decode_atom(buf, &index, atom) != 0)
return error(&result, "badarg");
// {:file, path}
if (strlen(atom) == 4 && strcmp(atom, "file") == 0)
return process_command_file(buf, index, &result);
// {:bytes, bytes}
if (strlen(atom) == 5 && strcmp(atom, "bytes") == 0)
return process_command_bytes(buf, index, &result);
// {:add_database, path}
if (strlen(atom) == 12 && strcmp(atom, "add_database") == 0)
return process_command_load(buf, index, &result);
// {:reload, _}
if (strlen(atom) == 6 && strcmp(atom, "reload") == 0)
return magic_open_all();
// {:stop, _}
if (strlen(atom) == 4 && strcmp(atom, "stop") == 0)
exit(ERROR_OK);
error(&result, "badarg");
}
void process_command_file(byte *buf, int index, ei_x_buff *result) {
int termtype, termsize;
char path[4097];
long bin_length;
if (!magic_loaded)
return error(result, "magic_database_not_loaded");
ei_get_type(buf, &index, &termtype, &termsize);
if (termtype != ERL_BINARY_EXT)
return error(result, "badarg");
if (termsize > 4096)
return error(result, "enametoolong");
EI_ENSURE(ei_decode_binary(buf, &index, path, &bin_length));
path[termsize] = '\0';
process_file(path, result);
}
void process_command_bytes(byte *buf, int index, ei_x_buff *result) {
if (!magic_loaded)
return error(result, "magic_database_not_loaded");
int termtype, termsize;
long bin_length;
char bytes[51];
EI_ENSURE(ei_get_type(buf, &index, &termtype, &termsize));
if (termtype != ERL_BINARY_EXT)
return error(result, "badarg");
if (termsize > 50)
return error(result, "toolong");
EI_ENSURE(ei_decode_binary(buf, &index, bytes, &bin_length));
bytes[termsize] = '\0';
process_bytes(bytes, termsize, result);
}
void process_command_load(byte *buf, int index, ei_x_buff *result) {
char path[4097];
int termtype, termsize;
ei_get_type(buf, &index, &termtype, &termsize);
if (termtype == ERL_BINARY_EXT) {
if (termsize > 4096)
return error(result, "enametoolong");
long bin_length;
EI_ENSURE(ei_decode_binary(buf, &index, path, &bin_length));
path[termsize] = '\0';
return process_load(result, path);
}
if (termtype == ERL_ATOM_EXT) {
EI_ENSURE(ei_decode_atom(buf, &index, path));
if (strlen(path) == 7 && strcmp(path, "default") == 0)
return process_load(result, NULL);
}
error(result, "badarg");
}
void process_load(ei_x_buff *result, char *path) {
if (magic_load_all(path) == 0) {
EI_ENSURE(ei_x_encode_atom(result, "ok"));
EI_ENSURE(ei_x_encode_atom(result, "loaded"));
} else {
EI_ENSURE(ei_x_encode_atom(result, "error"));
EI_ENSURE(ei_x_encode_atom(result, "not_loaded"));
}
send_and_free(result);
}
void setup_environment() { opterr = 0; }
void magic_close_all() {
magic_loaded = false;
if (magic_mime_encoding) {
magic_close(magic_mime_encoding);
magic_mime_encoding = NULL;
}
if (magic_mime_type) {
magic_close(magic_mime_type);
magic_mime_type = NULL;
}
if (magic_type_name) {
magic_close(magic_type_name);
magic_type_name = NULL;
}
}
void magic_open_all() {
magic_close_all();
magic_mime_encoding = magic_open(MAGIC_FLAGS_COMMON | MAGIC_MIME_ENCODING);
magic_mime_type = magic_open(MAGIC_FLAGS_COMMON | MAGIC_MIME_TYPE);
magic_type_name = magic_open(MAGIC_FLAGS_COMMON | MAGIC_NONE);
if (magic_mime_encoding && magic_mime_type && magic_type_name) {
ei_x_buff ok_buf;
EI_ENSURE(ei_x_new_with_version(&ok_buf));
EI_ENSURE(ei_x_encode_atom(&ok_buf, "ready"));
return send_and_free(&ok_buf);
}
exit(ERROR_MAGIC);
}
int magic_load_all(char *path) {
int res;
if ((res = magic_load(magic_mime_encoding, path)) != 0)
return res;
if ((res = magic_load(magic_mime_type, path)) != 0)
return res;
if ((res = magic_load(magic_type_name, path)) != 0)
return res;
magic_loaded = true;
return 0;
}
void process_bytes(char *path, int size, ei_x_buff *result) {
const char *mime_type_result = magic_buffer(magic_mime_type, path, size);
const int mime_type_errno = magic_errno(magic_mime_type);
if (mime_type_errno > 0)
return handle_magic_error(magic_mime_type, mime_type_errno, result);
const char *mime_encoding_result =
magic_buffer(magic_mime_encoding, path, size);
int mime_encoding_errno = magic_errno(magic_mime_encoding);
if (mime_encoding_errno > 0)
return handle_magic_error(magic_mime_encoding, mime_encoding_errno, result);
const char *type_name_result = magic_buffer(magic_type_name, path, size);
int type_name_errno = magic_errno(magic_type_name);
if (type_name_errno > 0)
return handle_magic_error(magic_type_name, type_name_errno, result);
EI_ENSURE(ei_x_encode_atom(result, "ok"));
EI_ENSURE(ei_x_encode_tuple_header(result, 3));
EI_ENSURE(
ei_x_encode_binary(result, mime_type_result, strlen(mime_type_result)));
EI_ENSURE(ei_x_encode_binary(result, mime_encoding_result,
strlen(mime_encoding_result)));
EI_ENSURE(
ei_x_encode_binary(result, type_name_result, strlen(type_name_result)));
send_and_free(result);
}
void handle_magic_error(magic_t handle, int errn, ei_x_buff *result) {
const char *error = magic_error(handle);
EI_ENSURE(ei_x_encode_atom(result, "error"));
EI_ENSURE(ei_x_encode_tuple_header(result, 2));
long errlon = (long)errn;
EI_ENSURE(ei_x_encode_long(result, errlon));
EI_ENSURE(ei_x_encode_binary(result, error, strlen(error)));
send_and_free(result);
}
void process_file(char *path, ei_x_buff *result) {
const char *mime_type_result = magic_file(magic_mime_type, path);
const int mime_type_errno = magic_errno(magic_mime_type);
if (mime_type_errno > 0)
return handle_magic_error(magic_mime_type, mime_type_errno, result);
const char *mime_encoding_result = magic_file(magic_mime_encoding, path);
int mime_encoding_errno = magic_errno(magic_mime_encoding);
if (mime_encoding_errno > 0)
return handle_magic_error(magic_mime_encoding, mime_encoding_errno, result);
const char *type_name_result = magic_file(magic_type_name, path);
int type_name_errno = magic_errno(magic_type_name);
if (type_name_errno > 0)
return handle_magic_error(magic_type_name, type_name_errno, result);
EI_ENSURE(ei_x_encode_atom(result, "ok"));
EI_ENSURE(ei_x_encode_tuple_header(result, 3));
EI_ENSURE(
ei_x_encode_binary(result, mime_type_result, strlen(mime_type_result)));
EI_ENSURE(ei_x_encode_binary(result, mime_encoding_result,
strlen(mime_encoding_result)));
EI_ENSURE(
ei_x_encode_binary(result, type_name_result, strlen(type_name_result)));
send_and_free(result);
}
// Adapted from https://erlang.org/doc/tutorial/erl_interface.html
// Changed `read_cmd`, the original one was buggy given some length (due to
// endinaness).
// TODO: Check if `write_cmd` exhibits the same issue.
size_t read_exact(byte *buf, size_t len) {
int i;
size_t got = 0;
do {
if ((i = read(0, buf + got, len - got)) <= 0) {
return (i);
}
got += i;
} while (got < len);
return (len);
}
size_t write_exact(byte *buf, size_t len) {
int i;
size_t wrote = 0;
do {
if ((i = write(1, buf + wrote, len - wrote)) <= 0)
return (i);
wrote += i;
} while (wrote < len);
return (len);
}
size_t read_cmd(byte *buf) {
int i;
if ((i = read(0, buf, sizeof(uint16_t))) <= 0) {
return (i);
}
uint16_t len16 = *(uint16_t *)buf;
len16 = ntohs(len16);
// Buffer isn't large enough: just return possible len, without reading.
// Up to the caller of verifying the size again and return an error.
// buf left unchanged, stdin emptied of X bytes.
if (len16 > COMMAND_LEN) {
fdseek(len16);
return len16;
}
return read_exact(buf, len16);
}
size_t write_cmd(byte *buf, size_t len) {
byte li;
li = (len >> 8) & 0xff;
write_exact(&li, 1);
li = len & 0xff;
write_exact(&li, 1);
return write_exact(buf, len);
}
void send_and_free(ei_x_buff *result) {
write_cmd(result->buff, result->index);
EI_ENSURE(ei_x_free(result));
}
void error(ei_x_buff *result, const char *error) {
EI_ENSURE(ei_x_encode_atom(result, "error"));
EI_ENSURE(ei_x_encode_atom(result, error));
send_and_free(result);
}
void fdseek(uint16_t count) {
int i = 0;
while (i < count) {
getchar();
i += 1;
}
}
diff --git a/test/majic/extension_test.exs b/test/majic/extension_test.exs
index be9c644..20468df 100644
--- a/test/majic/extension_test.exs
+++ b/test/majic/extension_test.exs
@@ -1,41 +1,60 @@
defmodule Majic.ExtensionTest do
use ExUnit.Case
alias Majic.Extension
test "it fixes extensions" do
assert "Makefile" == Extension.fix("Makefile.txt", "text/x-makefile")
assert "cat.webp" == Extension.fix("cat.jpeg", "image/webp")
end
test "it appends extensions" do
assert "Makefile" == Extension.fix("Makefile.txt", "text/x-makefile", append: true)
assert "cat.jpeg.webp" == Extension.fix("cat.jpeg", "image/webp", append: true)
end
test "it appends extensions if none exist" do
assert "webpage.html" == Extension.fix("webpage", "text/html", append: true)
end
test "it does not append extension if none exist when appending not requested" do
assert "webpage" == Extension.fix("webpage", "text/html")
end
test "it uses subtype as extension" do
assert "Makefile.x-makefile" ==
Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true)
assert "cat.webp" == Extension.fix("cat.jpeg", "image/webp", subtype_as_extension: true)
end
test "it appends and use subtype" do
assert "Makefile.txt.x-makefile" ==
Extension.fix("Makefile.txt", "text/x-makefile",
subtype_as_extension: true,
append: true
)
assert "cat.jpeg.webp" ==
Extension.fix("cat.jpeg", "image/webp", subtype_as_extension: true, append: true)
end
+
+ test "preserves directory path" do
+ assert "dir/cat.webp" == Extension.fix("dir/cat.jpeg", "image/webp")
+ assert "dir/sub/cat.webp" == Extension.fix("dir/sub/cat.jpeg", "image/webp")
+ end
+
+ test "handles uppercase extensions" do
+ assert "cat.webp" == Extension.fix("cat.JPEG", "image/webp")
+ assert "cat.webp" == Extension.fix("cat.Jpg", "image/webp")
+ end
+
+ test "no trailing dot when no extension candidates exist" do
+ assert "Makefile" == Extension.fix("Makefile.txt", "text/x-makefile")
+ assert "cat" == Extension.fix("cat.txt", "text/x-makefile")
+ end
+
+ test "preserves directory path with append" do
+ assert "dir/cat.jpeg.webp" == Extension.fix("dir/cat.jpeg", "image/webp", append: true)
+ end
end
diff --git a/test/majic/helpers_test.exs b/test/majic/helpers_test.exs
index ebe1736..a76436e 100644
--- a/test/majic/helpers_test.exs
+++ b/test/majic/helpers_test.exs
@@ -1,14 +1,27 @@
defmodule Majic.OnceTest do
use Majic.MagicCase
doctest Majic.Once
test "perform" do
path = absolute_path("Makefile")
assert {:ok, %{mime_type: "text/x-makefile"}} = Majic.Once.perform(path)
end
test "Majic.perform" do
path = absolute_path("Makefile")
assert {:ok, %{mime_type: "text/x-makefile"}} = Majic.perform(path, once: true)
end
+
+ test "Majic.perform once path passes timeout option" do
+ path = absolute_path("Makefile")
+ assert {:ok, %{mime_type: "text/x-makefile"}} = Majic.perform(path, once: true, timeout: 5000)
+ end
+
+ test "perform does not leak server on error" do
+ before = Process.list()
+ assert {:error, :enoent} = Majic.Once.perform(missing_filename())
+ after_call = Process.list()
+ leaked = after_call -- before
+ assert leaked == [], "Leaked processes: #{inspect(leaked)}"
+ end
end
diff --git a/test/majic/majic_test.exs b/test/majic/majic_test.exs
index 9beadcb..7e2aab8 100644
--- a/test/majic/majic_test.exs
+++ b/test/majic/majic_test.exs
@@ -1,81 +1,79 @@
defmodule MajicTest do
use Majic.MagicCase
alias Majic.Result
doctest Majic
@iterations 100
test "Makefile is text file" do
{:ok, pid} = Majic.Server.start_link([])
path = absolute_path("Makefile")
assert {:ok, %{mime_type: "text/x-makefile"}} = Majic.Server.perform(pid, path)
end
test "With Majic.perform" do
{:ok, pid} = Majic.Server.start_link([])
path = absolute_path("Makefile")
assert {:ok, %{mime_type: "text/x-makefile"}} = Majic.perform(path, server: pid)
end
@tag external: true
test "Load test local files" do
{:ok, pid} = Majic.Server.start_link([])
files_stream()
|> Stream.cycle()
|> Stream.take(@iterations)
|> Stream.map(&assert {:ok, %Result{}} = Majic.Server.perform(pid, &1))
|> Enum.all?()
|> assert
end
test "Non-existent file" do
{:ok, pid} = Majic.Server.start_link([])
path = missing_filename()
assert_no_file(Majic.Server.perform(pid, path))
end
test "Bytes" do
{:ok, pid} = Majic.Server.start_link([])
bytes = File.read!("test/fixtures/cat.webp")
assert {:ok, _} = Majic.Server.perform(pid, {:bytes, bytes})
end
test "Named process" do
{:ok, pid} = Majic.Server.start_link(name: :gen_magic)
path = absolute_path("Makefile")
assert {:ok, %{cycles: 0}} = Majic.Server.status(:gen_magic)
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
assert {:ok, %Result{} = result} = Majic.Server.perform(:gen_magic, path)
assert {:ok, %{cycles: 1}} = Majic.Server.status(:gen_magic)
assert {:ok, %{cycles: 1}} = Majic.Server.status(pid)
assert "text/x-makefile" = result.mime_type
end
describe "custom database" do
setup do
- database = absolute_path("elixir.mgc")
- on_exit(fn -> File.rm(database) end)
- {_, 0} = System.cmd("file", ["-C", "-m", absolute_path("test/elixir")])
+ database = absolute_path("test/elixir")
[database: database]
end
test "recognises Elixir files", %{database: database} do
{:ok, pid} = Majic.Server.start_link(database_patterns: [database])
path = absolute_path("mix.exs")
assert {:ok, %Result{} = result} = Majic.Server.perform(pid, path)
assert "text/x-elixir" = result.mime_type
assert "us-ascii" = result.encoding
assert "Elixir module source text" = result.content
end
test "recognises Elixir files after a reload", %{database: database} do
{:ok, pid} = Majic.Server.start_link([])
path = absolute_path("mix.exs")
{:ok, %Result{mime_type: mime}} = Majic.Server.perform(pid, path)
refute mime == "text/x-elixir"
:ok = Majic.Server.reload(pid, [database])
assert {:ok, %Result{mime_type: "text/x-elixir"}} = Majic.Server.perform(pid, path)
end
end
end
diff --git a/test/majic/plug_test.exs b/test/majic/plug_test.exs
index 43b0ee1..2e3fed1 100644
--- a/test/majic/plug_test.exs
+++ b/test/majic/plug_test.exs
@@ -1,112 +1,127 @@
defmodule Majic.PlugTest do
use ExUnit.Case, async: true
use Plug.Test
defmodule TestRouter do
use Plug.Router
plug(:match)
plug(:dispatch)
plug(Plug.Parsers,
parsers: [:urlencoded, :multipart],
pass: ["*/*"]
)
# plug Majic.Plug, once: true
post "/" do
send_resp(conn, 200, "Ok")
end
end
setup_all do
Application.ensure_all_started(:plug)
:ok
end
@router_opts TestRouter.init([])
- @tag skip: true
test "convert uploads" do
multipart = """
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"form[makefile]\"; filename*=\"utf-8''mymakefile.txt\"\r
Content-Type: text/plain\r
\r
#{File.read!("Makefile")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"form[make][file]\"; filename*=\"utf-8''mymakefile.txt\"\r
Content-Type: text/plain\r
\r
#{File.read!("Makefile")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cat\"; filename*=\"utf-8''cute-cat.jpg\"\r
Content-Type: image/jpg\r
\r
#{File.read!("test/fixtures/cat.webp")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cats[]\"; filename*=\"utf-8''first-cute-cat.jpg\"\r
Content-Type: image/jpg\r
\r
#{File.read!("test/fixtures/cat.webp")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cats[]\"\r
\r
hello i am annoying
\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cats[]\"; filename*=\"utf-8''second-cute-cat.jpg\"\r
Content-Type: image/jpg\r
\r
#{File.read!("test/fixtures/cat.webp")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cats[][inception][cat]\"; filename*=\"utf-8''third-cute-cat.jpg\"\r
Content-Type: image/jpg\r
\r
#{File.read!("test/fixtures/cat.webp")}\r
------w58EW1cEpjzydSCq--\r
"""
orig_conn =
conn(:post, "/", multipart)
|> put_req_header("content-type", "multipart/mixed; boundary=----w58EW1cEpjzydSCq")
|> TestRouter.call(@router_opts)
- plug = Majic.Plug.init(once: true)
- plug_no_ext = Majic.Plug.init(once: true, fix_extension: false)
- plug_append_ext = Majic.Plug.init(once: true, fix_extension: true, append: true)
+ plug = Majic.Plug.init(once: true, fix_extension: true, startup_timeout: 5000)
+ plug_no_ext = Majic.Plug.init(once: true, fix_extension: false, startup_timeout: 5000)
+
+ plug_append_ext =
+ Majic.Plug.init(once: true, fix_extension: true, append: true, startup_timeout: 5000)
conn = Majic.Plug.call(orig_conn, plug)
conn_no_ext = Majic.Plug.call(orig_conn, plug_no_ext)
conn_append_ext = Majic.Plug.call(orig_conn, plug_append_ext)
assert conn.state == :sent
assert conn.status == 200
assert get_in(conn.body_params, ["form", "makefile"]) ==
get_in(conn.params, ["form", "makefile"])
assert get_in(conn.params, ["form", "makefile"]).content_type == "text/x-makefile"
assert get_in(conn.params, ["form", "makefile"]).filename == "mymakefile"
assert get_in(conn_no_ext.params, ["form", "makefile"]).filename == "mymakefile.txt"
assert get_in(conn_append_ext.params, ["form", "makefile"]).filename == "mymakefile"
assert get_in(conn.body_params, ["form", "make", "file"]) ==
get_in(conn.params, ["form", "make", "file"])
assert get_in(conn.params, ["form", "make", "file"]).content_type == "text/x-makefile"
assert get_in(conn.body_params, ["cat"]) == get_in(conn.params, ["cat"])
assert get_in(conn.params, ["cat"]).content_type == "image/webp"
assert get_in(conn.params, ["cat"]).filename == "cute-cat.webp"
assert get_in(conn_no_ext.params, ["cat"]).filename == "cute-cat.jpg"
assert get_in(conn_append_ext.params, ["cat"]).filename == "cute-cat.jpg.webp"
assert Enum.all?(conn.params["cats"], fn
%Plug.Upload{} = upload -> upload.content_type == "image/webp"
%{"inception" => %{"cat" => upload}} -> upload.content_type == "image/webp"
_ -> true
end)
end
+
+ test "falls back to original upload on majic error" do
+ upload = %Plug.Upload{
+ path: "/nonexistent/path/file.txt",
+ filename: "file.txt",
+ content_type: "text/plain"
+ }
+
+ conn = conn(:post, "/", %{"file" => upload})
+ plug = Majic.Plug.init(once: true, startup_timeout: 5000)
+ result = Majic.Plug.call(conn, plug)
+ assert result.params["file"].content_type == "text/plain"
+ assert result.params["file"].filename == "file.txt"
+ end
end
diff --git a/test/majic/pool_test.exs b/test/majic/pool_test.exs
index 38155e8..9c23c58 100644
--- a/test/majic/pool_test.exs
+++ b/test/majic/pool_test.exs
@@ -1,17 +1,27 @@
defmodule Majic.PoollTest do
use Majic.MagicCase
test "pool" do
{:ok, _} = Majic.Pool.start_link(name: TestPool, pool_size: 2)
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.Pool.perform(TestPool, absolute_path("Makefile"))
assert {:ok, _} = Majic.perform(absolute_path("Makefile"), pool: TestPool)
end
+
+ test "pool stops workers on termination" do
+ before = Process.list()
+ {:ok, pool} = Majic.Pool.start_link(pool_size: 2)
+ assert {:ok, _} = Majic.Pool.perform(pool, absolute_path("Makefile"))
+ GenServer.stop(pool)
+ after_stop = Process.list()
+ new_pids = after_stop -- before
+ assert new_pids == [], "Leaked processes after pool stop: #{inspect(new_pids)}"
+ end
end
diff --git a/test/majic/port_test.exs b/test/majic/port_test.exs
index 8f95c6e..eb9e018 100644
--- a/test/majic/port_test.exs
+++ b/test/majic/port_test.exs
@@ -1,204 +1,204 @@
defmodule Majic.PortTest do
use Majic.MagicCase
@tmp_path "/tmp/testgenmagicx"
require Logger
test "sends ready" do
port = Port.open(Majic.Config.get_port_name(), Majic.Config.get_port_options([]))
on_exit(fn -> send(port, {self(), :close}) end)
assert_ready(port)
end
test "errors with non existent database with an error" do
opts = [:use_stdio, :binary, :exit_status, {:packet, 2}, {:args, []}]
port = Port.open(Majic.Config.get_port_name(), opts)
on_exit(fn -> send(port, {self(), :close}) end)
assert_ready(port)
send(
port,
{self(), {:command, :erlang.term_to_binary({:add_database, "/somewhere/nowhere"})}}
)
assert_receive {^port, {:data, data}}
assert {:error, :not_loaded} == :erlang.binary_to_term(data)
end
test "loads default database" do
opts = [:use_stdio, :binary, :exit_status, {:packet, 2}, {:args, []}]
port = Port.open(Majic.Config.get_port_name(), opts)
on_exit(fn -> send(port, {self(), :close}) end)
assert_ready(port)
send(
port,
{self(), {:command, :erlang.term_to_binary({:add_database, :default})}}
)
assert_receive {^port, {:data, data}}
assert {:ok, :loaded} == :erlang.binary_to_term(data)
end
test "reloads" do
opts = [:use_stdio, :binary, :exit_status, {:packet, 2}, {:args, []}]
port = Port.open(Majic.Config.get_port_name(), opts)
on_exit(fn -> send(port, {self(), :close}) end)
assert_ready_and_init_default(port)
send(port, {self(), {:command, :erlang.term_to_binary({:reload, :reload})}})
assert_ready(port)
end
test "errors when no database loaded" do
opts = [:use_stdio, :binary, :exit_status, {:packet, 2}, {:args, []}]
port = Port.open(Majic.Config.get_port_name(), opts)
on_exit(fn -> send(port, {self(), :close}) end)
assert_ready(port)
send(port, {self(), {:command, :erlang.term_to_binary({:bytes, "hello world"})}})
assert_receive {^port, {:data, data}}
assert {:error, :magic_database_not_loaded} = :erlang.binary_to_term(data)
refute_receive _
end
test "stops" do
port = Port.open(Majic.Config.get_port_name(), Majic.Config.get_port_options([]))
on_exit(fn -> send(port, {self(), :close}) end)
assert_ready_and_init_default(port)
send(port, {self(), {:command, :erlang.term_to_binary({:stop, :stop})}})
assert_receive {^port, {:exit_status, 0}}
end
describe "port" do
setup do
port = Port.open(Majic.Config.get_port_name(), Majic.Config.get_port_options([]))
on_exit(fn -> send(port, {self(), :close}) end)
assert_ready_and_init_default(port)
%{port: port}
end
test "exits with badly formatted erlang terms", %{port: port} do
send(port, {self(), {:command, "i forgot to term_to_binary!!"}})
assert_receive {^port, {:exit_status, 3}}
end
test "errors with wrong command", %{port: port} do
send(port, {self(), {:command, :erlang.term_to_binary(:wrong)}})
assert_receive {^port, {:data, data}}
assert {:error, :badarg} = :erlang.binary_to_term(data)
refute_receive _
send(port, {self(), {:command, :erlang.term_to_binary({:file, 42})}})
assert_receive {^port, {:data, data}}
assert {:error, :badarg} = :erlang.binary_to_term(data)
refute_receive _
send(port, {self(), {:command, :erlang.term_to_binary("more wrong")}})
assert_receive {^port, {:data, data}}
assert {:error, :badarg} = :erlang.binary_to_term(data)
refute_receive _
send(port, {self(), {:command, :erlang.term_to_binary({"no", "no"})}})
assert_receive {^port, {:data, data}}
assert {:error, :badarg} = :erlang.binary_to_term(data)
refute_receive _
end
test "file works", %{port: port} do
send(port, {self(), {:command, :erlang.term_to_binary({:file, Path.expand("Makefile")})}})
assert_receive {^port, {:data, data}}
assert {:ok, _} = :erlang.binary_to_term(data)
end
test "bytes works", %{port: port} do
send(port, {self(), {:command, :erlang.term_to_binary({:bytes, "some bytes!"})}})
assert_receive {^port, {:data, data}}
assert {:ok, _} = :erlang.binary_to_term(data)
end
test "fails with non existent file", %{port: port} do
send(port, {self(), {:command, :erlang.term_to_binary({:file, "/path/to/nowhere"})}})
assert_receive {^port, {:data, data}}
assert {:error, _} = :erlang.binary_to_term(data)
end
test "works with big file path", %{port: port} do
# Test with longest valid path.
{dir, bigfile} = too_big(@tmp_path, "/a")
case File.mkdir_p(dir) do
:ok ->
File.touch!(bigfile)
on_exit(fn -> File.rm_rf!(@tmp_path) end)
send(port, {self(), {:command, :erlang.term_to_binary({:file, bigfile})}})
assert_receive {^port, {:data, data}}
assert {:ok, _} = :erlang.binary_to_term(data)
refute_receive _
# This path should be long enough for buffers, but larger than a valid path name.
# Magic will return an errno 36.
file = @tmp_path <> String.duplicate("a", 256)
send(port, {self(), {:command, :erlang.term_to_binary({:file, file})}})
assert_receive {^port, {:data, data}}
assert {:error, {36, _}} = :erlang.binary_to_term(data)
refute_receive _
# Theses filename should be too big for the path buffer.
file = bigfile <> "aaaaaaaaaa"
send(port, {self(), {:command, :erlang.term_to_binary({:file, file})}})
assert_receive {^port, {:data, data}}
assert {:error, :enametoolong} = :erlang.binary_to_term(data)
refute_receive _
# This call should be larger than the COMMAND_BUFFER_SIZE. Ensure nothing bad happens!
file = String.duplicate(bigfile, 4)
send(port, {self(), {:command, :erlang.term_to_binary({:file, file})}})
assert_receive {^port, {:data, data}}
assert {:error, :badarg} = :erlang.binary_to_term(data)
refute_receive _
# We re-run a valid call to ensure the buffer/... haven't been corrupted in port land.
send(port, {self(), {:command, :erlang.term_to_binary({:file, bigfile})}})
assert_receive {^port, {:data, data}}
assert {:ok, _} = :erlang.binary_to_term(data)
refute_receive _
{:error, :enametoolong} ->
Logger.info(
"Skipping test, operating system does not support max POSIX length for directories"
)
:ignore
end
end
end
def assert_ready(port) do
- assert_receive {^port, {:data, data}}
+ assert_receive {^port, {:data, data}}, 5000
assert :ready == :erlang.binary_to_term(data)
end
def assert_ready_and_init_default(port) do
- assert_receive {^port, {:data, data}}
+ assert_receive {^port, {:data, data}}, 5000
assert :ready == :erlang.binary_to_term(data)
send(port, {self(), {:command, :erlang.term_to_binary({:add_database, :default})}})
- assert_receive {^port, {:data, data}}
+ assert_receive {^port, {:data, data}}, 5000
assert {:ok, _} = :erlang.binary_to_term(data)
end
def too_big(path, filename, limit \\ 4095) do
last_len = byte_size(filename)
path_len = byte_size(path)
needed = limit - (last_len + path_len)
extra = make_too_big(needed, "")
{path <> extra, path <> extra <> filename}
end
def make_too_big(needed, acc) when needed <= 255 do
acc <> "/" <> String.duplicate("a", needed - 1)
end
def make_too_big(needed, acc) do
acc = acc <> "/" <> String.duplicate("a", 254)
make_too_big(needed - 255, acc)
end
end
diff --git a/test/majic/server_test.exs b/test/majic/server_test.exs
index d9b2c7a..5fbe1bc 100644
--- a/test/majic/server_test.exs
+++ b/test/majic/server_test.exs
@@ -1,44 +1,57 @@
defmodule Majic.ServerTest do
use Majic.MagicCase
doctest Majic.Server
describe "recycle_threshold" do
test "resets" do
{:ok, pid} = Majic.Server.start_link(recycle_threshold: 3)
path = absolute_path("Makefile")
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
assert {:ok, _} = Majic.Server.perform(pid, path)
assert {:ok, %{cycles: 1}} = Majic.Server.status(pid)
assert {:ok, _} = Majic.Server.perform(pid, path)
assert {:ok, %{cycles: 2}} = Majic.Server.status(pid)
assert {:ok, _} = Majic.Server.perform(pid, path)
Process.sleep(100)
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
end
test "resets before reply" do
{:ok, pid} = Majic.Server.start_link(recycle_threshold: 1)
path = absolute_path("Makefile")
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
assert {:ok, _} = Majic.Server.perform(pid, path)
Process.sleep(100)
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
assert {:ok, _} = Majic.Server.perform(pid, path)
Process.sleep(100)
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
assert {:ok, _} = Majic.Server.perform(pid, path)
Process.sleep(100)
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
end
end
test "recycle" do
{:ok, pid} = Majic.Server.start_link([])
path = absolute_path("Makefile")
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
assert {:ok, _} = Majic.Server.perform(pid, path)
assert {:ok, %{cycles: 1}} = Majic.Server.status(pid)
assert :ok = Majic.Server.recycle(pid)
assert {:ok, %{cycles: 0}} = Majic.Server.status(pid)
end
+
+ test "reload during processing is postponed not crashed" do
+ {:ok, pid} = Majic.Server.start_link([])
+ path = absolute_path("Makefile")
+ assert {:ok, _} = Majic.Server.perform(pid, path)
+ assert :ok = Majic.Server.reload(pid)
+ assert {:ok, _} = Majic.Server.perform(pid, path)
+ end
+
+ test "enoent for missing file" do
+ {:ok, pid} = Majic.Server.start_link([])
+ assert {:error, :enoent} = Majic.Server.perform(pid, missing_filename())
+ end
end

File Metadata

Mime Type
text/x-diff
Expires
Sat, Aug 8, 6:37 AM (14 h, 3 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1722948
Default Alt Text
(81 KB)

Event Timeline