GuardedStruct (GuardedStruct v0.1.0-beta.1)

Copy Markdown View Source

GuardedStruct macro: build structs with validation, sanitization, constructors, and nested-struct support.

Quick example

defmodule MyStruct do
  use GuardedStruct

  guardedstruct enforce: true do
    field :name, String.t()
    field :title, String.t(), default: "untitled"
  end
end

MyStruct.builder(%{name: "Mishka"})
# => {:ok, %MyStruct{name: "Mishka", title: "untitled"}}

Atom-attack safety

GuardedStruct accepts both atom-keyed and string-keyed input maps for convenience (e.g. JSON payloads come with string keys). The runtime must convert string keys to atoms to match your declared field names — and that conversion is the classic atom-table-exhaustion DoS vector in Elixir.

How GuardedStruct defends — two layers

Layer 1. Parser.convert_to_atom_map/2 uses String.to_existing_atom/1 rather than String.to_atom/1. String keys are converted ONLY if the atom already exists (i.e. matches a field/sub_field/conditional_field declaration elsewhere in your codebase). Unknown / attacker-controlled keys stay as strings — they cannot grow the atom table.

Layer 2. dynamic_field values are identity-preserved — whatever map you submit (string keys, atom keys, mixed, nested) is byte-identical to what comes back from builder/1. No key conversion at any depth.

defmodule Doc do
  use GuardedStruct
  guardedstruct do
    field         :id,       String.t(), enforce: true
    dynamic_field :metadata
  end
end

Doc.builder(%{id: "x", metadata: %{"foo" => 1, :bar => 2, "baz" => %{"nested" => 3}}})
# => {:ok, %Doc{id: "x", metadata: %{"foo" => 1, :bar => 2, "baz" => %{"nested" => 3}}}}
#                                    ↑              ↑           ↑
#                              string stays      atom stays      deep nested string STAYS

How to consume dynamic_field values safely

When the input came from JSON / any untrusted source, your dynamic_field ends up with string keys exactly as the sender wrote them:

def receive(%{"id" => id, "metadata" => meta}) do
  {:ok, doc} = Doc.builder(%{id: id, metadata: meta})
  name = doc.metadata["customer_name"]   # ← read with string keys
  plan = doc.metadata["plan_tier"]
end

If you need atom keys for ergonomics (e.g. doc.metadata.foo dot-access), convert AT THE BOUNDARY where you know which keys are safe:

safe_keys = ~w(customer_name plan_tier signup_source)a   # ← compile-time list

atomized =
  for k <- safe_keys, into: %{} do
    {k, Map.get(doc.metadata, Atom.to_string(k))}
  end

That converts only the keys YOU declared in source — the atom table cannot grow from user input regardless of what the request body contains.

What NOT to do

# ❌ NEVER do this on user-controlled maps:
metadata = doc.metadata |> Map.new(fn {k, v} -> {String.to_atom(k), v} end)
#                                              ^^^^^^^^^^^^^^^^^
#                       creates a new atom from EVERY key the user sent.

The library protects you on the way IN. Don't undo that protection on the way OUT.

Reporting a vulnerability

See SECURITY.md for the security policy and how to report.

Options

  • :extensions (list of module that adopts Spark.Dsl.Extension) - A list of DSL extensions to add to the Spark.Dsl

  • :otp_app (atom/0) - The otp_app to use for any application configurable options

  • :fragments (list of module/0) - Fragments to include in the Spark.Dsl. See the fragments guide for more.

Summary

Functions

Arity-4 wrapper for conditional_field name, type, opts do … end.

guardedstruct do … end — no top-level options.

guardedstruct opts do … end — top-level options like enforce: true or module: Foo are lifted into setter calls inside the section body.

Arity-4 wrapper for sub_field name, type, opts do … end.

Functions

conditional_field(name, type, opts, do_block)

(macro)

Arity-4 wrapper for conditional_field name, type, opts do … end.

guardedstruct(list)

(macro)

guardedstruct do … end — no top-level options.

guardedstruct(opts, list)

(macro)

guardedstruct opts do … end — top-level options like enforce: true or module: Foo are lifted into setter calls inside the section body.

sub_field(name, type, opts, do_block)

(macro)

Arity-4 wrapper for sub_field name, type, opts do … end.