From 1f7697b286d1612ba9413747f83ea2c91d8f9402 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 11 Oct 2026 03:14:06 -0600 Subject: [PATCH] Refuses expression keywords as payload paths The typed send step compiles each payload entry into a param whose expr is the entry itself, and its path pattern admitted bare words such as true, which the expression language reads as a literal. An entry that names a keyword of the expression language is now refused, as a first segment and as a later one, except the duration words the language reads as a property name after a dot. The payload test now says listed path: the step does not check whether a path is declared. A new test cross-checks the step against the locked expression language's parser. --- lib/statifier_examples/typed_send_step.ex | 34 +++++++++++-- .../typed_send_step_test.exs | 49 ++++++++++++++++++- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/lib/statifier_examples/typed_send_step.ex b/lib/statifier_examples/typed_send_step.ex index f922eb6..43d3677 100644 --- a/lib/statifier_examples/typed_send_step.ex +++ b/lib/statifier_examples/typed_send_step.ex @@ -42,10 +42,17 @@ defmodule StatifierExamples.TypedSendStep do `payload` is a list of datamodel paths: dotted identifiers such as `loan.id`, each named once. `validate_config/1` refuses an entry that is - not one, and a duplicate. It names paths and never literal values: what - reaches the receiver is whatever the chart's datamodel holds at those - paths, so a chart that holds only ids sends only ids, and the receiver - reads the rest by them. + not one, and a duplicate. It also refuses an entry that names a keyword + of the expression language a ``'s `expr` is read in: `true`, + `false`, `null` or `undefined` would hand the receiver a literal rather + than the value at a path, and an operator or a statement word is no + expression at all. A keyword is refused as a path's first segment and + as a later one, except the five duration words (`ago`, `from`, `now`, + `next`, `last`), which the language reads as a property name after a + dot, so `patron.last` stays a path. It names paths and never literal + values: what reaches the receiver is whatever the chart's datamodel + holds at those paths, so a chart that holds only ids sends only ids, and + the receiver reads the rest by them. Whether a path is *declared* in the document's datamodel is not something this type can refuse: `validate_config/1` is handed the @@ -72,6 +79,16 @@ defmodule StatifierExamples.TypedSendStep do @path ~r/\A[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*\z/ + # The words the expression language (predicator) reads as something other + # than a name: its literals, its operators in both cases, its statement + # words and its duration words. No path segment may be one of them, except + # that a segment after a dot may be a duration word, which the language + # reads as a property name there. + @keywords ~w(true false null undefined and or not in contains AND OR NOT IN CONTAINS) ++ + ~w(if else while ago from now next last) + + @property_words ~w(ago from now next last) + @doc "The name this app registers the type under." @spec type_name() :: String.t() def type_name, do: @type_name @@ -177,6 +194,9 @@ defmodule StatifierExamples.TypedSendStep do not Enum.all?(payload, &path?/1) -> [{@payload, "every entry must be a datamodel path, like loan.id"}] + Enum.any?(payload, &keyword?/1) -> + [{@payload, "must not name a keyword of the expression language, like true"}] + length(Enum.uniq(payload)) != length(payload) -> [{@payload, "names a path more than once"}] @@ -189,5 +209,11 @@ defmodule StatifierExamples.TypedSendStep do defp path?(value), do: is_binary(value) and Regex.match?(@path, value) + @spec keyword?(String.t()) :: boolean() + defp keyword?(path) do + [first | rest] = String.split(path, ".") + first in @keywords or Enum.any?(rest, &(&1 in @keywords and &1 not in @property_words)) + end + defp filled?(value), do: is_binary(value) and value != "" end diff --git a/test/statifier_examples/typed_send_step_test.exs b/test/statifier_examples/typed_send_step_test.exs index ab05598..70ad561 100644 --- a/test/statifier_examples/typed_send_step_test.exs +++ b/test/statifier_examples/typed_send_step_test.exs @@ -30,7 +30,7 @@ defmodule StatifierExamples.TypedSendStepTest do # Sabotage: emitted the payload's params in reverse; this went red on the # param order, then reverted. - test "with a payload the compiled send carries one param per declared path, in order" do + test "with a payload the compiled send carries one param per listed path, in order" do config = Map.put(@config, "payload", ["loan.id", "loan.copy_id"]) assert send_element(config) == @@ -53,6 +53,46 @@ defmodule StatifierExamples.TypedSendStepTest do assert :ok = TypedSendStep.validate_config(Map.put(@config, "payload", ["loan.id", "_x"])) end + # Sabotage: dropped the keyword branch from payload_findings/1; this went + # red on "true", then reverted. + test "a payload entry that names a keyword of the expression language is refused" do + for payload <- [["true"], ["false"], ["null"], ["undefined"], ["true.id"], ["loan.null"]] do + assert {:error, + [{"payload", "must not name a keyword of the expression language, like true"}]} = + TypedSendStep.validate_config(Map.put(@config, "payload", payload)) + end + + assert :ok = + TypedSendStep.validate_config(Map.put(@config, "payload", ["patron.last", "nil"])) + end + + # Every word the expression language reserves, written as a path's first + # segment, as a middle one and as the last, beside ordinary names. The + # step admits an entry exactly when the locked predicator reads it as a + # datamodel path: an identifier followed by property names, never a + # literal, an operator or a parse error. `loan.last` is a path (a property + # may be named by one of the duration words) and `nil` is one too (the + # language spells its null `null`). + # Sabotage: dropped the later-segment check from keyword?/1; this went red + # on "loan.true", then reverted. Dropping the duration-word exemption went + # red on "loan.ago", and checking only the last segment went red on + # "loan.true.id"; each was reverted. + test "a payload entry is admitted exactly when the expression language reads it as a path" do + words = + ~w(true false null undefined and or not in contains if else while) ++ + ~w(AND OR NOT IN CONTAINS ago from now next last) ++ + ~w(loan id nil TRUE Null _x) + + for word <- words, + entry <- [word, "loan." <> word, word <> ".id", "loan." <> word <> ".id"] do + admitted? = TypedSendStep.validate_config(Map.put(@config, "payload", [entry])) == :ok + + assert admitted? == datamodel_path?(Predicator.parse(entry)), + "#{inspect(entry)}: the step answered admitted?=#{admitted?}, " <> + "the expression language reads #{inspect(Predicator.parse(entry))}" + end + end + # Sabotage: dropped "event" from @keys; this went red on the missing # finding, then reverted. test "type, target and event are each the author's to set" do @@ -62,6 +102,13 @@ defmodule StatifierExamples.TypedSendStepTest do end end + defp datamodel_path?({:ok, {:identifier, _name, _pos}}), do: true + + defp datamodel_path?({:ok, {:property_access, inner, _name, _pos}}), + do: datamodel_path?({:ok, inner}) + + defp datamodel_path?(_other), do: false + defp send_element(config) do document = %{ "schema_version" => 1,