Skip to content

Commit 7de3d9e

Browse files
fix: refuse an invalid range bounds on every path that accepts one (#2880)
1 parent cb7d027 commit 7de3d9e

2 files changed

Lines changed: 104 additions & 10 deletions

File tree

lib/ash/type/range.ex

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,13 @@ defmodule Ash.Type.Range do
167167
def dump_to_native(%Range{empty?: true}, _constraints), do: {:ok, Range.empty()}
168168

169169
def dump_to_native(%Range{lower: lower, upper: upper, bounds: bounds}, constraints) do
170-
with {:ok, lower} <- dump_bound(lower, constraints),
171-
{:ok, upper} <- dump_bound(upper, constraints) do
172-
{:ok, %Range{lower: lower, upper: upper, bounds: bounds}}
170+
if Range.valid_bounds?(bounds) do
171+
with {:ok, lower} <- dump_bound(lower, constraints),
172+
{:ok, upper} <- dump_bound(upper, constraints) do
173+
{:ok, %Range{lower: lower, upper: upper, bounds: bounds}}
174+
end
175+
else
176+
:error
173177
end
174178
end
175179

@@ -178,16 +182,24 @@ defmodule Ash.Type.Range do
178182
@impl true
179183
def apply_constraints(nil, _constraints), do: {:ok, nil}
180184

185+
def apply_constraints(%Range{bounds: bounds} = range, constraints) do
186+
if Range.valid_bounds?(bounds) do
187+
do_apply_constraints(range, constraints)
188+
else
189+
{:error, message: "range bounds must be a valid bounds specifier"}
190+
end
191+
end
192+
181193
# An empty range is constructed, not mistyped, so it is refused rather than nulled.
182-
def apply_constraints(%Range{empty?: true} = range, constraints) do
194+
defp do_apply_constraints(%Range{empty?: true} = range, constraints) do
183195
if Keyword.get(constraints, :allow_empty?, false) do
184196
{:ok, range}
185197
else
186198
{:error, message: "range must not be empty"}
187199
end
188200
end
189201

190-
def apply_constraints(%Range{lower: lower, upper: upper} = range, constraints) do
202+
defp do_apply_constraints(%Range{lower: lower, upper: upper} = range, constraints) do
191203
type = constraints[:inner_type]
192204
inner = constraints[:inner_constraints] || []
193205

@@ -198,7 +210,7 @@ defmodule Ash.Type.Range do
198210
:ok <- check_bound(:lower, range, constraints[:lower] || []),
199211
:ok <- check_bound(:upper, range, constraints[:upper] || []) do
200212
# Canonicalizing can empty a range, so the empty rule is applied to the result.
201-
if range.empty?, do: apply_constraints(range, constraints), else: {:ok, range}
213+
if range.empty?, do: do_apply_constraints(range, constraints), else: {:ok, range}
202214
end
203215
end
204216

@@ -325,7 +337,9 @@ defmodule Ash.Type.Range do
325337
end
326338

327339
defp extract(%Range{lower: lower, upper: upper, bounds: bounds, empty?: empty?}) do
328-
{:ok, lower, upper, normalize_bounds(bounds), empty?}
340+
with {:ok, bounds} <- normalize_bounds(bounds) do
341+
{:ok, lower, upper, bounds, empty?}
342+
end
329343
end
330344

331345
defp extract({lower, upper}), do: {:ok, lower, upper, :"[)", false}
@@ -335,13 +349,27 @@ defmodule Ash.Type.Range do
335349
upper = map[:upper] || map["upper"]
336350
bounds = map[:bounds] || map["bounds"] || :"[)"
337351
empty? = map[:empty?] || map["empty?"] || false
338-
{:ok, lower, upper, normalize_bounds(bounds), empty?}
352+
353+
with {:ok, bounds} <- normalize_bounds(bounds) do
354+
{:ok, lower, upper, bounds, empty?}
355+
end
339356
end
340357

341358
defp extract(_), do: {:error, "is not a valid range"}
342359

343-
defp normalize_bounds(bounds) when is_atom(bounds), do: bounds
344-
defp normalize_bounds(bounds) when is_binary(bounds), do: String.to_existing_atom(bounds)
360+
defp normalize_bounds(bounds) when is_atom(bounds) do
361+
if Range.valid_bounds?(bounds), do: {:ok, bounds}, else: bounds_error()
362+
end
363+
364+
defp normalize_bounds(bounds) when is_binary(bounds) do
365+
normalize_bounds(String.to_existing_atom(bounds))
366+
rescue
367+
ArgumentError -> bounds_error()
368+
end
369+
370+
defp normalize_bounds(_), do: bounds_error()
371+
372+
defp bounds_error, do: {:error, "bounds is not a valid bounds specifier"}
345373

346374
defp cast_bound(nil, _fun, _constraints), do: {:ok, nil}
347375

test/type/range_test.exs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ defmodule Ash.Type.RangeTest do
1414
)
1515

