From 751473a5f3221d6f989291cbebcc404353fd3ba8 Mon Sep 17 00:00:00 2001
From: Byron <sebastian.thiel@icloud.com>
Date: Tue, 11 Aug 2026 14:18:25 +0200
Subject: [PATCH] fix: parse actor identities without regular expressions

<!-- agent -->
GHSA-g5vv-9gxw-82hx reports quadratic backtracking when an actor
identity contains a long unterminated email delimiter.

Add a regression that exercises a 20,000-character malformed identity,
then replace both actor regexes with direct delimiter scans following
Git's first-opening, first-closing delimiter behavior. Keep GitPython's
whole-string fallback when either delimiter is absent.

Reference Git baseline cf5497b14c5a24f10c13f7e0ee85cb95
ident.c::split_ident_line and its invalid-committer cases in
t/t9300-fast-import.sh. Also reference gix-actor's signature decoder and
lenient identity tests.

Assisted-by: GPT 5.6
Co-authored-by: GPT 5.6 <codex@openai.com>
---
 doc/source/changes.rst | 13 +++++++++++++
 git/util.py            | 24 ++++++++----------------
 test/test_actor.py     | 20 ++++++++++++++++++++
 3 files changed, 41 insertions(+), 16 deletions(-)

diff --git a/doc/source/changes.rst b/doc/source/changes.rst
index 1a1b8fa12..bd6c471ff 100644
--- a/doc/source/changes.rst
+++ b/doc/source/changes.rst
@@ -2,6 +2,19 @@
 Changelog
 =========
 
+3.1.60
+======
+
+Security fixes for
+
+* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-g5vv-9gxw-82hx
+
+If you can, also try and provide feedback on the upcoming v4 branch
+https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome.
+
+See the following for all changes.
+https://github.com/gitpython-developers/GitPython/releases/tag/3.1.60
+
 3.1.59
 ======
 
diff --git a/git/util.py b/git/util.py
index 02f57c132..b0593feea 100644
--- a/git/util.py
+++ b/git/util.py
@@ -858,10 +858,6 @@ class Actor:
     committers and authors or anything with a name and an email as mentioned in the git
     log entries."""
 
-    # PRECOMPILED REGEX
-    name_only_regex = re.compile(r"<(.*)>")
-    name_email_regex = re.compile(r"(.*) <(.*?)>")
-
     # ENVIRONMENT VARIABLES
     # These are read when creating new commits.
     env_author_name = "GIT_AUTHOR_NAME"
@@ -906,18 +902,14 @@ def _from_string(cls, string: str) -> "Actor":
         :return:
             :class:`Actor`
         """
-        m = cls.name_email_regex.search(string)
-        if m:
-            name, email = m.groups()
-            return Actor(name, email)
-        else:
-            m = cls.name_only_regex.search(string)
-            if m:
-                return Actor(m.group(1), None)
-            # Assume the best and use the whole string as name.
-            return Actor(string, None)
-            # END special case name
-        # END handle name/email matching
+        line = string.partition("\n")[0]
+        left_bracket = line.find("<")
+        right_bracket = line.find(">", left_bracket + 1)
+        if left_bracket >= 0 and right_bracket >= 0:
+            return Actor(line[:left_bracket].rstrip(), line[left_bracket + 1 : right_bracket])
+
+        # Assume the best and use the whole string as name.
+        return Actor(string, None)
 
     @classmethod
     def _main_actor(
diff --git a/test/test_actor.py b/test/test_actor.py
index 5e6635709..baf6545f1 100644
--- a/test/test_actor.py
+++ b/test/test_actor.py
@@ -27,6 +27,26 @@ def test_from_string_should_handle_just_name(self):
         self.assertEqual("Michael Trier", a.name)
         self.assertEqual(None, a.email)
 
+    def test_from_string_handles_unterminated_email_without_regex_backtracking(self):
+        value = "A" * 20_000 + " <unterminated"
+        actor = Actor._from_string(value)
+        self.assertNotIn("name_email_regex", vars(Actor))
+        self.assertEqual(actor, Actor(value, None))
+
+    def test_from_string_does_not_parse_across_lines(self):
+        self.assertEqual(Actor._from_string("x <a>\n y <b>"), Actor("x", "a"))
+
+    def test_from_string_uses_git_delimiters(self):
+        for value, expected in (
+            ("Name <e<mail>", Actor("Name", "e<mail")),
+            ("Name <email>>", Actor("Name", "email")),
+            ("Name<email>", Actor("Name", "email")),
+            (" <>", Actor("", "")),
+            ("Name <email", Actor("Name <email", None)),
+            ("Name email>", Actor("Name email>", None)),
+        ):
+            self.assertEqual(Actor._from_string(value), expected)
+
     def test_should_display_representation(self):
         a = Actor._from_string("Michael Trier <mtrier@example.com>")
         self.assertEqual('<git.Actor "Michael Trier <mtrier@example.com>">', repr(a))
