regexec: work around macOS TRE leak on invalid UTF-8

On macOS, the system regex engine leaks an internal buffer when regexec() encounters an invalid multibyte sequence in a UTF-8 locale. The line-by-line path can call regexec_buf() for each pattern on every line, so "git grep" can leak repeatedly on a file containing invalid UTF-8. The total leak grows with the number of calls, and the per-call allocation grows with the pattern's automaton. In one case, grepping a repository containing PDFs exhausted memory and caused the machine to restart. ce025ae4f61e (grep: disable lookahead on error, 2024-10-20) made "git grep" fall back to line-by-line matching when regexec() reports an error on invalid UTF-8. That fallback cannot prevent this leak: the allocation has already leaked when regexec() returns REG_ILLSEQ. Avoid the leaking path by providing a Darwin-specific regexec_buf(). Walk the input with mbrtowc(), split it at bytes that cannot form a complete multibyte character, and search each valid segment separately. This preserves matches in valid text on either side of an invalid byte. Search each segment with REG_STARTEND so match offsets remain relative to the original buffer. Set REG_NOTBOL and REG_NOTEOL for internal segment boundaries so "^" and "$" do not match there. Keep the flags clear at the true beginning and end of the buffer. Use the normal regexec_buf() path in single-byte locales, where no byte can form an invalid multibyte sequence. Use the bundled regex implementation unchanged when NO_REGEX is enabled. Declare the Darwin override in compat/darwin.h and map regexec_buf() to darwin_regexec_buf(). This follows the platform override pattern used by the other compatibility headers and leaves the common inline implementation as the default. There is no reliable way to detect a future macOS version in which the system regex implementation has been fixed. Even after a fix, Git will need the workaround while it supports affected macOS releases, so treat it as an indefinite compatibility workaround. Add tests for matches before, after, and between invalid bytes, including an offset check after an invalid byte. Also check incomplete trailing input and anchors at true and internal line boundaries. Signed-off-by: Chungmin Lee <chungmin@chungminlee.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Chungmin Lee committed Jul 27, 2026 at 22:25 UTC cbbcd3395717397bd2f68a70e02c00f62edfbab9
8 files changed +154
Makefile
+4
@@ -2264,6 +2264,10 @@ ifdef USE_ENHANCED_BASIC_REGULAR_EXPRESSIONS
2264 COMPAT_CFLAGS += -DUSE_ENHANCED_BASIC_REGULAR_EXPRESSIONS
2265 COMPAT_OBJS += compat/regcomp_enhanced.o
2266 endif
2267 +ifdef DARWIN_REGEXEC
2268 + COMPAT_OBJS += compat/darwin/regexec.o
2269 + BASIC_CFLAGS += -DDARWIN_REGEXEC
2270 +endif
2271 endif
2272 ifdef NATIVE_CRLF
2273 BASIC_CFLAGS += -DNATIVE_CRLF
compat/darwin.h new
+8
@@ -0,0 +1,8 @@
1 +#ifndef COMPAT_DARWIN_H
2 +#define COMPAT_DARWIN_H
3 +
4 +int darwin_regexec_buf(const regex_t *preg, const char *buf, size_t size,
5 + size_t nmatch, regmatch_t pmatch[], int eflags);
6 +#define regexec_buf darwin_regexec_buf
7 +
8 +#endif
compat/darwin/regexec.c new
+91
@@ -0,0 +1,91 @@
1 +#include "git-compat-util.h"
2 +
3 +#include <wchar.h>
4 +
5 +/*
6 + * Darwin's TRE regex engine leaks an internal buffer when it encounters an
7 + * invalid multibyte sequence. Since the leak has already happened when
8 + * regexec() reports REG_ILLSEQ, keep invalid bytes out of regexec() by
9 + * searching each valid segment separately.
10 + */
11 +
12 +/*
13 + * Search buf[start, end), where size is the full size of buf. REG_STARTEND
14 + * keeps match offsets relative to buf. Do not let an internal segment create
15 + * a false beginning or end of line.
16 + */
17 +static int regexec_segment(const regex_t *preg, const char *buf,
18 + size_t size, size_t start, size_t end,
19 + size_t nmatch, regmatch_t pmatch[], int eflags)
20 +{
21 + eflags |= REG_STARTEND;
22 + if (start > 0)
23 + eflags |= REG_NOTBOL;
24 + if (end < size)
25 + eflags |= REG_NOTEOL;
26 + pmatch[0].rm_so = start;
27 + pmatch[0].rm_eo = end;
28 + return regexec(preg, buf, nmatch, pmatch, eflags);
29 +}
30 +
31 +int darwin_regexec_buf(const regex_t *preg, const char *buf, size_t size,
32 + size_t nmatch, regmatch_t pmatch[], int eflags)
33 +{
34 + size_t seg_start = 0, i = 0;
35 + mbstate_t mbs;
36 +
37 + assert(nmatch > 0 && pmatch);
38 +
39 + /*
40 + * A single-byte locale cannot contain an invalid multibyte sequence,
41 + * so use regexec() directly.
42 + */
43 + if (MB_CUR_MAX == 1) {
44 + pmatch[0].rm_so = 0;
45 + pmatch[0].rm_eo = size;
46 + return regexec(preg, buf, nmatch, pmatch, eflags | REG_STARTEND);
47 + }
48 +
49 + memset(&mbs, 0, sizeof(mbs));
50 + while (i < size) {
51 + unsigned char c = (unsigned char)buf[i];
52 + size_t n;
53 +
54 + if (c < 0x80) {
55 + i++;
56 + continue;
57 + }
58 +
59 + n = mbrtowc(NULL, buf + i, size - i, &mbs);
60 + if (!n)
61 + n = 1;
62 + if (n != (size_t)-1 && n != (size_t)-2) {
63 + i += n;
64 + continue;
65 + }
66 +
67 + /*
68 + * -1 denotes an encoding error; -2 denotes an incomplete
69 + * trailing sequence. In either case, buf[i] cannot begin a
70 + * complete valid character within this buffer. Search an
71 + * empty initial segment to preserve zero-width matches at the
72 + * true beginning.
73 + */
74 + if (i > seg_start || i == 0) {
75 + int ret = regexec_segment(preg, buf, size, seg_start, i,
76 + nmatch, pmatch, eflags);
77 + if (ret != REG_NOMATCH)
78 + return ret;
79 + }
80 + i++;
81 + seg_start = i;
82 + memset(&mbs, 0, sizeof(mbs));
83 + }
84 +
85 + /*
86 + * Search the final segment even when it is empty, so an empty buffer
87 + * or a buffer ending in invalid bytes still has its true end.
88 + */
89 + return regexec_segment(preg, buf, size, seg_start, size,
90 + nmatch, pmatch, eflags);
91 +}
config.mak.uname
+1
@@ -154,6 +154,7 @@ ifeq ($(uname_S),Darwin)
154 HAVE_DEV_TTY = YesPlease
155 COMPAT_OBJS += compat/precompose_utf8.o
156 BASIC_CFLAGS += -DPRECOMPOSE_UNICODE
157 + DARWIN_REGEXEC = YesPlease
158 BASIC_CFLAGS += -DPROTECT_HFS_DEFAULT=1
159 HAVE_BSD_SYSCTL = YesPlease
160 FREAD_READS_DIRECTORIES = UnfortunatelyYes
contrib/buildsystems/CMakeLists.txt
+3
@@ -519,6 +519,9 @@ if(NOT HAVE_REGEX)
519 include_directories(${CMAKE_SOURCE_DIR}/compat/regex)
520 list(APPEND compat_SOURCES compat/regex/regex.c )
521 add_compile_definitions(NO_REGEX NO_MBSUPPORT GAWK)
522 +elseif(APPLE)
523 + list(APPEND compat_SOURCES compat/darwin/regexec.c)
524 + add_compile_definitions(DARWIN_REGEXEC)
525 endif()
526
527
git-compat-util.h
+5
@@ -162,6 +162,9 @@ static inline int is_xplatform_dir_sep(int c)
162 #include "compat/win32/path-utils.h"
163 #include "compat/msvc.h"
164 #endif
165 +#ifdef DARWIN_REGEXEC
166 +#include "compat/darwin.h"
167 +#endif
168
169 /* used on Mac OS X */
170 #ifdef PRECOMPOSE_UNICODE
@@ -992,6 +995,7 @@ static inline int strtol_i(char const *s, int base, int *result)
995 #error "Git requires REG_STARTEND support. Compile with NO_REGEX=NeedsStartEnd"
996 #endif
997
998 +#ifndef regexec_buf
999 static inline int regexec_buf(const regex_t *preg, const char *buf, size_t size,
1000 size_t nmatch, regmatch_t pmatch[], int eflags)
1001 {
@@ -1000,6 +1004,7 @@ static inline int regexec_buf(const regex_t *preg, const char *buf, size_t size,
1004 pmatch[0].rm_eo = size;
1005 return regexec(preg, buf, nmatch, pmatch, eflags | REG_STARTEND);
1006 }
1007 +#endif
1008
1009 #ifdef USE_ENHANCED_BASIC_REGULAR_EXPRESSIONS
1010 int git_regcomp(regex_t *preg, const char *pattern, int cflags);
meson.build
+5
@@ -1387,6 +1387,11 @@ if not get_option('b_sanitize').contains('address') and get_option('regex').allo
1387 libgit_c_args += '-DUSE_ENHANCED_BASIC_REGULAR_EXPRESSIONS'
1388 compat_sources += 'compat/regcomp_enhanced.c'
1389 endif
1390 +
1391 + if host_machine.system() == 'darwin'
1392 + libgit_c_args += '-DDARWIN_REGEXEC'
1393 + compat_sources += 'compat/darwin/regexec.c'
1394 + endif
1395 elif not get_option('regex').enabled()
1396 libgit_c_args += [
1397 '-DNO_REGEX',
t/t7810-grep.sh
+37
@@ -89,6 +89,10 @@ test_expect_success setup '
89 function dummy() {}
90 EOF
91 printf "\200\nASCII\n" >invalid-utf8 &&
92 + printf "before\346world\n" >invalid-utf8-embedded &&
93 + printf "a\346b\347c\n" >invalid-utf8-multi &&
94 + printf "\346world\n" >invalid-utf8-leading &&
95 + printf "before\346\n" >invalid-utf8-trailing &&
96 if test_have_prereq FUNNYNAMES
97 then
98 echo unusual >"\"unusual\" pathname" &&
@@ -595,6 +599,39 @@ test_expect_success MB_REGEX 'grep two chars in single-char multibyte file' '
599 LC_ALL=en_US.UTF-8 test_expect_code 1 git grep ".." reverse-question-mark
600 '
601
602 +test_expect_success MACOS,MB_REGEX 'grep matches valid text on both sides of invalid UTF-8' '
603 + LC_ALL=en_US.UTF-8 git grep -h "befo[r]e" invalid-utf8-embedded >actual &&
604 + test_cmp invalid-utf8-embedded actual &&
605 + LC_ALL=en_US.UTF-8 git grep -h "worl[d]" invalid-utf8-embedded >actual &&
606 + test_cmp invalid-utf8-embedded actual &&
607 + LC_ALL=en_US.UTF-8 git grep -h -o "worl[d]" invalid-utf8-embedded >actual &&
608 + echo world >expected &&
609 + test_cmp expected actual
610 +'
611 +
612 +test_expect_success MACOS,MB_REGEX 'grep matches a run between two invalid sequences' '
613 + LC_ALL=en_US.UTF-8 git grep -h "[b]" invalid-utf8-multi >actual &&
614 + test_cmp invalid-utf8-multi actual
615 +'
616 +
617 +test_expect_success MB_REGEX 'grep does not anchor ^ or $ inside an invalid-byte line' '
618 + test_expect_code 1 env LC_ALL=en_US.UTF-8 \
619 + git grep -h "^world" invalid-utf8-embedded &&
620 + test_expect_code 1 env LC_ALL=en_US.UTF-8 \
621 + git grep -h "before\$" invalid-utf8-embedded
622 +'
623 +
624 +test_expect_success MACOS,MB_REGEX 'grep anchors ^ and $ at true line ends past invalid UTF-8' '
625 + LC_ALL=en_US.UTF-8 git grep -h "^before" invalid-utf8-embedded >actual &&
626 + test_cmp invalid-utf8-embedded actual &&
627 + LC_ALL=en_US.UTF-8 git grep -h "world\$" invalid-utf8-embedded >actual &&
628 + test_cmp invalid-utf8-embedded actual &&
629 + LC_ALL=en_US.UTF-8 git grep -h "^" invalid-utf8-leading >actual &&
630 + test_cmp invalid-utf8-leading actual &&
631 + LC_ALL=en_US.UTF-8 git grep -h "\$" invalid-utf8-trailing >actual &&
632 + test_cmp invalid-utf8-trailing actual
633 +'
634 +
635 cat >expected <<EOF
636 file
637 EOF