Conversation
…nd os.date
`:max_string_bytes` was checked by `string.rep`, the `..` operator and the
`load` reader, but `table.concat`, `string.gsub`, `string.format`,
`string.pack` and `os.date` built their result without consulting it. With
the ceiling set below a process heap cap, as the sandboxing guide
recommends, each of them could still produce a string past the ceiling in
one allocation -- `string.pack("c2147483647", "")` asked for 2 GiB.
Each now sizes its result before building it and raises the same catchable
"resulting string too large" error:
- `table.concat` sums the elements and separators before joining.
- `string.format` counts bytes as it renders and checks before flattening.
- `string.pack` checks before each field is appended.
- `os.date` and `string.gsub` keep a running size and stop partway.
`string.gsub` expands `%0`-`%9` into iodata so a replacement is sized
before it is built, and its error carries the VM state so a replacement
callback's side effects survive `pcall`. `table.concat` does the same for
`__index` side effects on this error.
`os.date` walks the format directly instead of through a regex; output is
unchanged and pinned by a new test.
Closes tv-labs#425
MrYawe
marked this pull request as ready for review
October 1, 2026 12:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #425.
Why
The sandboxing guide tells embedders to set
:max_string_bytesbelow their:max_heap_sizecap, so that string bombs are refused deterministically and the heap cap stays a backstop. That only held forstring.rep,..and theloadreader.table.concat,string.gsub,string.format,string.packandos.datebuilt their result without consulting the ceiling, so each could still produce an oversized string in one allocation, which is the casemax_heap_sizehandles worst because it is only checked at garbage collection. Under the default configuration,string.pack("c2147483647", "")asked for about 2 GiB.What changes
All five now work out the size of their result before building it and raise the same catchable
resulting string too largeerror asstring.rep. A result exactly at the ceiling still builds, and:infinitystill disables the check.table.concatandstring.formattotal their pieces before the final join. The pieces only reference the caller's strings, so nothing large exists before the check.string.pack,os.dateandstring.gsubbuild incrementally, so they check as they go and stop partway.The
:max_string_bytesoption doc and the guide's resource-limits table now list the five functions, and there is a changelog entry.Notes for review
Four things go beyond the fix sketched in the issue:
gsubcapture expansion is sized before it is built. A running count over finished replacements is not enough: a replacement string made of repeated%0can expand a single match far past the ceiling before the count is consulted. The expansion is now assembled as iodata (captures and literal runs are referenced, not copied), measured, then flattened. This is also where thegsubspeedups below come from, since the old expansion rebuilt the replacement byte by byte on every match.gsub(replacement callbacks) andtable.concat(__index,__len), Lua code runs before the size is known. The error carries the VM state at the raise, sopcallkeeps those effects, the same waygsub's existing "invalid replacement value" error does.Limits.check_string_size!takes an optional state for this.table.concat's existing "invalid value" error still drops them, as onmain; I left that alone.os.dateno longer goes through a regex. Walking the format directly lets it stop as soon as the ceiling is hit, without tokenising the whole format first. Output is unchanged: a new test covers every directive plus%%, unknown directives, a%before a newline and a trailing%, and it passes against the old implementation too.string.charandutf8.charare not covered. They can exceed a very small ceiling, but emit at most 1 and 6 bytes per argument, so they cannot amplify. I can add them if you would rather the ceiling be absolute.Unrelated to this change: the
reset/0tests inLua.VM.BootstrapTestfail intermittently for me on unmodifiedmain(4 of 25 runs ofmix test --include lua53at3f2aead), in case CI trips on them.Benchmarks
mainat3f2aeadagainst this branch at840ead7, each built in its own worktree. Apple M2 (8 cores), macOS 26.6.2, Elixir 1.20.0, Erlang/OTP 29.0.5 with the JIT.Four alternating passes (main, branch, branch, main, main, branch, branch, main), each started once the 1-minute load average was below 3. Every figure is the median of the four per-pass medians. Lower is better, and the ratio is branch / main.
Project workloads
MIX_ENV=benchmark mix run benchmarks/<workload>.exs, quick mode,lua (chunk)job. The Luerl job runs identical code on both sides, so its ratio shows the noise floor for that row. luaport is not installed on this machine, so there is no C Lua job.table.concat(n=100)string.format(n=100)The gsub row includes one disturbed branch pass (3.78 ms, with Luerl also jumping to 3.95 ms in that pass). Over the other three passes it is 1.04×, and an earlier run of the same protocol on the same
gsubcode measured 1.02×, so I read it as 2–7% slower. It is the one place the per-replacement check shows up in the project's suite.Paths the project workloads do not reach
A supplementary script (below), run with
MIX_ENV=prod. Each case is a pre-compiled chunk that loops over the call under test, timed 25 times per pass.os.date, six directives (×20 000)string.pack, mixed fields (×100 000)string.pack, integers (×100 000)string.format, three specifiers (×100 000)table.concat, 1000 strings, with separator (×2000)table.concat, 1000 strings, no separator (×2000)gsub, 1-byte string replacement (80k matches)gsub, capture template%2=%1(40k matches)gsub, capture template with markup (40k matches)gsub, 200-byte string replacement (25k matches)gsub, function replacement (30k matches)gsub, table replacement (60k matches)Where the check costs something:
table.concatwith a separator is about 7% slower at 1000 elements (one extra pass to total the sizes; not visible at n=100), and a tight three-specifierstring.formatloop is about 5% slower (not visible in the project's format workloads). Where it got faster:os.dateabout 1.9×,gsubwith capture templates 15–22%,gsubwith a long literal replacement about 29×, andtable.concatwithout a separator about 11%.Supplementary script
Tests
gsubevery replacement kind, anchored patterns, capture expansion, the unmatched remainder, and side effects survivingpcall. One more test pinsos.dateoutput.mix test --include lua53: 2904 passed.mix format --check-formatted,mix docs --warnings-as-errorsandmix dialyzerare clean.