summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/render/escape.ml8
-rw-r--r--test/test_escape.ml42
2 files changed, 42 insertions, 8 deletions
diff --git a/lib/render/escape.ml b/lib/render/escape.ml
index 53fdd46..f506c44 100644
--- a/lib/render/escape.ml
+++ b/lib/render/escape.ml
@@ -77,6 +77,14 @@ let fold_ics line =
else begin
let cut = ref (pos + limit) in
while !cut > pos && is_cont line.[!cut] do decr cut done;
+ (* Valid UTF-8's longest continuation run is 3, so backoff finds a
+ boundary within a few bytes. But this function must stay TOTAL on
+ ARBITRARY octet strings, not only valid UTF-8: 74+ consecutive
+ continuation bytes back `cut` all the way down to `pos`, which would
+ yield a zero-length chunk and recurse on the identical position
+ forever. When backoff finds no boundary inside the window, cut hard
+ at the limit instead -- forward progress is then unconditional. *)
+ if !cut = pos then cut := pos + limit;
if not first then Buffer.add_char b ' ';
Buffer.add_string b (String.sub line pos (!cut - pos));
Buffer.add_string b "\r\n";
diff --git a/test/test_escape.ml b/test/test_escape.ml
index 1587388..35a9b9d 100644
--- a/test/test_escape.ml
+++ b/test/test_escape.ml
@@ -70,16 +70,41 @@ let test_fold_long_line () =
let test_fold_never_splits_utf8 () =
let long = "SUMMARY:" ^ String.concat "" (List.init 40 (fun _ -> "\xc4\x99\xc5\x9b\xc4\x87")) in
let out = E.fold_ics long in
- let stripped =
- String.concat ""
- (List.filter_map
- (fun l ->
- let l = if l <> "" && l.[String.length l - 1] = '\r' then String.sub l 0 (String.length l - 1) else l in
- if l = "" then None else if l.[0] = ' ' then Some (String.sub l 1 (String.length l - 1)) else Some l)
- (String.split_on_char '\n' out))
+ let chunks =
+ List.filter_map
+ (fun l ->
+ let l = if l <> "" && l.[String.length l - 1] = '\r' then String.sub l 0 (String.length l - 1) else l in
+ if l = "" then None else if l.[0] = ' ' then Some (String.sub l 1 (String.length l - 1)) else Some l)
+ (String.split_on_char '\n' out)
in
+ (* The named property: no chunk may START with a UTF-8 continuation byte
+ (0x80-0xBF) -- that would mean the previous fold cut mid-character.
+ Unfolding losslessly (below) cannot detect this on its own: concatenation
+ is insensitive to where the cuts fell, so a fold at ANY position still
+ round-trips. *)
+ List.iter
+ (fun c ->
+ if String.length c > 0 then
+ Alcotest.(check bool) "chunk does not start mid-UTF-8" true (Char.code c.[0] land 0xC0 <> 0x80))
+ chunks;
+ let stripped = String.concat "" chunks in
check "unfolds to the original" long stripped
+(* B1 regression: 100 consecutive UTF-8 continuation bytes (0x80-0xBF) is not
+ producible by valid UTF-8 (whose longest continuation run is 3), but
+ fold_ics must stay TOTAL on arbitrary octet strings. A backoff loop with no
+ hard-cut fallback backs `cut` all the way down to `pos`, yielding a
+ zero-length chunk and recursing on the identical position forever. *)
+let test_fold_pathological_input_terminates () =
+ let pathological = String.make 100 '\x80' in
+ let out = E.fold_ics pathological in
+ let lines = String.split_on_char '\n' out in
+ List.iter
+ (fun l ->
+ let l = if l <> "" && l.[String.length l - 1] = '\r' then String.sub l 0 (String.length l - 1) else l in
+ if String.length l > 75 then Alcotest.failf "line of %d octets exceeds 75" (String.length l))
+ (List.filter (fun l -> l <> "") lines)
+
let suite =
( "Escape",
[ Alcotest.test_case "latex" `Quick test_latex;
@@ -90,4 +115,5 @@ let suite =
Alcotest.test_case "flavour names" `Quick test_flavour_names;
Alcotest.test_case "fold: short unchanged" `Quick test_fold_short_line_unchanged;
Alcotest.test_case "fold: long line" `Quick test_fold_long_line;
- Alcotest.test_case "fold: never splits utf8" `Quick test_fold_never_splits_utf8 ] )
+ Alcotest.test_case "fold: never splits utf8" `Quick test_fold_never_splits_utf8;
+ Alcotest.test_case "fold: pathological input terminates" `Quick test_fold_pathological_input_terminates ] )