diff --git a/CHANGELOG.md b/CHANGELOG.md index 5104040c..c26f0cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,13 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. ## [Unreleased] +### Fixed +- `:max_string_bytes` is now enforced by `table.concat`, `string.gsub`, + `string.format`, `string.pack` and `os.date`, which previously built results + past the ceiling in a single allocation. Each raises the same catchable + `"resulting string too large"` error as `string.rep` and `..`, before the + result is built (#425). + ## [1.0.2] - 2026-07-28 ### Changed diff --git a/guides/sandboxing.md b/guides/sandboxing.md index befba9e2..8022985b 100644 --- a/guides/sandboxing.md +++ b/guides/sandboxing.md @@ -94,7 +94,7 @@ These guards are always on and need no configuration. They cover: | Operation | Guard | Error (catchable with `pcall`) | | :-------- | :---- | :----------------------------- | -| `string.rep`, the `..` operator | result larger than the string ceiling (default ~256 MiB) | `resulting string too large` | +| `string.rep`, the `..` operator, `string.format`, `string.gsub`, `string.pack`, `table.concat`, `os.date` | result larger than the string ceiling (default ~256 MiB) | `resulting string too large` | | `string.format` width/precision | field wider than 99 | `invalid conversion` | | `table.unpack` | more than 10M results | `too many results to unpack` | | `table.concat`, `table.move` | range wider than 10M | `range too large` | diff --git a/lib/lua.ex b/lib/lua.ex index e0553662..242d83ca 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -103,7 +103,8 @@ defmodule Lua do true * `:max_string_bytes` - (default 256 MiB) ceiling for any single string the VM will build, - whether via `..`, `string.rep`, or a `load` reader. An oversized result raises a catchable + whether via `..`, `string.rep`, `string.format`, `string.gsub`, `string.pack`, + `table.concat`, `os.date`, or a `load` reader. An oversized result raises a catchable `"resulting string too large"` error — the size is computed *before* allocating, so the bomb is refused rather than detected after the fact. Accepts a positive integer or `:infinity` for no limit (matching `:max_call_depth` and `:max_instructions`). diff --git a/lib/lua/vm/limits.ex b/lib/lua/vm/limits.ex index 98a0fc5b..46be9918 100644 --- a/lib/lua/vm/limits.ex +++ b/lib/lua/vm/limits.ex @@ -1,7 +1,7 @@ defmodule Lua.VM.Limits do @moduledoc """ - Practical resource ceilings for stdlib operations whose output size is a - function of a numeric argument. + Practical resource ceilings for stdlib operations whose output can be far + larger than their arguments. These guard against denial-of-service via a single oversized allocation (e.g. `string.rep("x", 1e15)`, `table.unpack(t, 1, 1e12)`). Each call @@ -45,18 +45,22 @@ defmodule Lua.VM.Limits do given ceiling (a state's `max_string_bytes`, defaulting to the practical bound here). Raises a catchable "resulting string too large" runtime error otherwise. + + Pass `state` when Lua code ran between the native call's entry and this + check (a `string.gsub` callback, a `table.concat` `__index`): the raise + then carries it, so a protected call keeps that code's heap effects. """ # `max` may be `:infinity` (from `Lua.new(max_string_bytes: :infinity)`). # Erlang term ordering places every number below every atom, so an integer # `bytes <= :infinity` is always true and the check passes unconditionally — # no separate clause is needed. - @spec check_string_size!(integer(), pos_integer() | :infinity) :: :ok - def check_string_size!(bytes, max \\ @max_string_bytes) + @spec check_string_size!(integer(), pos_integer() | :infinity, Lua.VM.State.t() | nil) :: :ok + def check_string_size!(bytes, max \\ @max_string_bytes, state \\ nil) - def check_string_size!(bytes, max) when is_integer(bytes) and bytes <= max, do: :ok + def check_string_size!(bytes, max, _state) when is_integer(bytes) and bytes <= max, do: :ok - def check_string_size!(_bytes, _max) do - raise RuntimeError, value: "resulting string too large" + def check_string_size!(_bytes, _max, state) do + raise RuntimeError, value: "resulting string too large", state: state end @doc """ diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index da5e62e9..054bfc5c 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -23,8 +23,8 @@ defmodule Lua.VM.State do # means no limit. See `check_call_depth!/1`. call_depth: 0, max_call_depth: :infinity, - # Ceiling for any single string the VM will build (`..`, - # `string.rep`, `load` reader chunks). Defaults to the practical + # Ceiling for any single string the VM will build (`..` and the + # string-building stdlib functions). Defaults to the practical # bound in `Lua.VM.Limits`. Embedders running the VM under a # process heap cap (`:max_heap_size`) should set this below that # cap so an allocation bomb is refused deterministically instead diff --git a/lib/lua/vm/stdlib/os.ex b/lib/lua/vm/stdlib/os.ex index 3eea7a16..65ee0c83 100644 --- a/lib/lua/vm/stdlib/os.ex +++ b/lib/lua/vm/stdlib/os.ex @@ -25,6 +25,7 @@ defmodule Lua.VM.Stdlib.Os do @behaviour Lua.VM.Stdlib.Library alias Lua.VM.ArgumentError + alias Lua.VM.Limits alias Lua.VM.RuntimeError alias Lua.VM.State alias Lua.VM.Stdlib.Util @@ -148,7 +149,7 @@ defmodule Lua.VM.Stdlib.Os do case format do "*t" -> date_table(dt, state) - _ -> {[strftime(format, dt)], state} + _ -> {[strftime(format, dt, state.max_string_bytes)], state} end end @@ -250,10 +251,25 @@ defmodule Lua.VM.Stdlib.Os do {[tref], state} end - defp strftime(format, %DateTime{} = dt) do - Regex.replace(~r/%./, format, fn directive -> - directive_value(directive, dt) - end) + # A directive can expand well past its two format bytes (`%c` becomes 24), + # so a long format multiplies. The format is walked one directive at a + # time and the running size checked as each expands, so an oversized + # result is refused partway rather than built. + defp strftime(format, %DateTime{} = dt, max_bytes), do: expand_directives(format, dt, max_bytes, 0, []) + + defp expand_directives(format, dt, max_bytes, size, acc) do + case :binary.split(format, "%") do + [literal, <>] -> + value = directive_value(<>, dt) + size = size + byte_size(literal) + byte_size(value) + Limits.check_string_size!(size, max_bytes) + expand_directives(rest, dt, max_bytes, size, [acc, literal, value]) + + # No directive left: the rest, a trailing lone `%` included, is literal. + _no_directive -> + Limits.check_string_size!(size + byte_size(format), max_bytes) + IO.iodata_to_binary([acc, format]) + end end defp directive_value("%Y", dt), do: Integer.to_string(dt.year) diff --git a/lib/lua/vm/stdlib/pattern.ex b/lib/lua/vm/stdlib/pattern.ex index b5155f3c..1b332e86 100644 --- a/lib/lua/vm/stdlib/pattern.ex +++ b/lib/lua/vm/stdlib/pattern.ex @@ -13,7 +13,9 @@ defmodule Lua.VM.Stdlib.Pattern do - Escape: % + non-alphanumeric = literal """ + alias Lua.VM.Limits alias Lua.VM.RuntimeError + alias Lua.VM.State alias Lua.VM.Stdlib.Util @doc """ @@ -160,7 +162,7 @@ defmodule Lua.VM.Stdlib.Pattern do # (Lua 5.3 §6.4.1), so gsub performs at most one replacement there and # keeps the remainder untouched — PUC-Lua str_gsub's `anchor` flag makes # its scan loop run exactly once. An unanchored pattern scans every - # position via gsub_from/10. + # position via gsub_from/11. defp do_gsub(subject, true, _pattern, _repl, max_n, state) when max_n != nil and max_n <= 0 do {subject, 0, state} @@ -170,9 +172,9 @@ defmodule Lua.VM.Stdlib.Pattern do case match_pattern(subject, 0, pattern, subject) do {:match, end_pos, captures} -> whole_match = binary_part(subject, 0, end_pos) - {replacement, state} = apply_replacement(repl, whole_match, captures, state) + {replacement, state} = apply_replacement(repl, whole_match, captures, 0, state) rest = binary_part(subject, end_pos, byte_size(subject) - end_pos) - {IO.iodata_to_binary([replacement, rest]), 1, state} + finish_gsub([replacement], rest, byte_size(replacement), 1, state) :nomatch -> {subject, 0, state} @@ -180,7 +182,7 @@ defmodule Lua.VM.Stdlib.Pattern do end defp do_gsub(subject, false, pattern, repl, max_n, state) do - gsub_from(subject, 0, byte_size(subject), pattern, repl, max_n, 0, [], state, false) + gsub_from(subject, 0, byte_size(subject), pattern, repl, max_n, 0, [], 0, state, false) end # Lua 5.3.3+ semantics: an empty match that starts where the *previous* @@ -192,35 +194,40 @@ defmodule Lua.VM.Stdlib.Pattern do # The flag `skip_empty?` is set after every matched-and-applied replacement; # the next iteration is allowed to fire an empty match only after we've # advanced past that boundary. + # + # `size` is the byte count accumulated in `acc`. Replacements can grow the + # result without bound relative to the subject, so it is checked against + # the string ceiling as each one is produced (see apply_replacement/5) and + # once more, with the unmatched remainder, before the result is built. - defp gsub_from(subject, pos, len, _pattern, _repl, max_n, count, acc, state, _skip_empty?) + defp gsub_from(subject, pos, len, _pattern, _repl, max_n, count, acc, size, state, _skip_empty?) when pos > len or (max_n != nil and count >= max_n) do # Append remaining subject (clamp pos so we never read past end_of_string) remaining = if pos < len, do: binary_part(subject, pos, len - pos), else: "" - {IO.iodata_to_binary([Enum.reverse(acc), remaining]), count, state} + finish_gsub(acc, remaining, size, count, state) end - defp gsub_from(subject, pos, len, pattern, repl, max_n, count, acc, state, skip_empty?) do + defp gsub_from(subject, pos, len, pattern, repl, max_n, count, acc, size, state, skip_empty?) do case match_pattern(subject, pos, pattern, subject) do {:match, end_pos, _captures} when skip_empty? and end_pos == pos -> # Empty match immediately after a previous match — skip without # replacing, advance by one byte (or terminate at end of subject). if pos < len do char = <<:binary.at(subject, pos)>> - gsub_from(subject, pos + 1, len, pattern, repl, max_n, count, [char | acc], state, false) + gsub_from(subject, pos + 1, len, pattern, repl, max_n, count, [char | acc], size + 1, state, false) else - {IO.iodata_to_binary(Enum.reverse(acc)), count, state} + finish_gsub(acc, "", size, count, state) end {:match, end_pos, captures} -> if max_n != nil and count >= max_n do remaining = binary_part(subject, pos, len - pos) - {IO.iodata_to_binary([Enum.reverse(acc), remaining]), count, state} + finish_gsub(acc, remaining, size, count, state) else whole_match = binary_part(subject, pos, max(end_pos - pos, 0)) - {replacement, state} = apply_replacement(repl, whole_match, captures, state) + {replacement, state} = apply_replacement(repl, whole_match, captures, size, state) empty_match? = end_pos == pos next_pos = if empty_match?, do: pos + 1, else: end_pos prefix = if empty_match? and pos < len, do: <<:binary.at(subject, pos)>>, else: "" @@ -239,6 +246,7 @@ defmodule Lua.VM.Stdlib.Pattern do max_n, count + 1, [prefix, replacement | acc], + size + byte_size(replacement) + byte_size(prefix), state, not empty_match? ) @@ -247,19 +255,46 @@ defmodule Lua.VM.Stdlib.Pattern do :nomatch -> if pos < len do char = <<:binary.at(subject, pos)>> - gsub_from(subject, pos + 1, len, pattern, repl, max_n, count, [char | acc], state, false) + gsub_from(subject, pos + 1, len, pattern, repl, max_n, count, [char | acc], size + 1, state, false) else - {IO.iodata_to_binary(Enum.reverse(acc)), count, state} + finish_gsub(acc, "", size, count, state) end end end - defp apply_replacement(repl, whole_match, captures, state) when is_binary(repl) do - # Replace %0 with whole match, %1-%9 with captures - {replace_captures(repl, whole_match, captures), state} + defp finish_gsub(acc, remaining, size, count, state) do + check_result_size!(size + byte_size(remaining), state) + {IO.iodata_to_binary([Enum.reverse(acc), remaining]), count, state} + end + + # The non-stateful `gsub/4` entry calls with `state == nil` and falls back + # to the default ceiling. + defp check_result_size!(size, %State{max_string_bytes: max}) when size <= max, do: :ok + + defp check_result_size!(size, %State{max_string_bytes: max} = state) do + Limits.check_string_size!(size, max, state) + end + + defp check_result_size!(size, nil), do: Limits.check_string_size!(size) + + # Produces the replacement for one match, refusing it when it would take + # the result — `size` bytes so far — past the string ceiling. + defp apply_replacement(repl, whole_match, captures, size, state) when is_binary(repl) do + # Replace %0 with whole match, %1-%9 with captures. A replacement with + # no `%` comes back as the same binary; otherwise the expansion is + # iodata, sized and checked before it is flattened. + case replace_captures(repl, whole_match, captures) do + literal when is_binary(literal) -> + check_result_size!(size + byte_size(literal), state) + {literal, state} + + expansion -> + check_result_size!(size + IO.iodata_length(expansion), state) + {IO.iodata_to_binary(expansion), state} + end end - defp apply_replacement(repl, whole_match, captures, state) when is_function(repl, 2) do + defp apply_replacement(repl, whole_match, captures, size, state) when is_function(repl, 2) do args = if captures == [], do: [whole_match], else: captures {result, state} = repl.(args, state) @@ -272,17 +307,21 @@ defmodule Lua.VM.Stdlib.Pattern do other -> raise_invalid_replacement(other, state) end + check_result_size!(size + byte_size(replacement), state) {replacement, state} end - defp apply_replacement(_repl, whole_match, _captures, state), do: {whole_match, state} + defp apply_replacement(_repl, whole_match, _captures, size, state) do + check_result_size!(size + byte_size(whole_match), state) + {whole_match, state} + end # The callback may have made heap mutations (global/table/upvalue/metatable # writes) that thread back through `state` before returning an invalid value. # Ferry the freshest `state` out on the raise so a protected unwind keeps # those effects (Lua 5.3 §2.3). The non-stateful `gsub/4` entry calls with # `state == nil`, so only attach when a real `%State{}` is in scope. - defp raise_invalid_replacement(other, %Lua.VM.State{} = state) do + defp raise_invalid_replacement(other, %State{} = state) do raise RuntimeError, value: "invalid replacement value (a #{Util.typeof(other)})", state: state end @@ -290,9 +329,24 @@ defmodule Lua.VM.Stdlib.Pattern do raise RuntimeError, value: "invalid replacement value (a #{Util.typeof(other)})" end - defp replace_captures("", _whole, _captures), do: "" + # Builds iodata rather than a binary: literal runs and captures are + # referenced, not copied, so a replacement that repeats `%0` many times + # stays the size of the replacement string until its expanded size has + # been checked. A replacement with no `%` comes back as-is. + defp replace_captures(repl, whole, captures), do: literal_run(repl, repl, 0, whole, captures) + + # Scans to the next `%`; the `len` bytes of `run` before it are one piece. + defp literal_run(<>, run, len, whole, captures) do + [binary_part(run, 0, len), replace_directive(rest, whole, captures)] + end + + defp literal_run(<<_byte, rest::binary>>, run, len, whole, captures) do + literal_run(rest, run, len + 1, whole, captures) + end + + defp literal_run(<<>>, run, _len, _whole, _captures), do: run - defp replace_captures("%" <> <>, whole, captures) when c in ?0..?9 do + defp replace_directive(<>, whole, captures) when c in ?0..?9 do idx = c - ?0 value = @@ -313,25 +367,17 @@ defmodule Lua.VM.Stdlib.Pattern do raise RuntimeError, value: "invalid capture index %#{idx} in replacement string" end - capture_to_binary(value) <> replace_captures(rest, whole, captures) + [capture_to_binary(value), replace_captures(rest, whole, captures)] end - defp replace_captures("%%" <> rest, whole, captures) do - "%" <> replace_captures(rest, whole, captures) + defp replace_directive("%" <> rest, whole, captures) do + ["%", replace_captures(rest, whole, captures)] end - defp replace_captures("%" <> <<_c, _rest::binary>>, _whole, _captures) do + defp replace_directive(_other, _whole, _captures) do raise RuntimeError, value: "invalid use of '%' in replacement string" end - defp replace_captures("%", _whole, _captures) do - raise RuntimeError, value: "invalid use of '%' in replacement string" - end - - defp replace_captures(<>, whole, captures) do - <> <> replace_captures(rest, whole, captures) - end - # Captures from `()` (position captures) are integers; everything else is # already a binary. Coerce to a binary so iodata-flattening downstream # doesn't reinterpret integers as raw bytes. diff --git a/lib/lua/vm/stdlib/string.ex b/lib/lua/vm/stdlib/string.ex index 93bb808e..7065afb7 100644 --- a/lib/lua/vm/stdlib/string.ex +++ b/lib/lua/vm/stdlib/string.ex @@ -342,7 +342,7 @@ defmodule Lua.VM.Stdlib.String do # `argn` is the 1-based position of the next value argument. The format # string is arg #1, so the first conversion consumes arg #2; PUC reports a # missing value against this index ("bad argument #2 ... (no value)"). - {[render_format(segments, args, [], 2)], state} + {[render_format(segments, args, [], 2, 0, state.max_string_bytes)], state} rescue e in Lua.VM.RuntimeError -> reraise e, __STACKTRACE__ @@ -418,27 +418,47 @@ defmodule Lua.VM.Stdlib.String do # Render compiled segments against the argument list. `acc` is an iolist # appended as `[acc, piece]` (O(1) per step), materialized exactly once via # `IO.iodata_to_binary/1` at the base case. - defp render_format([], _args, acc, _argn), do: IO.iodata_to_binary(acc) + # + # `size` is the iolist's running byte count. The iolist only references its + # `%s` arguments, so one large string named many times costs nothing until + # that flatten; the base case refuses an oversized result before making it. + defp render_format([], _args, acc, _argn, size, max_bytes) do + Limits.check_string_size!(size, max_bytes) + IO.iodata_to_binary(acc) + end - defp render_format([{:lit, literal} | rest], args, acc, argn) do - render_format(rest, args, [acc, literal], argn) + defp render_format([{:lit, literal} | rest], args, acc, argn, size, max_bytes) do + render_format(rest, args, [acc, literal], argn, size + byte_size(literal), max_bytes) end - defp render_format([{:bare, c} | rest], [arg | remaining_args], acc, argn) do - render_format(rest, remaining_args, [acc, convert_raw(c, arg, nil, 0, nil)], argn + 1) + defp render_format([{:bare, c} | rest], [arg | remaining_args], acc, argn, size, max_bytes) do + piece = convert_raw(c, arg, nil, 0, nil) + render_format(rest, remaining_args, [acc, piece], argn + 1, size + piece_size(piece), max_bytes) end - defp render_format([{:spec, spec} | rest], [arg | remaining_args], acc, argn) do - render_format(rest, remaining_args, [acc, apply_format_spec(spec, arg)], argn + 1) + defp render_format([{:spec, spec} | rest], [arg | remaining_args], acc, argn, size, max_bytes) do + piece = apply_format_spec(spec, arg) + render_format(rest, remaining_args, [acc, piece], argn + 1, size + piece_size(piece), max_bytes) end - defp render_format([_segment | _], [], _acc, argn) do + defp render_format([_segment | _], [], _acc, argn, _size, _max_bytes) do raise ArgumentError, function_name: "string.format", arg_num: argn, details: "no value" end + # A conversion is a binary unless width padding made it a short list of + # binaries (see apply_width_flags/3); those shapes are sized here without + # a BIF call per piece. + defp piece_size(piece) when is_binary(piece), do: byte_size(piece) + defp piece_size([a, b]) when is_binary(a) and is_binary(b), do: byte_size(a) + byte_size(b) + + defp piece_size([a, b, c]) when is_binary(a) and is_binary(b) and is_binary(c), + do: byte_size(a) + byte_size(b) + byte_size(c) + + defp piece_size(piece), do: IO.iodata_length(piece) + # PUC-Lua's `scanformat` reads at most two width and two precision digits and # errors on a third. Mirror that with a digit-count limit (not a value limit) # so the error message matches and a spec like `%.2000000000f` cannot drive a @@ -1171,7 +1191,7 @@ defmodule Lua.VM.Stdlib.String do # Return an iolist rather than concatenating with `<>`: the padded # result threads through the `[acc, piece]` accumulator in - # `render_format/3` and is materialised exactly once at that function's + # `render_format/6` and is materialised exactly once at that function's # base case via `IO.iodata_to_binary/1`, so each width-flagged specifier # no longer allocates a fresh padded binary. if minus? do @@ -1227,7 +1247,7 @@ defmodule Lua.VM.Stdlib.String do # string.pack(fmt, v1, v2, ...) — pack values per fmt defp string_pack([fmt | args], state) when is_binary(fmt) do - {[Pack.pack(fmt, args)], state} + {[Pack.pack(fmt, args, state.max_string_bytes)], state} end defp string_pack([other | _], _state), do: raise_string_expected(1, "pack", other) diff --git a/lib/lua/vm/stdlib/string/pack.ex b/lib/lua/vm/stdlib/string/pack.ex index 99ac16a4..3bfe29f8 100644 --- a/lib/lua/vm/stdlib/string/pack.ex +++ b/lib/lua/vm/stdlib/string/pack.ex @@ -16,6 +16,7 @@ defmodule Lua.VM.Stdlib.String.Pack do import Bitwise, only: [<<<: 2, &&&: 2] + alias Lua.VM.Limits alias Lua.VM.RuntimeError # Platform constants. We run on the 64-bit BEAM, so these match the @@ -42,10 +43,15 @@ defmodule Lua.VM.Stdlib.String.Pack do # ---- public entry points ------------------------------------------------ - @doc "Implements string.pack(fmt, ...)." - def pack(fmt, args) when is_binary(fmt) do + @doc """ + Implements string.pack(fmt, ...). + + `max_bytes` is the string ceiling (a state's `max_string_bytes`); a + result that would outgrow it raises "resulting string too large". + """ + def pack(fmt, args, max_bytes) when is_binary(fmt) do ops = parse(fmt) - do_pack(ops, args, <<>>) + do_pack(ops, args, <<>>, max_bytes) end @doc "Implements string.unpack(fmt, s [, pos])." @@ -328,46 +334,49 @@ defmodule Lua.VM.Stdlib.String.Pack do # ---- pack driver ------------------------------------------------------- - defp do_pack(ops, args, acc) do - do_pack_loop(ops, args, acc, 0) - end - - defp do_pack_loop([], _args, acc, _pos), do: acc + # `acc` is the packed output so far; its size is the position alignment + # is computed from. + defp do_pack([], _args, acc, _max_bytes), do: acc - defp do_pack_loop([{:align, alignment} | ops], args, acc, pos) do - fill = rem(alignment - rem(pos, alignment), alignment) + defp do_pack([{:align, alignment} | ops], args, acc, max_bytes) do + fill = rem(alignment - rem(byte_size(acc), alignment), alignment) + check_room!(acc, fill, max_bytes) pad = :binary.copy(<<0>>, fill) - do_pack_loop(ops, args, acc <> pad, pos + fill) + do_pack(ops, args, acc <> pad, max_bytes) end - defp do_pack_loop([{:padding, n} | ops], args, acc, pos) do - do_pack_loop(ops, args, acc <> :binary.copy(<<0>>, n), pos + n) + defp do_pack([{:padding, n} | ops], args, acc, max_bytes) do + check_room!(acc, n, max_bytes) + do_pack(ops, args, acc <> :binary.copy(<<0>>, n), max_bytes) end - defp do_pack_loop([{:int, signed?, size, endian} | ops], [val | rest], acc, pos) do + defp do_pack([{:int, signed?, size, endian} | ops], [val | rest], acc, max_bytes) do n = to_integer(val, "string.pack") bytes = encode_int(n, size, signed?, endian) - do_pack_loop(ops, rest, acc <> bytes, pos + size) + check_room!(acc, size, max_bytes) + do_pack(ops, rest, acc <> bytes, max_bytes) end - defp do_pack_loop([{:float, size, endian} | ops], [val | rest], acc, pos) do + defp do_pack([{:float, size, endian} | ops], [val | rest], acc, max_bytes) do f = to_float(val, "string.pack") bytes = encode_float(f, size, endian) - do_pack_loop(ops, rest, acc <> bytes, pos + size) + check_room!(acc, size, max_bytes) + do_pack(ops, rest, acc <> bytes, max_bytes) end - defp do_pack_loop([{:fixed_string, n} | ops], [val | rest], acc, pos) do + defp do_pack([{:fixed_string, n} | ops], [val | rest], acc, max_bytes) do s = to_string_arg(val, "string.pack") if byte_size(s) > n do raise_runtime("string longer than given size") end + check_room!(acc, n, max_bytes) padded = s <> :binary.copy(<<0>>, n - byte_size(s)) - do_pack_loop(ops, rest, acc <> padded, pos + n) + do_pack(ops, rest, acc <> padded, max_bytes) end - defp do_pack_loop([{:lstring, prefix_size, endian} | ops], [val | rest], acc, pos) do + defp do_pack([{:lstring, prefix_size, endian} | ops], [val | rest], acc, max_bytes) do s = to_string_arg(val, "string.pack") len = byte_size(s) @@ -379,23 +388,32 @@ defmodule Lua.VM.Stdlib.String.Pack do end prefix = encode_int(len, prefix_size, false, endian) - do_pack_loop(ops, rest, acc <> prefix <> s, pos + prefix_size + len) + check_room!(acc, prefix_size + len, max_bytes) + do_pack(ops, rest, acc <> prefix <> s, max_bytes) end - defp do_pack_loop([{:zstring} | ops], [val | rest], acc, pos) do + defp do_pack([{:zstring} | ops], [val | rest], acc, max_bytes) do s = to_string_arg(val, "string.pack") if String.contains?(s, <<0>>) do raise_runtime("string contains zeros") end - do_pack_loop(ops, rest, acc <> s <> <<0>>, pos + byte_size(s) + 1) + check_room!(acc, byte_size(s) + 1, max_bytes) + do_pack(ops, rest, acc <> s <> <<0>>, max_bytes) end - defp do_pack_loop([_op | _], [], _acc, _pos) do + defp do_pack([_op | _], [], _acc, _max_bytes) do raise_runtime("bad argument to 'pack' (no value)") end + # `@max_total_size` bounds only what the format parser will read, so a + # `c` field or a run of string arguments can still ask for far more + # than the VM allows. Checking before the next `n` bytes are built refuses + # that request instead of allocating it. + defp check_room!(acc, n, max_bytes) when byte_size(acc) + n <= max_bytes, do: :ok + defp check_room!(acc, n, max_bytes), do: Limits.check_string_size!(byte_size(acc) + n, max_bytes) + # ---- unpack driver ----------------------------------------------------- defp do_unpack([], _s, pos, results) do diff --git a/lib/lua/vm/stdlib/table.ex b/lib/lua/vm/stdlib/table.ex index ab23525f..6cb7a5d0 100644 --- a/lib/lua/vm/stdlib/table.ex +++ b/lib/lua/vm/stdlib/table.ex @@ -244,7 +244,16 @@ defmodule Lua.VM.Stdlib.Table do |> then(fn {acc, st} -> {Enum.reverse(acc), st} end) end - {[Enum.join(elements, sep)], state} + # A table can hold one string many times for a few words each, so the + # joined size is unrelated to the table's own footprint. Sum the pieces + # and refuse an oversized result before the join allocates it. + separator_bytes = max(j - i, 0) * byte_size(sep) + Limits.check_string_size!(IO.iodata_length(elements) + separator_bytes, state.max_string_bytes, state) + + # concat_value/2 made every element a binary, so with no separator the + # list is already the result's iodata. + result = if sep == "", do: IO.iodata_to_binary(elements), else: Enum.join(elements, sep) + {[result], state} end defp table_concat([tref | _], _state) do diff --git a/test/lua/vm/limits_test.exs b/test/lua/vm/limits_test.exs index 858ccacf..96142473 100644 --- a/test/lua/vm/limits_test.exs +++ b/test/lua/vm/limits_test.exs @@ -94,6 +94,138 @@ defmodule Lua.VM.LimitsTest do assert message =~ "resulting string too large" end + test "table.concat honors a lowered ceiling", %{small: small} do + # 32 references to one 64-byte string: the table holds 64 bytes of + # string data, the joined result would be 2048. + repeated = ~s|local s = string.rep("x", 64) local t = {} for i = 1, 32 do t[i] = s end return table.concat(t)| + assert pcall_error(small, repeated) =~ "resulting string too large" + + # The elements alone fit (600 bytes); the separators push it over. + separated = ~s|local t = {} for i = 1, 600 do t[i] = "x" end return table.concat(t, "--")| + assert pcall_error(small, separated) =~ "resulting string too large" + end + + test "table.concat keeps __index side effects when the ceiling trips", %{small: small} do + code = """ + local reads = 0 + local s = string.rep("x", 64) + local t = setmetatable({}, {__index = function() + reads = reads + 1 + return s + end}) + local ok, err = pcall(table.concat, t, "", 1, 32) + return ok, tostring(err), reads + """ + + assert {[false, message, 32], _} = Lua.eval!(small, code) + assert message =~ "resulting string too large" + end + + test "table.concat still builds a result exactly at the ceiling", %{small: small} do + code = ~s|local s = string.rep("x", 64) local t = {} for i = 1, 16 do t[i] = s end return #table.concat(t)| + assert {[1024], _} = Lua.eval!(small, code) + end + + test "string.gsub honors a lowered ceiling for string and table replacements", %{small: small} do + for repl <- [~s|string.rep("y", 32)|, ~s|{x = string.rep("y", 32)}|] do + code = ~s|return string.gsub(string.rep("x", 64), ".", #{repl})| + assert pcall_error(small, code) =~ "resulting string too large" + end + end + + test "string.gsub counts capture expansions against the ceiling", %{small: small} do + # One match, one replacement: `%0%0` doubles the 600-byte match. + assert pcall_error(small, ~s|return string.gsub(string.rep("x", 600), ".+", "%0%0")|) =~ + "resulting string too large" + end + + test "string.gsub honors a lowered ceiling for an anchored pattern", %{small: small} do + code = ~s|return string.gsub(string.rep("x", 1024), "^x", string.rep("y", 1024))| + assert pcall_error(small, code) =~ "resulting string too large" + end + + test "string.gsub counts the unmatched remainder against the ceiling", %{small: small} do + # Replacements stop after one (max_n = 1) at 1001 bytes; the 99 + # untouched bytes that follow take the result to 1100. + code = ~s|return string.gsub(string.rep("x", 100), "x", string.rep("y", 1001), 1)| + assert pcall_error(small, code) =~ "resulting string too large" + end + + test "string.gsub still builds a result exactly at the ceiling", %{small: small} do + assert {[1024, 512], _} = + Lua.eval!(small, ~s|local s, n = string.gsub(string.rep("x", 512), ".", "yy") return #s, n|) + + # A full-size subject is fine when the replacements shrink it. + assert {["", 1024], _} = Lua.eval!(small, ~s|return string.gsub(string.rep("x", 1024), "x", "")|) + end + + test "string.gsub keeps callback side effects when the ceiling trips", %{small: small} do + code = """ + local calls = 0 + local y = string.rep("y", 32) + local ok, err = pcall(string.gsub, string.rep("x", 64), ".", function() + calls = calls + 1 + return y + end) + return ok, tostring(err), calls + """ + + # 32 replacements fill the ceiling exactly; the 33rd is refused. + assert {[false, message, 33], _} = Lua.eval!(small, code) + assert message =~ "resulting string too large" + end + + test "string.format honors a lowered ceiling", %{small: small} do + code = ~s|local s = string.rep("x", 1024) return string.format("%s%s", s, s)| + assert pcall_error(small, code) =~ "resulting string too large" + + at_ceiling = ~s|local s = string.rep("x", 512) return #string.format("%s%s", s, s)| + assert {[1024], _} = Lua.eval!(small, at_ceiling) + end + + test "string.format counts width padding against the ceiling", %{small: small} do + # Each field pads to 64 bytes: 16 land exactly on the ceiling, 17 go + # over. `%64s` pads a string; `%064d` zero-fills after a minus sign. + for spec <- ["%64s", "%064d"] do + padded = fn fields -> + ~s|local t = {} for i = 1, #{fields} do t[i] = -i end return #string.format(string.rep("#{spec}", #{fields}), table.unpack(t))| + end + + assert {[1024], _} = Lua.eval!(small, padded.(16)) + assert pcall_error(small, padded.(17)) =~ "resulting string too large" + end + end + + test "string.pack honors a lowered ceiling", %{small: small} do + # A fixed-size field is padded to its declared width. + assert pcall_error(small, ~s|return string.pack("c2048", "")|) =~ "resulting string too large" + + # Variable-length fields: the same argument packed twice, and a + # length prefix that tips an otherwise in-bounds string over. + assert pcall_error(small, ~s|local s = string.rep("x", 600) return string.pack("zz", s, s)|) =~ + "resulting string too large" + + assert pcall_error(small, ~s|return string.pack("s", string.rep("x", 1020))|) =~ + "resulting string too large" + + # An eight-byte integer after 1020 bytes: 1028. + assert pcall_error(small, ~s|return string.pack("c1020j", "", 0)|) =~ "resulting string too large" + + assert {[1024], _} = Lua.eval!(small, ~s|return #string.pack("c1024", "")|) + end + + test "os.date honors a lowered ceiling", %{small: small} do + # `%c` expands 2 format bytes into 24. + assert pcall_error(small, ~s|return os.date(string.rep("%c", 100))|) =~ "resulting string too large" + + # `%Y` at the epoch is 4 bytes: 256 of them land exactly on the + # ceiling, 257 go over. + assert {[1024], _} = Lua.eval!(small, ~s|return #os.date(string.rep("%Y", 256), 0)|) + + assert pcall_error(small, ~s|return os.date(string.rep("%Y", 257), 0)|) =~ + "resulting string too large" + end + test "the default ceiling is unchanged", %{lua: lua} do # 1 MB is far under the 256 MiB default; must build fine. assert {[1_048_576], _} = Lua.eval!(lua, ~s|return #string.rep("x", 2^20)|) @@ -116,6 +248,9 @@ defmodule Lua.VM.LimitsTest do assert {[8192], _} = Lua.eval!(unbounded, ~s|return #string.rep("x", 8192)|) assert {[result], _} = Lua.eval!(unbounded, ~s|return string.rep("x", 4) .. "y"|) assert result == "xxxxy" + + assert {[2048], _} = + Lua.eval!(unbounded, ~s|return #(string.gsub(string.rep("x", 64), ".", string.rep("y", 32)))|) end end diff --git a/test/lua/vm/stdlib/os_test.exs b/test/lua/vm/stdlib/os_test.exs index 56a46410..706e81bd 100644 --- a/test/lua/vm/stdlib/os_test.exs +++ b/test/lua/vm/stdlib/os_test.exs @@ -54,6 +54,31 @@ defmodule Lua.VM.Stdlib.OsTest do assert s == "2000-01-01" end + test "os.date expands every directive and leaves the rest of the format alone" do + # 946684800 is Saturday 2000-01-01 00:00:00 UTC. + cases = [ + {~S|"!%c"|, "Sat Jan 01 00:00:00 2000"}, + {~S|"!%A %a %B %b"|, "Saturday Sat January Jan"}, + {~S|"!%y %j %p %x %X"|, "00 001 AM 01/01/00 00:00:00"}, + {~S|"![%H:%M:%S]"|, "[00:00:00]"}, + {~S|"!%Y%m%d"|, "20000101"}, + # `%%` is a literal percent, and does not start a directive with + # the byte after it. + {~S|"!%%"|, "%"}, + {~S|"!%%Y"|, "%Y"}, + # Unknown directives, a `%` before a newline, and a trailing lone + # `%` pass through unchanged. + {~S|"!%Q"|, "%Q"}, + {~S|"!a%\nb"|, "a%\nb"}, + {~S|"!100%"|, "100%"}, + {~S|"!"|, ""} + ] + + for {format, expected} <- cases do + assert {[^expected], _} = Lua.eval!("return os.date(#{format}, 946684800)") + end + end + test "os.date with *t returns a broken-down time table" do code = """ local t = os.date("!*t", 946684800)