From 73ecc7e22f161950d977731b4bb78ae3112f66ed Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Thu, 20 Aug 2026 07:16:43 +0200 Subject: [PATCH 1/2] Fix process abort in Diff.patch on a single multi-byte character line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core's String.starts-with? guards on String.length (bytes, strlen) but compares against String.prefix, which slices characters via Array.prefix/Array.slice — and Array.slice reads with unsafe-nth, no bounds check. For a line whose byte length is >= 2 while its character count is < 2 (i.e. one multi-byte character), the guard passes and the slice runs off the end, so (Diff.patch "a\nb" "@@ -1,2 +1,2 @@\n a\né\n b") died with Array_unsafe_nth__Char: Assertion 'n < a.len' failed (SIGABRT) instead of returning a Maybe, as patch's doc string promises. Only the "@@" test in patch's cond is affected; the other four use one-byte subs, where the guard implies a non-empty string and a one-character slice is always in range. Comparing the first two bytes is equivalent for every non-aborting input, since '@' is ASCII, and String.length is bytes so it is a valid bound for String.byte-slice. A differential run over hunk headers, empty, count-omitted, ASCII and multi-byte lines is byte-identical to master apart from the abort. --- diff.carp | 3 ++- tests/diff.carp | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/diff.carp b/diff.carp index 6a39049..3fc12d3 100644 --- a/diff.carp +++ b/diff.carp @@ -300,7 +300,8 @@ or out of order. Both documents are split and rejoined on newlines, so (unless failed (let [line (Array.unsafe-nth &lines i)] (cond - (String.starts-with? line "@@") + (and (>= (String.length line) 2) + (= "@@" &(String.byte-slice line 0 2))) (match (parse-hunk-start line) (Maybe.Nothing) (set! failed true) (Maybe.Just start) diff --git a/tests/diff.carp b/tests/diff.carp index 8f42bc2..b2ce239 100644 --- a/tests/diff.carp +++ b/tests/diff.carp @@ -498,4 +498,10 @@ c"))) "patch returns Nothing when a context line does not match the original") (assert-equal test &(the (Maybe String) (Maybe.Nothing)) &(patch "a b" "@@ garbage @@ a -") "patch returns Nothing on a malformed hunk header"))) +") "patch returns Nothing on a malformed hunk header") + (assert-equal test &(Maybe.Just @"a +b") &(patch "a +b" "@@ -1,2 +1,2 @@ + a +é + b") "patch does not abort on a line that is a single multi-byte character"))) From 03b52777f8b092f3244f57adbcb6320caf4c4d11 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Thu, 20 Aug 2026 12:55:31 +0200 Subject: [PATCH 2/2] Make the whole Diff.patch path byte-correct A line of UTF-8 continuation bytes has a positive strlen and zero characters, because utf8len counts only the bytes that are not continuation bytes. Every remaining String.starts-with? in the patch path therefore cleared its byte guard and then read index 0 of an empty character array: a body line that is the single byte 0xBB aborts the host process, and so does a hunk header carrying that byte as a token, which the @@ byte check passed straight down into parse-hunk-start. Compare and cut bytes throughout instead. String.suffix was character based as well, so it dropped those bytes instead of keeping them and an exact no-op patch of a latin-1 document did not apply; stripping one byte makes patch byte-transparent. --- diff.carp | 26 ++++++++++++++++---------- tests/diff.carp | 25 ++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/diff.carp b/diff.carp index 3fc12d3..bc14b82 100644 --- a/diff.carp +++ b/diff.carp @@ -265,6 +265,13 @@ Expects the output of `line-diff` or a diff of string arrays. Uses 3 lines of context around changes.") (defn unified [d] (unified-with-context d 3)) + ; core's starts-with?/suffix guard on byte length but index characters; patch input is bytes + (defn- byte-starts-with? [s sub] + (let [ls (String.length sub)] + (and (>= (String.length s) ls) (= sub &(String.byte-slice s 0 ls))))) + + (defn- byte-suffix [s b] (String.byte-slice s b (String.length s))) + ; parses the 1-based old-file start line from a unified hunk header such as ; `@@ -3,4 +3,5 @@`; the count is optional (`@@ -3 +3 @@` means one line). (defn- parse-hunk-start [header] @@ -273,9 +280,9 @@ Uses 3 lines of context around changes.") (for [i 0 (Array.length &tokens)] (let [tok (Array.unsafe-nth &tokens i)] (when (and (Maybe.nothing? &res) - (String.starts-with? tok "-") + (byte-starts-with? tok "-") (> (String.length tok) 1)) - (let [range (String.suffix tok 1)] + (let [range (byte-suffix tok 1)] (set! res (Int.from-string (Array.unsafe-nth &(String.split-by &range &[\,]) 0))))))) @@ -300,8 +307,7 @@ or out of order. Both documents are split and rejoined on newlines, so (unless failed (let [line (Array.unsafe-nth &lines i)] (cond - (and (>= (String.length line) 2) - (= "@@" &(String.byte-slice line 0 2))) + (byte-starts-with? line "@@") (match (parse-hunk-start line) (Maybe.Nothing) (set! failed true) (Maybe.Just start) @@ -312,22 +318,22 @@ or out of order. Both documents are split and rejoined on newlines, so (for [k cursor target] (Array.push-back! &out @(Array.unsafe-nth &orig k))) (set! cursor target))))) - (String.starts-with? line " ") - (let [content (String.suffix line 1)] + (byte-starts-with? line " ") + (let [content (byte-suffix line 1)] (if (or (>= cursor olen) (/= (Array.unsafe-nth &orig cursor) &content)) (set! failed true) (do (Array.push-back! &out content) (set! cursor (Int.inc cursor))))) - (String.starts-with? line "-") - (let [content (String.suffix line 1)] + (byte-starts-with? line "-") + (let [content (byte-suffix line 1)] (if (or (>= cursor olen) (/= (Array.unsafe-nth &orig cursor) &content)) (set! failed true) (set! cursor (Int.inc cursor)))) - (String.starts-with? line "+") - (Array.push-back! &out (String.suffix line 1)) + (byte-starts-with? line "+") + (Array.push-back! &out (byte-suffix line 1)) ())))) (unless failed (for [k cursor olen] (Array.push-back! &out @(Array.unsafe-nth &orig k)))) diff --git a/tests/diff.carp b/tests/diff.carp index b2ce239..94e5725 100644 --- a/tests/diff.carp +++ b/tests/diff.carp @@ -4,6 +4,9 @@ (use-all Diff Test) +; 0xBB is `»` in latin-1: one byte, zero utf-8 characters +(defn latin1-raquo [] (String.from-bytes &[187b])) + (defn main [] (with-test test (assert-equal test @@ -504,4 +507,24 @@ b") &(patch "a b" "@@ -1,2 +1,2 @@ a é - b") "patch does not abort on a line that is a single multi-byte character"))) + b") "patch does not abort on a line that is a single multi-byte character") + (assert-equal test &(Maybe.Just @"a +b") &(patch "a +b" &(String.concat &[@"@@ -1,2 +1,2 @@ + a +" (latin1-raquo) @" + b"])) "patch does not abort on a body line that is a single continuation byte") + (assert-equal test &(the (Maybe String) (Maybe.Nothing)) &(patch "a +b" &(String.concat &[@"@@ " (latin1-raquo) @" @@ + a +"])) "patch does not abort on a continuation byte in the hunk header") + (assert-equal test &(Maybe.Just (String.concat &[(latin1-raquo) @" +b"])) &(patch &(String.concat &[(latin1-raquo) @" +b"]) &(String.concat &[@"@@ -1,2 +1,2 @@ + " (latin1-raquo) @" + b"])) "patch applies a no-op patch to a latin-1 document") + (assert-equal test &(Maybe.Just (String.concat &[(latin1-raquo) @" +B"])) &(patch &(String.concat &[(latin1-raquo) @" +b"]) &(unified &(line-diff &(String.concat &[(latin1-raquo) @" +b"]) &(String.concat &[(latin1-raquo) @" +B"])))) "patch round-trips a diff of a latin-1 document")))