From e4db079c5793a447ce20a186872d03a05c8bcb93 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 04:58:16 +0700 Subject: [PATCH] Pin the escape buffer's bound, and say what it reserves The guard reserves 9 bytes but the comment explained 5, which is the longest escape alone -- it did not account for the three writes after the loop (the ellipsis and the closing quote), so the next person to touch the escape table would have preserved the wrong invariant. Swept every length to 1300 against \x01, a quote, a backslash and 'a' under ASan with a red zone past the buffer: no write past 1024, worst output 1021. Correct, but by three bytes, which is exactly why the reserve is now written down as the four things it is spent on. Nothing exercised truncation -- the longest nested string in the fixture was 18 bytes -- so println.flan now prints a struct with an 1100-byte string field, and the expected output spells the surviving count out as a number so a change to the buffer shows up as one. --- runtime/flan_rt.c | 8 ++++++-- test/programs/println.flan | 9 +++++++++ test/test_acceptance.ml | 8 +++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index ada55fa..28de468 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -222,8 +222,12 @@ void flan_escape_bytes(const uint8_t *p, int64_t n, flan_slice *out) { size_t len = n < 0 ? 0 : (size_t)n; size_t w = 0; int cut = 0; - /* 5 is the longest single escape (\xNN is 4, plus room for the close - * quote); leaving it spare means the loop never writes a partial escape. */ + /* The guard reserves 9 bytes, and all 9 are spoken for: 4 for the longest + * single escape (\xNN), 3 for the ellipsis, 1 for the closing quote, 1 + * spare. So the loop never writes a partial escape and the three writes + * after it never need a bound of their own. Swept over every length to 1300 + * against \x01, '"', '\\' and 'a': the worst output is 1021 of 1024. If the + * escape table ever grows a longer form, this 9 grows with it. */ escaped[w++] = '"'; for (size_t i = 0; i < len; i++) { if (w + 5 + 4 >= ESCAPE_MAX) { cut = 1; break; } diff --git a/test/programs/println.flan b/test/programs/println.flan index 394c074..2b252c7 100644 --- a/test/programs/println.flan +++ b/test/programs/println.flan @@ -31,6 +31,8 @@ (defvar wide [10 i32]) (defvar deep D1) (defvar n i32) +(defstruct Long [s string]) +(defvar long-one Long) (defn nothing [] ) @@ -108,6 +110,13 @@ (println (slice arr 0 2))) (println (slice arr 2 4)) + ;; A nested string longer than the escape buffer. Escaping is the one + ;; conversion whose output is not a bounded handful of characters, so it + ;; truncates -- with an ellipsis inside the quotes, because a value that + ;; prints as a shorter value is the failure nobody notices. + (set (.s long-one) "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") + (println long-one) + ;; print is println without the newline. (print "a") (print "b") diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index e5fb534..ee57812 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -149,7 +149,13 @@ let () = (Blob {:id 7 :name \"sandy \\\"quoted\\\"\" :pos (V {:x 1.5 :y -2}) :tags [ 0 42 0]})\n\ [ 0 0 9 0]\n[ 0 0 0 0 0 0 0 0 ...]\n[ 0 9 0]\n\ (D1 {:d (D2 {:d (D3 {:d (D4 {:d (D5 {:n ...})})})})})\n[ 0 0]\n\ - [ 0 0]\n[ 9 0]\nabc\n" + [ 0 0]\n[ 9 0]\n" + (* The escape buffer is 1024 and the input is 1100 x's, so this is the + truncation: the ellipsis goes *inside* the quotes, and the count is + spelled out rather than pasted so that a change to the buffer or to + the reserve shows up here as a number and not as a wall of x. *) + ^ "(Long {:s \"" ^ String.make 1014 'x' ^ "...\"})\n" + ^ "abc\n" in outputs "println, every arm" "programs/println.flan" println_out; outputs ~opt:"-O0" "println, every arm, -O0" "programs/println.flan"