From 658a4d8e1c0e56ef35872f8ab4a3723f002fd16b Mon Sep 17 00:00:00 2001 From: Bruce Williams Date: Thu, 2 Jul 2026 23:52:42 +0000 Subject: [PATCH 1/3] Support string-keyed variable maps, eliminate atom creation - Switches Environment internals from atom-keyed to string-keyed maps. - Atom-keyed input is normalized to strings at the boundary via Atom.to_string/1. The compiler uses an indexed variable pool (:__cel_var_0__, :__cel_var_1__, ...) instead of converting CEL identifier names to atoms. - No atoms are created from either variable data or expression identifiers, preventing atom exhaustion from untrusted input on both paths. This should be backwards-compatible; atom-keyed and string-keyed maps are accepted transparently and the tests still pass, unchanged. Signed-off-by: Bruce Williams --- lib/celixir/compiler.ex | 90 ++++++++++++---------- lib/celixir/compiler/runtime.ex | 127 ++++++++++++++++++++++++++------ lib/celixir/environment.ex | 57 +++++++------- 3 files changed, 187 insertions(+), 87 deletions(-) diff --git a/lib/celixir/compiler.ex b/lib/celixir/compiler.ex index 1fee522..61504c7 100644 --- a/lib/celixir/compiler.ex +++ b/lib/celixir/compiler.ex @@ -46,8 +46,14 @@ defmodule Celixir.Compiler do module_name = :"Celixir.Compiled.#{:erlang.unique_integer([:positive, :monotonic])}" + # Map each free variable name to a positional atom (:__cel_var_0__, :__cel_var_1__, ...). + # This avoids creating atoms from CEL identifier names, which is critical when + # expressions are untrusted (user-authored); unbounded unique identifiers would + # otherwise exhaust the atom table. + var_index_map = free_vars |> Enum.with_index() |> Map.new(fn {name, idx} -> {name, :"__cel_var_#{idx}__"} end) + # Generate the body with free variables inlined - ctx = %{free_vars: MapSet.new(free_vars), bound_vars: MapSet.new(), comp_var_map: %{}} + ctx = %{free_vars: MapSet.new(free_vars), bound_vars: MapSet.new(), comp_var_map: %{}, var_index_map: var_index_map} body = to_quoted(ast, ctx) # Build the pattern-matching function head that extracts variables directly @@ -67,22 +73,22 @@ defmodule Celixir.Compiler do defp build_eval_fn(body, []) do quote do def eval(__cel_env__) do - try do - {:ok, Celixir.unwrap(unquote(body))} - rescue - e in Celixir.EvalError -> {:error, e.message} - end + {:ok, Celixir.unwrap(unquote(body))} + rescue + e in Celixir.EvalError -> {:error, e.message} end end end defp build_eval_fn(body, free_vars) do - # Build a map pattern: %{x: x, y: y, ...} (atom keys) + # Build a map pattern: %{"x" => __cel_var_0__, "y" => __cel_var_1__, ...} + # Uses positional atoms as Elixir AST variable names rather than converting + # CEL identifier strings to atoms. This prevents atom exhaustion when compiling + # untrusted expressions with arbitrary identifier names. var_pairs = - Enum.map(free_vars, fn name -> - atom = String.to_atom(name) - var = Macro.var(atom, __MODULE__) - {atom, var} + Enum.with_index(free_vars, fn name, idx -> + var = Macro.var(:"__cel_var_#{idx}__", __MODULE__) + {name, var} end) vars_map_pattern = {:%{}, [], var_pairs} @@ -95,12 +101,11 @@ defmodule Celixir.Compiler do # Fallback when a free variable is missing from the environment missing_check = Enum.reduce(Enum.reverse(free_vars), nil, fn name, acc -> - atom = String.to_atom(name) if acc == nil do quote do: {:error, "undefined variable: #{unquote(name)}"} else quote do - if Map.has_key?(__cel_env__.variables, unquote(atom)) do + if Map.has_key?(__cel_env__.variables, unquote(name)) do unquote(acc) else {:error, "undefined variable: #{unquote(name)}"} @@ -111,11 +116,9 @@ defmodule Celixir.Compiler do quote do def eval(unquote(env_pattern)) do - try do - {:ok, Celixir.unwrap(unquote(body))} - rescue - e in Celixir.EvalError -> {:error, e.message} - end + {:ok, Celixir.unwrap(unquote(body))} + rescue + e in Celixir.EvalError -> {:error, e.message} end def eval(__cel_env__) do @@ -285,13 +288,15 @@ defmodule Celixir.Compiler do _ -> # If target is a qualified chain, extract the base ident name case extract_base_name(target) do - "" -> namespace_base_idents(target, acc) + "" -> + namespace_base_idents(target, acc) + base_name when base_name != nil -> # Only add if extract_qualified_name returns non-nil (it's a chain) - if extract_qualified_name(target) != nil do - MapSet.put(acc, base_name) - else + if extract_qualified_name(target) == nil do namespace_base_idents(target, acc) + else + MapSet.put(acc, base_name) end end end @@ -336,6 +341,7 @@ defmodule Celixir.Compiler do Enum.reduce(entries, acc, fn {:optional, k, v}, a -> a |> then(&namespace_base_idents(k, &1)) |> then(&namespace_base_idents(v, &1)) + {k, v}, a -> a |> then(&namespace_base_idents(k, &1)) |> then(&namespace_base_idents(v, &1)) end) @@ -416,12 +422,14 @@ defmodule Celixir.Compiler do Map.has_key?(ctx.comp_var_map, name) -> Macro.var(Map.get(ctx.comp_var_map, name), __MODULE__) - # Free variable inlining — pattern-matched from the struct, no lookup needed + # Free variable inlining — reference the positional variable bound in the + # pattern-matched function head (e.g. :__cel_var_0__ for the first free var). + # No atoms are created from the CEL identifier name. MapSet.member?(ctx.free_vars, name) and not MapSet.member?(ctx.bound_vars, name) -> - Macro.var(String.to_atom(name), __MODULE__) + Macro.var(Map.fetch!(ctx.var_index_map, name), __MODULE__) true -> - quote do: Celixir.Compiler.Runtime.lookup(__cel_env__, unquote(String.to_atom(name))) + quote do: Celixir.Compiler.Runtime.lookup(__cel_env__, unquote(name)) end end @@ -591,9 +599,15 @@ defmodule Celixir.Compiler do quote do case unquote(cq) do - true -> unquote(tq) - false -> unquote(fq) - v -> raise Celixir.EvalError, message: "ternary condition must be bool, got #{Celixir.Compiler.Runtime.cel_typeof(v)}" + true -> + unquote(tq) + + false -> + unquote(fq) + + v -> + raise Celixir.EvalError, + message: "ternary condition must be bool, got #{Celixir.Compiler.Runtime.cel_typeof(v)}" end end end @@ -707,12 +721,12 @@ defmodule Celixir.Compiler do MapSet.member?(ctx.free_vars, base_name) or Map.has_key?(ctx.comp_var_map, base_name) if qname && not is_known_var do - base_atom = String.to_atom(base_name) quote do - case Celixir.Compiler.Runtime.lookup_opt(__cel_env__, unquote(base_atom)) do + case Celixir.Compiler.Runtime.lookup_opt(__cel_env__, unquote(base_name)) do :error -> # Base name is not a variable — try as a qualified function call Celixir.Compiler.Runtime.call(__cel_env__, unquote(qname), [unquote_splicing(args_quoted)]) + {:ok, _} -> # Base name is a variable — evaluate full target and call method Celixir.Compiler.Runtime.method(__cel_env__, unquote(name), unquote(tq), [unquote_splicing(args_quoted)]) @@ -762,11 +776,10 @@ defmodule Celixir.Compiler do if filter_expr do fq_body = to_quoted(filter_expr, inner_ctx) quote do: fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(fq_body) end - else - nil end - quote do: {:transform_map, fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(tq) end, unquote(fq)} + quote do: + {:transform_map, fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(tq) end, unquote(fq)} {:transform_map_entry, transform_expr, filter_expr} -> tq = to_quoted(transform_expr, inner_ctx) @@ -775,11 +788,11 @@ defmodule Celixir.Compiler do if filter_expr do fq_body = to_quoted(filter_expr, inner_ctx) quote do: fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(fq_body) end - else - nil end - quote do: {:transform_map_entry, fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(tq) end, unquote(fq)} + quote do: + {:transform_map_entry, fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(tq) end, + unquote(fq)} {:sort_by, key_expr} -> kq = to_quoted(key_expr, inner_ctx) @@ -792,11 +805,10 @@ defmodule Celixir.Compiler do if filter_expr do fq_body = to_quoted(filter_expr, inner_ctx) quote do: fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(fq_body) end - else - nil end - quote do: {:collect_list, fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(tq) end, unquote(fq)} + quote do: + {:collect_list, fn __cel_env__, unquote(v1), unquote(v2), unquote(acc) -> unquote(tq) end, unquote(fq)} :standard -> quote do: :standard diff --git a/lib/celixir/compiler/runtime.ex b/lib/celixir/compiler/runtime.ex index 13cfa74..717899d 100644 --- a/lib/celixir/compiler/runtime.ex +++ b/lib/celixir/compiler/runtime.ex @@ -9,22 +9,22 @@ defmodule Celixir.Compiler.Runtime do alias Celixir.Types.Optional @type_denotations %{ - bool: :bool, - int: :int, - uint: :uint, - double: :double, - string: :string, - bytes: :bytes, - list: :list, - map: :map, - type: :type, - null_type: :null_type, - optional_type: :optional_type + "bool" => :bool, + "int" => :int, + "uint" => :uint, + "double" => :double, + "string" => :string, + "bytes" => :bytes, + "list" => :list, + "map" => :map, + "type" => :type, + "null_type" => :null_type, + "optional_type" => :optional_type } # --- Variable resolution --- - def lookup(env, name) when is_atom(name) do + def lookup(env, name) when is_binary(name) do case Environment.get_variable(env, name) do {:ok, value} -> normalize_value(value) @@ -37,10 +37,16 @@ defmodule Celixir.Compiler.Runtime do end end + def lookup(env, name) when is_atom(name) do + lookup(env, Atom.to_string(name)) + end + # Returns {:ok, value} | :error — never raises. Used for qualified method dispatch. - def lookup_opt(env, name) when is_atom(name) do + def lookup_opt(env, name) when is_binary(name) do case Environment.get_variable(env, name) do - {:ok, value} -> {:ok, normalize_value(value)} + {:ok, value} -> + {:ok, normalize_value(value)} + :error -> case Map.get(@type_denotations, name) do nil -> :error @@ -49,6 +55,10 @@ defmodule Celixir.Compiler.Runtime do end end + def lookup_opt(env, name) when is_atom(name) do + lookup_opt(env, Atom.to_string(name)) + end + # Fast path: primitive types don't need normalization defp normalize_value(v) when is_integer(v), do: v defp normalize_value(v) when is_float(v), do: v @@ -156,7 +166,7 @@ defmodule Celixir.Compiler.Runtime do # --- Field selection --- - def select(env, operand_val, field, nil, test_only) do + def select(_env, operand_val, field, nil, test_only) do cel_raise(Celixir.Evaluator.dispatch_select(operand_val, field, test_only)) end @@ -199,10 +209,13 @@ defmodule Celixir.Compiler.Runtime do Enum.reduce(entries, %{}, fn {:__cel_opt_entry__, k, %Optional{has_value: true, value: v}}, acc -> do_add_map_entry!(k, v, acc) + {:__cel_opt_entry__, _k, %Optional{has_value: false}}, acc -> acc + {:__cel_opt_entry__, k, v}, acc -> do_add_map_entry!(k, v, acc) + {:__cel_entry__, k, v}, acc -> do_add_map_entry!(k, v, acc) end) @@ -229,8 +242,7 @@ defmodule Celixir.Compiler.Runtime do cel_raise(Celixir.Proto.finalize_struct(qualified_name, fields)) end - defp qualify_struct_type(type_name, %{container: container}) - when is_binary(container) and container != "" do + defp qualify_struct_type(type_name, %{container: container}) when is_binary(container) and container != "" do qualified = container <> "." <> type_name if Celixir.Proto.get_schema(qualified), do: qualified, else: type_name end @@ -256,7 +268,18 @@ defmodule Celixir.Compiler.Runtime do end # Fast path: list range, no iter_var2, collect_list — prepend + reverse, O(n) - defp run_comprehension(env, range, _iter_var, nil, _acc_var, _acc_init, _loop_cond_f, _loop_step_f, result_f, {:collect_list, transform_f, filter_f}) + defp run_comprehension( + env, + range, + _iter_var, + nil, + _acc_var, + _acc_init, + _loop_cond_f, + _loop_step_f, + result_f, + {:collect_list, transform_f, filter_f} + ) when is_list(range) do acc = Enum.reduce(range, [], fn v1, acc -> @@ -306,7 +329,18 @@ defmodule Celixir.Compiler.Runtime do defp item_v2({_v1}), do: nil defp item_v2({_v1, v2}), do: v2 - defp run_comprehension_items(env, items, _iter_var, _iter_var2, _acc_var, acc_init, loop_cond_f, loop_step_f, result_f, :standard) do + defp run_comprehension_items( + env, + items, + _iter_var, + _iter_var2, + _acc_var, + acc_init, + loop_cond_f, + loop_step_f, + result_f, + :standard + ) do final_acc = Enum.reduce_while(items, acc_init, fn item, current_acc -> v1 = item_v1(item) @@ -322,7 +356,18 @@ defmodule Celixir.Compiler.Runtime do result_f.(env, final_acc) end - defp run_comprehension_items(env, items, _iter_var, _iter_var2, _acc_var, _acc_init, _loop_cond_f, _loop_step_f, result_f, {:collect_list, transform_f, filter_f}) do + defp run_comprehension_items( + env, + items, + _iter_var, + _iter_var2, + _acc_var, + _acc_init, + _loop_cond_f, + _loop_step_f, + result_f, + {:collect_list, transform_f, filter_f} + ) do acc = Enum.reduce(items, [], fn item, acc -> v1 = item_v1(item) @@ -338,7 +383,18 @@ defmodule Celixir.Compiler.Runtime do result_f.(env, Enum.reverse(acc)) end - defp run_comprehension_items(env, items, _iter_var, _iter_var2, _acc_var, acc_init, _loop_cond_f, _loop_step_f, result_f, {:transform_map, transform_f, filter_f}) do + defp run_comprehension_items( + env, + items, + _iter_var, + _iter_var2, + _acc_var, + acc_init, + _loop_cond_f, + _loop_step_f, + result_f, + {:transform_map, transform_f, filter_f} + ) do final_acc = Enum.reduce(items, acc_init, fn {key, _value} = item, current_acc -> v1 = item_v1(item) @@ -354,7 +410,18 @@ defmodule Celixir.Compiler.Runtime do result_f.(env, final_acc) end - defp run_comprehension_items(env, items, _iter_var, _iter_var2, _acc_var, acc_init, _loop_cond_f, _loop_step_f, result_f, {:transform_map_entry, transform_f, filter_f}) do + defp run_comprehension_items( + env, + items, + _iter_var, + _iter_var2, + _acc_var, + acc_init, + _loop_cond_f, + _loop_step_f, + result_f, + {:transform_map_entry, transform_f, filter_f} + ) do final_acc = Enum.reduce(items, acc_init, fn item, current_acc -> v1 = item_v1(item) @@ -366,9 +433,11 @@ defmodule Celixir.Compiler.Runtime do case entry do e when is_map(e) and not is_struct(e) and map_size(e) == 1 -> [{new_key, new_val}] = Map.to_list(e) + if Map.has_key?(current_acc, new_key) do raise EvalError, message: "transformMapEntry: duplicate key" end + Map.put(current_acc, new_key, new_val) _ -> @@ -382,7 +451,18 @@ defmodule Celixir.Compiler.Runtime do result_f.(env, final_acc) end - defp run_comprehension_items(env, items, _iter_var, _iter_var2, _acc_var, acc_init, _loop_cond_f, _loop_step_f, result_f, {:sort_by, key_f}) do + defp run_comprehension_items( + env, + items, + _iter_var, + _iter_var2, + _acc_var, + acc_init, + _loop_cond_f, + _loop_step_f, + result_f, + {:sort_by, key_f} + ) do sorted = items |> Enum.map(fn {value} = item -> @@ -403,6 +483,7 @@ defmodule Celixir.Compiler.Runtime do def opt_lambda(env, %Optional{has_value: true, value: v}, var, kind, expr_f) do inner_env = Environment.put_variable(env, var, v) result = expr_f.(inner_env) + case kind do :flat_map -> result :map -> Optional.of(result) diff --git a/lib/celixir/environment.ex b/lib/celixir/environment.ex index 9f7d13e..9834b9d 100644 --- a/lib/celixir/environment.ex +++ b/lib/celixir/environment.ex @@ -47,15 +47,21 @@ defmodule Celixir.Environment do Celixir.eval!("format.currency(price, 'USD')", env) """ - defstruct variables: %{}, functions: %{}, type_adapter: nil, container: nil, container_prefixes: [], locals: %{}, private: %{} + defstruct variables: %{}, + functions: %{}, + type_adapter: nil, + container: nil, + container_prefixes: [], + locals: %{}, + private: %{} @type t :: %__MODULE__{ - variables: %{atom() => any()}, + variables: %{String.t() => any()}, functions: %{String.t() => function()}, type_adapter: module() | nil, container: String.t() | nil, container_prefixes: [String.t()], - locals: %{atom() => any()}, + locals: %{String.t() => any()}, private: %{any() => any()} } @@ -64,23 +70,23 @@ defmodule Celixir.Environment do @doc "Creates an environment with the given variable bindings." def new(variables) when is_map(variables) do - %__MODULE__{variables: maybe_atomize_keys(variables)} + %__MODULE__{variables: normalize_keys(variables)} end @doc "Adds a variable binding." def put_variable(%__MODULE__{} = env, name, value) do - %{env | variables: Map.put(env.variables, to_atom(name), value)} + %{env | variables: Map.put(env.variables, to_string_key(name), value)} end @doc "Adds a local variable binding that shadows container-resolved and outer names." def put_local(%__MODULE__{} = env, name, value) do - %{env | locals: Map.put(env.locals, to_atom(name), value)} + %{env | locals: Map.put(env.locals, to_string_key(name), value)} end @doc "Adds multiple local variable bindings at once (single struct copy instead of N copies)." def put_locals_bulk(%__MODULE__{} = env, new_locals) when is_map(new_locals) do - atomized = Map.new(new_locals, fn {k, v} -> {to_atom(k), v} end) - %{env | locals: Map.merge(env.locals, atomized)} + stringified = Map.new(new_locals, fn {k, v} -> {to_string_key(k), v} end) + %{env | locals: Map.merge(env.locals, stringified)} end @doc "Sets the container (namespace) for identifier resolution." @@ -96,26 +102,27 @@ defmodule Celixir.Environment do def get_variable(%__MODULE__{} = env, "." <> rest) do # Absolute: bypass locals and container, look up in outer variables only bare = String.trim_leading(rest, ".") - Map.fetch(env.variables, String.to_atom(bare)) + Map.fetch(env.variables, bare) end def get_variable(%__MODULE__{} = env, name) when is_atom(name) do - case Map.fetch(env.locals, name) do + str = Atom.to_string(name) + + case Map.fetch(env.locals, str) do {:ok, _} = ok -> ok - :error -> resolve_with_container(env, name) + :error -> resolve_with_container(env, str) end end def get_variable(%__MODULE__{} = env, name) when is_binary(name) do - atom = String.to_atom(name) - case Map.fetch(env.locals, atom) do + case Map.fetch(env.locals, name) do {:ok, _} = ok -> ok - :error -> resolve_with_container(env, atom) + :error -> resolve_with_container(env, name) end end @doc "Checks if a variable name is locally bound (e.g., comprehension iter var)." - def local?(env, name), do: Map.has_key?(env.locals, to_atom(name)) + def local?(env, name), do: Map.has_key?(env.locals, to_string_key(name)) defp resolve_with_container(%{container: nil} = env, name) do Map.fetch(env.variables, name) @@ -123,9 +130,9 @@ defmodule Celixir.Environment do defp resolve_with_container(env, name) do # Try progressively shorter container prefixes: com.example.y, com.y, y - name_str = Atom.to_string(name) Enum.find_value(env.container_prefixes, fn prefix -> - qualified = String.to_atom(prefix <> "." <> name_str) + qualified = prefix <> "." <> name + case Map.fetch(env.variables, qualified) do {:ok, _} = ok -> ok :error -> nil @@ -199,17 +206,17 @@ defmodule Celixir.Environment do %{env | type_adapter: adapter} end - defp to_atom(name) when is_atom(name), do: name - defp to_atom(name), do: String.to_atom(to_string(name)) + defp to_string_key(name) when is_binary(name), do: name + defp to_string_key(name) when is_atom(name), do: Atom.to_string(name) - defp maybe_atomize_keys(map) when map_size(map) == 0, do: %{} + defp normalize_keys(map) when map_size(map) == 0, do: %{} - defp maybe_atomize_keys(map) do - # Fast path: if the first key is already an atom, assume all are (common case - # when the caller passes %{x: 5, y: 3} with atom keys). + defp normalize_keys(map) do + # Fast path: if the first key is already a string, assume all are (common case + # when the caller passes string-keyed maps from JSON/database). case :maps.next(:maps.iterator(map)) do - {k, _v, _rest} when is_atom(k) -> map - _ -> Map.new(map, fn {k, v} -> {String.to_atom(to_string(k)), v} end) + {k, _v, _rest} when is_binary(k) -> map + _ -> Map.new(map, fn {k, v} -> {to_string_key(k), v} end) end end end From 8bb440d78421d11ce0e88eba479c7cffd6fbd5fc Mon Sep 17 00:00:00 2001 From: Bruce Williams Date: Thu, 2 Jul 2026 23:58:06 +0000 Subject: [PATCH 2/3] Add tests for string-keyed variable maps Verifies that string-keyed maps work end-to-end through eval, compile, to_fun, comprehensions, and container resolution. Signed-off-by: Bruce Williams --- test/string_keys_test.exs | 170 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 test/string_keys_test.exs diff --git a/test/string_keys_test.exs b/test/string_keys_test.exs new file mode 100644 index 0000000..ef28149 --- /dev/null +++ b/test/string_keys_test.exs @@ -0,0 +1,170 @@ +defmodule Celixir.StringKeysTest do + use ExUnit.Case, async: true + + alias Celixir.Environment + + describe "Celixir.eval/2 with string-keyed maps" do + test "simple variable lookup" do + assert {:ok, "high"} = Celixir.eval("severity", %{"severity" => "high"}) + end + + test "comparison expression" do + assert {:ok, true} = Celixir.eval("severity == 'high' && count > 2", %{"severity" => "high", "count" => 3}) + end + + test "arithmetic" do + assert {:ok, 15} = Celixir.eval("x * y + z", %{"x" => 2, "y" => 5, "z" => 5}) + end + + test "string functions" do + assert {:ok, true} = Celixir.eval("name.startsWith('hello')", %{"name" => "hello world"}) + end + + test "ternary" do + assert {:ok, "big"} = Celixir.eval("x > 10 ? 'big' : 'small'", %{"x" => 42}) + end + + test "list operations" do + assert {:ok, 3} = Celixir.eval("items.size()", %{"items" => [1, 2, 3]}) + end + + test "map access" do + assert {:ok, "bar"} = Celixir.eval("data['foo']", %{"data" => %{"foo" => "bar"}}) + end + + test "nested map field access" do + assert {:ok, 42} = Celixir.eval("config.timeout", %{"config" => %{"timeout" => 42}}) + end + + test "undefined variable returns error" do + assert {:error, "undefined variable: missing"} = Celixir.eval("missing", %{"other" => 1}) + end + end + + describe "backward compatibility with atom-keyed maps" do + test "atom-keyed map still works" do + assert {:ok, true} = Celixir.eval("severity == 'high'", %{severity: "high"}) + end + + test "mixed usage in sequence" do + expr = "x + y" + assert {:ok, 3} = Celixir.eval(expr, %{x: 1, y: 2}) + assert {:ok, 7} = Celixir.eval(expr, %{"x" => 3, "y" => 4}) + end + end + + describe "Environment.new/1 with string keys" do + test "accepts string-keyed map directly" do + env = Environment.new(%{"name" => "alice", "age" => 30}) + assert {:ok, "alice"} = Environment.get_variable(env, "name") + assert {:ok, 30} = Environment.get_variable(env, "age") + end + + test "accepts atom-keyed map (backward compatible)" do + env = Environment.new(%{name: "bob"}) + assert {:ok, "bob"} = Environment.get_variable(env, "name") + end + + test "empty map" do + env = Environment.new(%{}) + assert :error = Environment.get_variable(env, "x") + end + end + + describe "Environment.put_variable/3 with string names" do + test "string name" do + env = Environment.put_variable(Environment.new(), "score", 100) + assert {:ok, 100} = Environment.get_variable(env, "score") + end + + test "atom name (backward compatible)" do + env = Environment.put_variable(Environment.new(), :score, 100) + assert {:ok, 100} = Environment.get_variable(env, "score") + end + end + + describe "comprehensions with string-keyed variables" do + test "filter" do + assert {:ok, [3, 4, 5]} = + Celixir.eval("[1, 2, 3, 4, 5].filter(x, x > 2)", %{}) + end + + test "map" do + assert {:ok, [2, 4, 6]} = + Celixir.eval("[1, 2, 3].map(x, x * 2)", %{}) + end + + test "exists with string-keyed variable" do + assert {:ok, true} = + Celixir.eval("items.exists(x, x > threshold)", %{"items" => [1, 2, 3], "threshold" => 2}) + end + + test "all with string-keyed variable" do + assert {:ok, true} = + Celixir.eval("scores.all(s, s >= min_score)", %{"scores" => [80, 90, 95], "min_score" => 70}) + end + end + + describe "custom functions with string-keyed variables" do + test "custom function receives string-keyed variable values" do + env = + %{"name" => "world"} + |> Environment.new() + |> Environment.put_function("greet", fn name -> "Hello, #{name}!" end) + + assert {:ok, "Hello, world!"} = Celixir.eval("greet(name)", env) + end + end + + describe "Celixir.Program.eval/2 with string-keyed bindings" do + test "compiled program with string keys" do + {:ok, program} = Celixir.compile("x * 2 + y") + assert {:ok, 11} = Celixir.Program.eval(program, %{"x" => 5, "y" => 1}) + assert {:ok, 23} = Celixir.Program.eval(program, %{"x" => 10, "y" => 3}) + end + + test "compiled program with atom keys (backward compatible)" do + {:ok, program} = Celixir.compile("x + y") + assert {:ok, 3} = Celixir.Program.eval(program, %{x: 1, y: 2}) + end + + test "compiled program reusable across key types" do + {:ok, program} = Celixir.compile("a > b") + assert {:ok, true} = Celixir.Program.eval(program, %{a: 5, b: 3}) + assert {:ok, false} = Celixir.Program.eval(program, %{"a" => 1, "b" => 10}) + end + end + + describe "to_fun!/1 with string-keyed maps" do + test "accepts string-keyed map" do + fun = Celixir.to_fun!("age >= 18 && status == 'active'") + assert {:ok, true} = fun.(%{"age" => 25, "status" => "active"}) + assert {:ok, false} = fun.(%{"age" => 15, "status" => "active"}) + end + + test "accepts atom-keyed map (backward compatible)" do + fun = Celixir.to_fun!("x + 1") + assert {:ok, 11} = fun.(%{x: 10}) + end + end + + describe "container resolution with string-keyed variables" do + test "resolves qualified names against string-keyed variables" do + env = + %{"com.example.x" => 42} + |> Environment.new() + |> Environment.set_container("com.example") + + assert {:ok, 42} = Celixir.eval("x", env) + end + + test "falls back to unqualified name" do + env = + %{"x" => 99} + |> Environment.new() + |> Environment.set_container("com.example") + + assert {:ok, 99} = Celixir.eval("x", env) + end + end +end From 0093baff45b1c82b6ee0be6cf3df499aca2ae62c Mon Sep 17 00:00:00 2001 From: Bruce Williams Date: Fri, 3 Jul 2026 00:06:10 +0000 Subject: [PATCH 3/3] Update documentation Adds examples of string-keyed variable bindings, reasoning. Signed-off-by: Bruce Williams --- README.md | 3 +++ lib/celixir.ex | 9 +++++++++ lib/celixir/environment.ex | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/README.md b/README.md index 34d18a7..6c3881b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ Celixir.eval!("'hello' + ' ' + 'world'") # => "hello world" # Variable bindings Celixir.eval!("age >= 18", %{age: 21}) # => true +# String-keyed maps (recommended for untrusted data; no atoms created) +Celixir.eval!("severity == 'high'", %{"severity" => "high"}) # => true + # Complex expressions Celixir.eval!( "request.method == 'GET' && resource.public", diff --git a/lib/celixir.ex b/lib/celixir.ex index 05aeabf..22558d2 100644 --- a/lib/celixir.ex +++ b/lib/celixir.ex @@ -17,6 +17,12 @@ defmodule Celixir do iex> Celixir.eval("x > 10 ? 'big' : 'small'", %{x: 42}) {:ok, "big"} + String-keyed maps are supported directly, avoiding atom creation from + untrusted input (e.g., JSON from user submissions or databases): + + iex> Celixir.eval("severity == 'high'", %{"severity" => "high"}) + {:ok, true} + ## Compile Once, Evaluate Many {:ok, program} = Celixir.compile("x * 2 + y") @@ -138,6 +144,9 @@ defmodule Celixir do iex> Celixir.eval("x > 0", %{x: 5}) {:ok, true} + iex> Celixir.eval("severity == 'high'", %{"severity" => "high"}) + {:ok, true} + iex> Celixir.eval("undefined_var") {:error, "undefined variable: undefined_var"} """ diff --git a/lib/celixir/environment.ex b/lib/celixir/environment.ex index 9834b9d..6a793fa 100644 --- a/lib/celixir/environment.ex +++ b/lib/celixir/environment.ex @@ -5,6 +5,14 @@ defmodule Celixir.Environment do ## Building an environment + Both atom-keyed and string-keyed maps are accepted. String keys are recommended + when variable data comes from untrusted sources (JSON, user input, databases) + to avoid atom exhaustion. + + # String-keyed (recommended for untrusted data) + env = Celixir.Environment.new(%{"severity" => "high", "count" => 3}) + + # Atom-keyed (convenient for trusted, developer-defined bindings) env = Celixir.Environment.new(%{x: 10, name: "alice"}) ## Registering custom functions