# SafeAtom
[](https://github.com/ivan-podgurskiy/safe_atom/actions/workflows/ci.yml)
[](LICENSE)
Whitelist-based casting of values to atoms without growing the VM atom table from
untrusted input.
`SafeAtom` never calls `String.to_atom/1` or `String.to_existing_atom/1` on
external data. Binary input is matched against `Atom.to_string/1` for atoms you
already listed in `:allowed`, and the returned atom always comes from that list.
## Installation
Add `safe_atom` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:safe_atom, "~> 0.1"}
]
end
```
Or depend on the Git repository:
```elixir
{:safe_atom, git: "https://github.com/ivan-podgurskiy/safe_atom.git"}
```
## Quick start
```elixir
SafeAtom.cast("user", allowed: [:user, :guest])
# => {:ok, :user}
SafeAtom.cast(:guest, allowed: [:user, :guest])
# => {:ok, :guest}
SafeAtom.cast("admin", allowed: [:user, :guest])
# => {:error, :not_allowed}
SafeAtom.cast!("user", allowed: [:user, :guest])
# => :user
```
## API
### `SafeAtom.cast/2`
Casts a binary or atom to one of the atoms in `allowed: [...]`.
- `:allowed` is required and must be a list of atoms.
- Binary input is compared to each allowed atom’s string form.
- Atom input must already be a member of `:allowed`.
- `nil` is treated as an atom; include `nil` in `:allowed` if you need it.
Returns `{:ok, atom()}` or `{:error, reason}`.
### `SafeAtom.cast!/2`
Same as `cast/2`, but raises `SafeAtom.Error` on failure. The exception carries
`value`, `reason`, and `allowed` for debugging.
### Error reasons
| Reason | When |
| --- | --- |
| `:missing_allowed` | `:allowed` was not provided |
| `:invalid_allowed` | `:allowed` is not a list of atoms |
| `:invalid_value` | Input is neither a binary nor an atom |
| `:not_allowed` | Input is valid but not in the whitelist |
## Why?
Atoms in the Erlang VM are not garbage-collected. Calling `String.to_atom/1` on
user-controlled strings can exhaust the atom table and crash the node.
`String.to_existing_atom/1` avoids creating new atoms but still walks the global
atom table for every lookup.
`SafeAtom` keeps casting explicit: you declare the finite set of atoms you accept,
and only those atoms can be returned.
## Telemetry
`SafeAtom` emits one event whenever `cast/2` returns an error:
| Event | Measurements | Metadata |
| --- | --- | --- |
| `[:safe_atom, :cast, :rejected]` | `%{system_time: integer()}` | `%{reason, value, allowed}` |
Successful casts do not emit events. Attach a handler with `:telemetry.attach/4`
to log rejections or aggregate rates.
```elixir
:telemetry.attach(
"safe-atom-rejections",
[:safe_atom, :cast, :rejected],
fn _event, _measurements, %{reason: reason, value: value}, _config ->
Logger.warning("SafeAtom rejected #{inspect(value)}: #{reason}")
end,
nil
)
```
## Development
```bash
mix test
mix credo --strict
mix dialyzer
mix docs
```
## License
MIT © Ivan Podgurskiy. See [LICENSE](LICENSE).