From ca69b0c57874dfeb91a617fb6133b9103631cdb1 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Thu, 20 Aug 2026 12:43:37 +0200 Subject: [PATCH 1/2] Compare the leading byte in absolute? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `String.starts-with?` in core guards on `(length s)`, which is `strlen` and therefore counts bytes, but then compares with `prefix`, which slices characters via `String.chars`. `chars` sizes the array with `utf8len`, which counts only non-continuation bytes, so a one-byte string whose byte is in 0x80-0xBF has a byte length of 1 and zero characters, and the slice runs off the end: (Path.absolute? &(String.from-bytes &[187b])) ; Assertion `n < a.len' failed, exit -6 POSIX filenames are arbitrary byte strings, so a bare 0xBB — a Latin-1 » — is a legal name on disk, and `absolute?` is the predicate every other function in the module routes through: `relative?`, `absolute`, `normalize`, and `relative` all abort on it. A leading `/` is always a whole character, so reading the first byte and comparing it to \/ keeps the answer identical for every input that did not crash; a differential over ASCII, multi-byte and truncated-UTF-8 inputs reports no mismatches. The empty-string guard is explicit rather than relying on the NUL terminator. The windows-only branch matches a drive-letter pattern and is untouched. --- path.carp | 2 +- test/path.carp | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/path.carp b/path.carp index 401599f..f751c6a 100644 --- a/path.carp +++ b/path.carp @@ -36,7 +36,7 @@ variable we use on this OS.") (def sep-string "\\") (def escapes? false)) (posix-only - (defn absolute? [p] (String.starts-with? p "/")) + (defn absolute? [p] (and (not (String.empty? p)) (= \/ (String.head p)))) (def separator \/) (def separators [\/]) (def search-path-separator \:) diff --git a/test/path.carp b/test/path.carp index e809bd7..64cc68a 100644 --- a/test/path.carp +++ b/test/path.carp @@ -10,6 +10,9 @@ (assert-false test (absolute? "path") "absolute? works on relative paths") (assert-false test (relative? "/path") "relative? works on absolute paths") (assert-true test (relative? "path") "relative? works on relative paths") + (assert-false test + (absolute? &(String.from-bytes &[187b])) + "absolute? works on a path that is a lone UTF-8 continuation byte") (assert-equal test "file.ext" &(add-extension "file" "ext") From 81cb3a31087ed1ccc53d9bdb957012536fc48c59 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Thu, 20 Aug 2026 18:14:09 +0200 Subject: [PATCH 2/2] Cut split-extension on byte indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pattern.find returns MatchResult.start from the C matcher, a byte offset, while String.prefix and String.suffix slice characters through String.chars. Any multi-byte character before the dot shifts the byte index past the character index, so the split lands in the wrong place: "ä.txt" split as ("ä." . "xt") and "grüße.c" lost its extension entirely, which propagated to extension, is-extension? and drop-extension. When the byte index runs past the character count outright — a name of UTF-8 continuation bytes such as . — the character slice ran off the end of the array and aborted, the same assertion this branch removes from absolute?. Cutting with String.byte-slice matches the index to the slice. core's String.split-by already splits this way. --- path.carp | 4 +++- test/path.carp | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/path.carp b/path.carp index f751c6a..6763139 100644 --- a/path.carp +++ b/path.carp @@ -165,7 +165,9 @@ Examples on POSIX: (let [i (Pattern.find extension-pat p)] (if (= -1 i) (Maybe.Nothing) - (Maybe.Just (Pair.init (prefix p i) (suffix p (inc i))))))) + (Maybe.Just + (Pair.init (String.byte-slice p 0 i) + (String.byte-slice p (inc i) (String.length p))))))) (doc extension "gets the extension of a file as a `Maybe`.") (defn extension [p] (Maybe.apply (split-extension p) &(fn [p] @(Pair.b &p)))) diff --git a/test/path.carp b/test/path.carp index 64cc68a..52eac42 100644 --- a/test/path.carp +++ b/test/path.carp @@ -166,6 +166,21 @@ &(Maybe.Just (Pair.init @"file/path.txt.bob" @"fred")) &(split-extension "file/path.txt.bob.fred") "split-extension gets last extension") + (assert-equal test + &(Maybe.Just (Pair.init @"ä" @"txt")) + &(split-extension "ä.txt") + "split-extension splits at the dot in a multi-byte filename") + (assert-equal test + &(Maybe.Just (Pair.init @"grüße" @"c")) + &(split-extension "grüße.c") + "split-extension keeps the extension of a multi-byte filename") + (assert-true test + (is-extension? "ä.txt" "txt") + "is-extension? works on a multi-byte filename") + (assert-equal test + &(Maybe.Just (Pair.init (String.from-bytes &[187b 187b]) @"")) + &(split-extension &(String.from-bytes &[187b 187b 46b])) + "split-extension works on a path of UTF-8 continuation bytes") (assert-equal test "file.ext" &(replace-extension "file.txt" "ext")