1616
{:ok, date_constraints} = Ash.Type.init(Ash.Type.Range, inner_type: :date)
17+
{:ok, int_constraints} = Ash.Type.init(Ash.Type.Range, inner_type: :integer)
1718
@constraints constraints
1819
@date_constraints date_constraints
20+
@int_constraints int_constraints
1921

2022
@lower ~U[2026-01-01 00:00:00.000000Z]
2123
@upper ~U[2026-02-01 00:00:00.000000Z]
@@ -57,6 +59,70 @@ defmodule Ash.Type.RangeTest do
5759
Ash.Type.cast_input(Ash.Type.Range, {@lower, @upper}, @constraints)
5860
end
5961

62+
test "cast_input from a bounds string resolves it to the atom" do
63+
assert {:ok, %Range{lower: @lower, upper: @upper, bounds: :"[]"}} =
64+
Ash.Type.cast_input(
65+
Ash.Type.Range,
66+
%{lower: @lower, upper: @upper, bounds: "[]"},
67+
@constraints
68+
)
69+
end
70+
71+
test "cast_input refuses a bounds that is not a bounds specifier" do
72+
for bounds <- [:not_a_bound, :inclusive_inclusive, :"[x", "nonsense", 5] do
73+
assert {:error, _} =
74+
Ash.Type.cast_input(
75+
Ash.Type.Range,
76+
%{lower: 1, upper: 9, bounds: bounds},
77+
@int_constraints
78+
),
79+
"expected #{inspect(bounds)} to be refused"
80+
end
81+
end
82+
83+
test "an unrecognized bounds is refused rather than read as exclusive at both ends" do
84+
assert {:error, _} =
85+
Ash.Type.cast_input(
86+
Ash.Type.Range,
87+
%Range{lower: 1, upper: 9, bounds: :not_a_bound},
88+
@int_constraints
89+
)
90+
end
91+
92+
test "cast_stored refuses a bounds that is not a bounds specifier" do
93+
assert {:error, _} =
94+
Ash.Type.cast_stored(
95+
Ash.Type.Range,
96+
%{"lower" => 1, "upper" => 9, "bounds" => "nonsense"},
97+
@int_constraints
98+
)
99+
end
100+
101+
test "dump_to_native refuses a bounds that is not a bounds specifier" do
102+
assert :error =
103+
Ash.Type.dump_to_native(
104+
Ash.Type.Range,
105+
%Range{lower: 1, upper: 9, bounds: :not_a_bound},
106+
@int_constraints
107+
)
108+
109+
assert {:ok, %Range{bounds: :"[]"}} =
110+
Ash.Type.dump_to_native(
111+
Ash.Type.Range,
112+
%Range{lower: 1, upper: 9, bounds: :"[]"},
113+
@int_constraints
114+
)
115+
end
116+
117+
test "apply_constraints refuses a bounds that is not a bounds specifier" do
118+
assert {:error, _} =
119+
Ash.Type.apply_constraints(
120+
Ash.Type.Range,
121+
%Range{lower: 1, upper: 9, bounds: :not_a_bound},
122+
@int_constraints
123+
)
124+
end
125+
60126
test "a nil bound is an unbounded end" do
61127
assert {:ok, %Range{lower: @lower, upper: nil}} =
62128
Ash.Type.cast_input(Ash.Type.Range, %Range{lower: @lower, upper: nil}, @constraints)

0 commit comments

Comments
 (0)