Version in base suite: 3.1.44-1 Base version: python-git_3.1.44-1 Target version: python-git_3.1.44-1+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/python-git/python-git_3.1.44-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/python-git/python-git_3.1.44-1+deb13u1.dsc changelog | 91 +++ patches/CVE-2026-100689.patch | 99 +++ patches/CVE-2026-110855.patch | 156 +++++ patches/CVE-2026-110856.patch | 122 ++++ patches/CVE-2026-110857.patch | 339 +++++++++++ patches/CVE-2026-110858.patch | 60 ++ patches/CVE-2026-110859.patch | 81 ++ patches/CVE-2026-110860.patch | 750 ++++++++++++++++++++++++++ patches/CVE-2026-114145.patch | 551 +++++++++++++++++++ patches/CVE-2026-42215.patch | 224 +++++++ patches/CVE-2026-42284.patch | 89 +++ patches/CVE-2026-44243_1.patch | 110 +++ patches/CVE-2026-44243_2.patch | 245 ++++++++ patches/CVE-2026-44244_1.patch | 141 ++++ patches/CVE-2026-44244_2.patch | 26 patches/CVE-2026-67322.patch | 223 +++++++ patches/CVE-2026-67323.patch | 610 +++++++++++++++++++++ patches/CVE-2026-67324_CVE-2026-67325_1.patch | 69 ++ patches/CVE-2026-67324_CVE-2026-67325_2.patch | 90 +++ patches/CVE-2026-67324_CVE-2026-67325_3.patch | 211 +++++++ patches/CVE-2026-67326.patch | 144 ++++ patches/CVE-2026-69097.patch | 112 +++ patches/CVE-2026-73619.patch | 103 +++ patches/CVE-2026-73620.patch | 192 ++++++ patches/CVE-2026-73621.patch | 82 ++ patches/CVE-2026-73622.patch | 107 +++ patches/CVE-2026-73623.patch | 67 ++ patches/CVE-2026-73624.patch | 163 +++++ patches/CVE-2026-73625.patch | 79 ++ patches/CVE-2026-76217.patch | 289 ++++++++++ patches/CVE-2026-76218.patch | 124 ++++ patches/CVE-2026-76219.patch | 174 ++++++ patches/CVE-2026-76220.patch | 88 +++ patches/CVE-2026-76221.patch | 102 +++ patches/CVE-2026-76222.patch | 193 ++++++ patches/CVE-2026-78675.patch | 69 ++ patches/CVE-2026-78676.patch | 107 +++ patches/CVE-2026-78677.patch | 121 ++++ patches/CVE-2026-78678_CVE-2026-78679.patch | 310 ++++++++++ patches/CVE-2026-87817.patch | 636 ++++++++++++++++++++++ patches/CVE-2026-87818.patch | 71 ++ patches/CVE-2026-87819_1.patch | 102 +++ patches/CVE-2026-87819_2.patch | 210 +++++++ patches/series | 42 + 44 files changed, 7974 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmphjfxwpsi/python-git_3.1.44-1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmphjfxwpsi/python-git_3.1.44-1+deb13u1.dsc: no acceptable signature found diff -Nru python-git-3.1.44/debian/changelog python-git-3.1.44/debian/changelog --- python-git-3.1.44/debian/changelog 2025-03-05 18:51:18.000000000 +0000 +++ python-git-3.1.44/debian/changelog 2026-10-28 11:40:08.000000000 +0000 @@ -1,3 +1,94 @@ +python-git (3.1.44-1+deb13u1) trixie-security; urgency=medium + + * CVE-2026-42215: block unsafe options passed as underscored kwargs + (Closes: #1135349). + * CVE-2026-42284: check unsafe options after splitting multi_options + with shlex (Closes: #1135350). + * CVE-2026-44243: prevent access to files outside the repository when + manipulating references. + * CVE-2026-44244: reject control characters in config values written + by the config writer, preventing config injection. + * CVE-2026-67322: do not expand environment variables in clone URLs, + which could leak secrets to a remote host (Closes: #1143454). + * CVE-2026-67323: guard unsafe options (--exec, --upload-pack, ...) in + archive, fetch, pull, push, ls-remote, blame and revision APIs. + * CVE-2026-67324, CVE-2026-67325: abbreviated long options and joined + short options bypassed the unsafe option checks, leading to command + execution. + * CVE-2026-67326: config section names were not validated before + writing, allowing config injection. + * CVE-2026-69097: config section names containing an unquoted closing + bracket could inject arbitrary settings into .git/config (Closes: + #1143602). + * CVE-2026-73619: add --add-file, --add-virtual-file (archive) and + --bundle-uri (clone) to the unsafe option lists (Closes: #1144344). + * CVE-2026-73620: block unsafe options in IndexFile.checkout() + (--prefix) and TagReference.create() (-F/--file), which could write + to or read from arbitrary filesystem paths. + * CVE-2026-73621: reject unsafe output options in Commit.count. + * CVE-2026-73622: do not expand environment variables in remote and + submodule URLs, which could leak secrets to a remote host. + * CVE-2026-73623: reject unsafe --template option when cloning. + * CVE-2026-73624: guard unsafe output options in diff operations. + * CVE-2026-73625: validate the values of split short options, which + could bypass the unsafe option checks in clone, remote, revision, + blame and archive operations. + * CVE-2026-76217: guard --pathspec-from-file options in IndexFile.remove, + Head.checkout and HEAD.reset (Closes: #1144929). + * CVE-2026-76218: guard unsafe git init options (--template, + --separate-git-dir) in Repo.init. + * CVE-2026-76219: guard --index-output in read-tree calls made by + from_tree, merge_tree and reset, which could write to an arbitrary + path. + * CVE-2026-76220: check the value joined to one-character options when + split_single_char_options is false, which could bypass the unsafe + option checks. + * CVE-2026-76221: reject config option names containing characters that + could inject arbitrary settings when writing the config. + * CVE-2026-76222: validate submodule names read from .gitmodules before + filesystem operations, preventing path traversal outside the + repository. + * CVE-2026-78675: do not process include directives in .gitmodules, + which could read arbitrary files. + * CVE-2026-78676: preserve multiline config values when writing, which + could otherwise inject new options into the config. + * CVE-2026-78677: block --separate-git-dir in Repo.clone() and + Repo.clone_from(), which could write repository metadata to an + arbitrary path. + * CVE-2026-78678, CVE-2026-78679: block file-reading options in blame, + diff and tag APIs, which could read arbitrary files. + * CVE-2026-87817: prefer the .git entry during repository discovery and + validate candidate git directories, aligning the behaviour with git. + * CVE-2026-87818: treat --no-index as an unsafe diff option, since it + allows diffing arbitrary filesystem paths. + * CVE-2026-87819: parse actor identities without regular expressions, + avoiding quadratic backtracking (ReDoS) on malformed identities. + * CVE-2026-100689: reject submodule checkout paths that resolve outside + the repository or through a symbolic link, applying to Submodule.update + the containment check already used by add and move. + * CVE-2026-110855: do not expand environment variables in the + separate_git_dir path when cloning, preserving literal paths so + repository metadata is not written to an unexpected location + * CVE-2026-110856: parse actor identities and dates in commit authors, + committers and taggers without backtracking regular expressions, + avoiding excessive CPU use (ReDoS) on malformed metadata. + * CVE-2026-110857: complete the unsafe option and protocol checks in + Git.ls_remote(), Repo.merge_base() and IndexFile.move(), which + previously omitted guards already used by sibling APIs. + * CVE-2026-110858: reject option-shaped remote names in Remote.update(), + which git forwards to "fetch --multiple" without an option terminator, + allowing a leading-dash name to change the requested operation. + * CVE-2026-110859: validate the positional reference passed by + Head.checkout() against the unsafe-option policy, since reference names + originating in a cloned repository could otherwise bypass it. + * CVE-2026-110860: reject submodule checkout, move and metadata paths that + traverse intermediate symbolic links before performing filesystem + operations. + * CVE-2026-114145: GitPython validates a submodule's '.git' path only + after cloning into it (RCE). + + -- Emmanuel Arias Wed, 28 Oct 2026 08:40:08 -0300 + python-git (3.1.44-1) unstable; urgency=medium * New upstream version 3.1.44 diff -Nru python-git-3.1.44/debian/patches/CVE-2026-100689.patch python-git-3.1.44/debian/patches/CVE-2026-100689.patch --- python-git-3.1.44/debian/patches/CVE-2026-100689.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-100689.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,99 @@ +From: Byron +Date: Tue, 1 Sep 2026 11:33:19 +0200 +Subject: Reject submodule checkout paths outside the repository + +GHSA-59cr-6r3x-644w identifies that submodule update paths could reach +filesystem operations without the containment check already used by add and +move. + +Add a regression that proves update rejects a parent-directory checkout +path before cloning, and override Submodule.abspath to apply the shared +_to_relative_path guard for every filesystem consumer. + +Git baseline: git.git read-cache.c verify_path_internal() rejects invalid index +paths, covered for parent traversal by t/t9300-fast-import.sh. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-100689 +--- + git/objects/submodule/base.py | 12 ++++++++++++ + test/test_submodule.py | 40 ++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 52 insertions(+) + +diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py +index 04bb5c1..add762c 100644 +--- a/git/objects/submodule/base.py ++++ b/git/objects/submodule/base.py +@@ -414,6 +414,18 @@ class Submodule(IndexObject, TraversableIterableObj): + + return path + ++ @property ++ def abspath(self) -> PathLike: ++ root = self.repo.working_tree_dir ++ if root is None: ++ return super().abspath ++ path = root ++ for component in os.fspath(self._to_relative_path(self.repo, self.path)).split("/"): ++ path = join_path_native(path, component) ++ if osp.islink(path): ++ raise ValueError("Submodule checkout path %r contains a symbolic link" % self.path) ++ return path ++ + @classmethod + def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_abspath: PathLike) -> None: + """Write a ``.git`` file containing a (preferably) relative path to the actual +diff --git a/test/test_submodule.py b/test/test_submodule.py +index ee9aae0..2b604e2 100644 +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -1158,6 +1158,46 @@ class TestSubmodule(TestBase): + assert sm_mod.commit() == sm_pfb.commit, "Now head should have been reset" + assert sm_mod.head.ref.name == sm_pfb.name + ++ @with_rw_directory ++ def test_update_rejects_checkout_path_outside_parent(self, rwdir): ++ parent = git.Repo.init(osp.join(rwdir, "parent")) ++ submodule = Submodule( ++ parent, ++ Submodule.NULL_BIN_SHA, ++ name="module", ++ path=osp.join("..", "outside"), ++ url="unused", ++ ) ++ ++ with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("clone attempted")): ++ with pytest.raises(ValueError, match="is not in repository"): ++ submodule.update(init=True) ++ ++ @with_rw_directory ++ def test_update_rejects_checkout_path_through_symlink(self, rwdir): ++ parent = git.Repo.init(osp.join(rwdir, "parent")) ++ os.mkdir(osp.join(parent.working_tree_dir, "target")) ++ os.symlink("target", osp.join(parent.working_tree_dir, "link")) ++ submodule = Submodule( ++ parent, ++ Submodule.NULL_BIN_SHA, ++ name="module", ++ path=osp.join("link", "module"), ++ url="unused", ++ ) ++ ++ with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("clone attempted")): ++ with pytest.raises(ValueError, match="contains a symbolic link"): ++ submodule.update(init=True) ++ ++ @with_rw_directory ++ def test_update_rejects_checkout_path_at_parent_root(self, rwdir): ++ parent = git.Repo.init(osp.join(rwdir, "parent")) ++ submodule = Submodule(parent, Submodule.NULL_BIN_SHA, name="module", path=".", url="unused") ++ ++ with pytest.raises(ValueError, match="must not be the repository root"): ++ submodule.update(init=True) ++ + @skipUnless(sys.platform == "win32", "Specifically for Windows.") + def test_to_relative_path_with_super_at_root_drive(self): + class Repo: diff -Nru python-git-3.1.44/debian/patches/CVE-2026-110855.patch python-git-3.1.44/debian/patches/CVE-2026-110855.patch --- python-git-3.1.44/debian/patches/CVE-2026-110855.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-110855.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,156 @@ +From: Byron +Date: Mon, 28 Sep 2026 04:31:10 +0200 +Subject: fix(repo): preserve literal separate Git directory paths + + + +The code-change itself is trivial. Skimmed the tests, better to have them, +but kind of low value given the amount of code they take. Oh well... + + +`Repo._clone()` expanded `separate_git_dir` before invoking Git, so +metadata could be written under a different directory name from the one +requested. Preserve literal paths to address `GHSA-fx3j-rwgx-fr94`. + +Disable expansion in the shared `Git.polish_url()` call. Normalize +path-like arguments with `os.fspath()` first so `pathlib.Path` and other +`os.PathLike` implementations remain supported. Existing path separator +conversion still applies. + +Add local regression coverage for both clone APIs, three path argument +types, variable-like names, and recursive submodule initialization. Check +the metadata location, usable submodule commit, and debug output. + +Git reference: checkout `d38352cd43ab9745686d697872408bc3249a153f`, +`builtin/clone.c` and `t/t5601-clone.sh`'s separate-git-dir tests. +Native Git 2.54.0 also preserved all three literal variable forms. + +Assisted-by: GPT 6.0 +Co-authored-by: GPT 6.0 + +Origin: https://github.com/gitpython-developers/GitPython/commit/93f11925ad7c40e710e9ac0b4735f82792b1877f +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-110855 +--- + git/repo/base.py | 2 +- + git/repo/fun.py | 2 +- + test/test_clone.py | 31 +++++++++++++++++++++++++++++++ + test/test_submodule.py | 29 +++++++++++++++++++++++++++++ + 4 files changed, 62 insertions(+), 2 deletions(-) + +diff --git a/git/repo/base.py b/git/repo/base.py +index daaee8b..d4f4c22 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -1509,7 +1509,7 @@ class Repo: + clone_path = Git.polish_url(path) if Git.is_cygwin() and "bare" in kwargs else path + sep_dir = kwargs.get("separate_git_dir") + if sep_dir: +- kwargs["separate_git_dir"] = Git.polish_url(sep_dir) ++ kwargs["separate_git_dir"] = Git.polish_url(os.fspath(sep_dir), expand_vars=False) + multi = None + if multi_options: + multi = shlex.split(" ".join(multi_options)) +diff --git a/git/repo/fun.py b/git/repo/fun.py +index bc18eb7..38b03df 100644 +--- a/git/repo/fun.py ++++ b/git/repo/fun.py +@@ -150,7 +150,7 @@ def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]: + # Cygwin creates submodules prefixed with `/cygdrive/...`. + # Cygwin git understands Cygwin paths much better than Windows ones. + # Also the Cygwin tests are assuming Cygwin paths. +- path = cygpath(path) ++ path = cygpath(path, expand_vars=False) + if not osp.isabs(path): + path = osp.normpath(osp.join(osp.dirname(d), path)) + return path if is_git_dir(path) else None +diff --git a/test/test_clone.py b/test/test_clone.py +index e0a7352..f1a950d 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -2,8 +2,10 @@ + # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ + + from pathlib import Path ++from dataclasses import dataclass + import re + import os ++import pytest + import tempfile + from unittest import mock + +@@ -12,6 +14,35 @@ import git + from test.lib import TestBase, with_rw_directory, with_rw_repo + from git.exc import UnsafeOptionError, UnsafeProtocolError + ++@dataclass ++class PathLikeMock: ++ path: str ++ ++ def __fspath__(self) -> str: ++ return self.path ++ ++ ++@pytest.mark.parametrize("clone_method", ["clone", "clone_from"]) ++@pytest.mark.parametrize("path_type", [str, Path, PathLikeMock]) ++@pytest.mark.parametrize("name", ["$GITPYTHON_TEST_SECRET", "${GITPYTHON_TEST_SECRET}", "%GITPYTHON_TEST_SECRET%"]) ++def test_clone_preserves_literal_separate_git_dir(tmp_path, monkeypatch, caplog, clone_method, path_type, name): ++ monkeypatch.setenv("GITPYTHON_TEST_SECRET", "sensitive-value") ++ caplog.set_level("DEBUG", logger="git.cmd") ++ separate_git_dir = tmp_path / name ++ options = {"separate_git_dir": path_type(str(separate_git_dir)), "allow_unsafe_options": True} ++ ++ with git.Repo.init(tmp_path / "source") as source: ++ if clone_method == "clone": ++ cloned = source.clone(tmp_path / "clone", **options) ++ else: ++ cloned = git.Repo.clone_from(source.git_dir, tmp_path / "clone", **options) ++ with cloned: ++ assert (separate_git_dir / "HEAD").is_file() ++ assert separate_git_dir.samefile(cloned.git_dir) ++ ++ assert not (tmp_path / "sensitive-value").exists() ++ assert "sensitive-value" not in caplog.text ++ + + class TestClone(TestBase): + +diff --git a/test/test_submodule.py b/test/test_submodule.py +index 2b604e2..678e6b9 100644 +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -50,6 +50,35 @@ def _patch_git_config(name, value): + with patcher: + yield + ++@pytest.mark.parametrize( ++ "name", ["module", "$GITPYTHON_TEST_SECRET", "prefix-${GITPYTHON_TEST_SECRET}-suffix", "%GITPYTHON_TEST_SECRET%"] ++) ++def test_submodule_update_preserves_literal_name(tmp_path, monkeypatch, caplog, name): ++ monkeypatch.setenv("GITPYTHON_TEST_SECRET", "sensitive-value") ++ caplog.set_level("DEBUG", logger="git.cmd") ++ with git.Repo.init(tmp_path / "source") as source, git.Repo.init(tmp_path / "parent") as parent: ++ source.git.symbolic_ref("HEAD", "refs/heads/master") ++ source.index.commit("Initial commit") ++ with _patch_git_config("protocol.file.allow", "always"): ++ parent.git.submodule("add", "--name", name, source.working_tree_dir, "module") ++ parent.index.commit("Add submodule") ++ ++ with git.Repo.clone_from(parent.working_tree_dir, tmp_path / "clone") as clone: ++ clone.submodule_update(init=True, recursive=True) ++ ++ modules_dir = Path(clone.git_dir, "modules") ++ assert {path.name for path in modules_dir.iterdir()} == {name} ++ # Exercise relative gitfile conversion on every platform. ++ with mock.patch.object(Git, "is_cygwin", return_value=True): ++ resolved_git_dir = find_submodule_git_dir(Path(clone.working_tree_dir, "module", ".git")) ++ assert resolved_git_dir is not None ++ assert (modules_dir / name).samefile(resolved_git_dir) ++ with clone.submodules[0].module() as module: ++ assert (modules_dir / name).samefile(module.git_dir) ++ assert module.head.commit == source.head.commit ++ ++ assert "sensitive-value" not in caplog.text ++ + + class TestRootProgress(RootUpdateProgress): + """Just prints messages, for now without checking the correctness of the states""" diff -Nru python-git-3.1.44/debian/patches/CVE-2026-110856.patch python-git-3.1.44/debian/patches/CVE-2026-110856.patch --- python-git-3.1.44/debian/patches/CVE-2026-110856.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-110856.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,122 @@ +From: Byron +Date: Sun, 27 Sep 2026 19:31:47 +0200 +Subject: fix: bound actor and date parsing + + +Address `GHSA-m64x-33q8-m5h7` in the shared `parse_actor_and_date()` +helper used for commit authors, committers, and annotated taggers. +Malformed metadata could make its regular expressions retry overlapping +field boundaries and consume excessive CPU time before returning. + +Bound the leading field name at its first separator and check the line +ending once, before extracting the actor and date. The date expression +then needs no trailing wildcard or end assertion. Apply the same field +boundary to the actor-only fallback. This prevents repeated scans while +preserving accepted suffixes, Unicode digits, final newlines, and the +existing zero-date fallback for malformed input. + +Add compatibility cases and CPU-time regressions for long malformed +metadata and multiline input, covering all three field names and long +valid names. Both timing regressions failed before the fix and pass +afterward. A separate comparison preserved capture groups in 3,240 cases. + +Git reference: `git/git@d38352cd43ab9745686d697872408bc3249a153f`, +`ident.c:split_ident_line()` and `t/t4212-log-corrupt.sh`, which scan +identity delimiters directly and cover tolerant handling of invalid +dates. Retain GitPython's existing return values for malformed input. + +Assisted-by: GPT 6.0 +Co-authored-by: GPT 6.0 + +Origin: https://github.com/gitpython-developers/GitPython/commit/71b954501c0de6e769d2e18a42de0d07484091cc +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-110856 +--- + git/objects/util.py | 7 ++++--- + test/test_util.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 53 insertions(+), 3 deletions(-) + +diff --git a/git/objects/util.py b/git/objects/util.py +index a68d701..64cdf71 100644 +--- a/git/objects/util.py ++++ b/git/objects/util.py +@@ -318,9 +318,10 @@ def parse_date(string_date: Union[str, datetime]) -> Tuple[int, int]: + # END handle exceptions + + +-# Precompiled regexes +-_re_actor_epoch = re.compile(r"^.+? (.*) (\d+) ([+-]\d+).*$") +-_re_only_actor = re.compile(r"^.+? (.*)$") ++# Check the line ending once, before parsing fields, to avoid repeated backtracking. ++# Keep the field name from consuming spaces belonging to the actor. ++_re_actor_epoch = re.compile(r"^(?=[^\n]*$).[^ \n]* (.*) (\d+) ([+-]\d+)") ++_re_only_actor = re.compile(r"^.[^ \n]* (.*)$") + + + def parse_actor_and_date(line: str) -> Tuple[Actor, int, int]: +diff --git a/test/test_util.py b/test/test_util.py +index dad2f3d..24accbb 100644 +--- a/test/test_util.py ++++ b/test/test_util.py +@@ -22,6 +22,7 @@ from git.cmd import dashify + from git.objects.util import ( + altz_to_utctz_str, + from_timestamp, ++ parse_actor_and_date, + parse_date, + tzoffset, + utctz_to_altz, +@@ -524,6 +525,54 @@ class TestUtils(TestBase): + Actor("name last another", "some-very-long-email@example.com"), + ) + ++ @ddt.data( ++ ("", Actor("", None), 0, 0), ++ ("author", Actor("author", None), 0, 0), ++ ("author Name 42 -0700", Actor("Name", "email"), 42, 25200), ++ ("committer Name 42 +0530\n", Actor("Name", "email"), 42, -19800), ++ ("tagger Name 42 +0000\r\n", Actor("Name", "email"), 42, 0), ++ ("author Name 42 -0700 trailing", Actor("Name", "email"), 42, 25200), ++ ("author Name 1 +0 42 -0700", Actor("Name", "email"), 42, 25200), ++ ("author Name invalid -0700", Actor("Name", "email"), 0, 0), ++ ("author Name 42 invalid", Actor("Name", "email"), 0, 0), ++ ("author 42 -0700", Actor("42 -0700", None), 0, 0), ++ ("author 42 -0700", Actor("", None), 42, 25200), ++ (" author Name 42 -0700", Actor("Name", "email"), 42, 25200), ++ ("author Näme ١ +٠١٣٠", Actor("Näme", "email"), 1, -5400), ++ ("author\nName 42 -0700", Actor("author\nName 42 -0700", None), 0, 0), ++ ("author Name 42 -0700\nextra", Actor("author Name", "email"), 0, 0), ++ ) ++ @ddt.unpack ++ def test_parse_actor_and_date(self, line, actor, epoch, offset): ++ self.assertEqual(parse_actor_and_date(line), (actor, epoch, offset)) ++ ++ def test_parse_actor_and_date_long_malformed_lines(self): ++ padding = " " * 64_000 ++ for field in ("author", "committer", "tagger"): ++ for tail in ("", " 42 -0700"), ++ (Actor(name.rstrip(), "email"), 42, 25200), ++ ) ++ ++ def test_parse_actor_and_date_long_multiline_input(self): ++ for field in ("author", "committer", "tagger"): ++ line = f"{field} Name 42 +" + "0" * 64_000 + "\nextra" ++ start = time.process_time() ++ result = parse_actor_and_date(line) ++ elapsed = time.process_time() - start ++ self.assertLess(elapsed, 1.0, field) ++ self.assertEqual(result, (Actor(f"{field} Name", "email"), 0, 0)) ++ + @ddt.data( + ("name", ""), + ("name", "prefix_"), diff -Nru python-git-3.1.44/debian/patches/CVE-2026-110857.patch python-git-3.1.44/debian/patches/CVE-2026-110857.patch --- python-git-3.1.44/debian/patches/CVE-2026-110857.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-110857.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,339 @@ +From: Byron +Date: Sat, 26 Sep 2026 20:45:34 +0200 +Subject: fix: complete safety checks in Git command wrappers + +Mostly a rubber-stamp, particularly the tests I just skimmed. +It's also a common fix just with unsafe-options guards, and +a regex fix which hopefully is truly better than before. +I didn't question it in the interest of time. + + +Address GHSA-w8jc-g24h-crhw by applying the existing guard policy to +`Git.ls_remote()`, `Repo.merge_base()`, and `IndexFile.move()`. These wrappers +previously omitted protocol or option checks already used by sibling APIs. + +Check flattened positional arguments and split short-option values before +`ls_remote()` starts Git, including values that become the repository after +option parsing. Add the independent `allow_unsafe_protocols` opt-in and recognize +helper selectors even when their address is empty or begins with a newline. +Match Git's scheme-character rules at the start of the address so ordinary +IPv6 URLs and double colons in repository paths retain their meaning. This +addresses the review finding that the broad matcher rejected valid IPv6 remotes. +The protocol check is conservative for positional and split option values; +long-form `server_option` values remain available without a protocol opt-in. + +Reuse the revision and pathspec option guards in `merge_base()` and `move()`, +with explicit `allow_unsafe_options` opt-ins. Git currently rejects these +denylisted options for those subcommands; the checks keep their policy aligned +with sibling APIs. Preserve literal move operands behind `--` and validate +options before either the dry run or actual move. + +Only treat exit status 1 from `merge_base()` as no common ancestor. Other +failures, including invalid options, now propagate as `GitCommandError` +instead of silently returning an empty list. + +Git reference: `git/git@d38352cd43ab9745686d697872408bc3249a153f`, inspected in +`builtin/ls-remote.c`, `transport.c`, `builtin/merge-base.c`, `builtin/mv.c`, +`url.c`, and `parse-options.c`. These confirm option parsing before the remote operand, +helper selection independently of address contents, long-option abbreviations, +and status 1 for unrelated histories. + +Assisted-by: GPT 6.0 +Co-authored-by: GPT 6.0 + +Origin: https://github.com/gitpython-developers/GitPython/commit/73a72c969504bb5a781d8d11dde5ac775ebb17a0 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-110857 +--- + git/cmd.py | 15 ++++- + git/index/base.py | 10 +++ + git/repo/base.py | 16 ++++- + test/test_command_guards.py | 151 ++++++++++++++++++++++++++++++++++++++++++++ + 4 files changed, 188 insertions(+), 4 deletions(-) + create mode 100644 test/test_command_guards.py + +diff --git a/git/cmd.py b/git/cmd.py +index 673eba2..0c0f726 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -414,7 +414,8 @@ class Git(metaclass=_GitMeta): + "_version_info_token", + ) + +- re_unsafe_protocol = re.compile(r"(.+)::.+") ++ # Match Git's leading transport selector, including an empty helper name. ++ re_unsafe_protocol = re.compile(r"([A-Za-z0-9][A-Za-z0-9+.-]*|)::") + + unsafe_git_ls_remote_options = [ + # This option allows arbitrary command execution in git-ls-remote. +@@ -1111,16 +1112,28 @@ class Git(metaclass=_GitMeta): + self, + *args: Any, + allow_unsafe_options: bool = False, ++ allow_unsafe_protocols: bool = False, + **kwargs: Any, + ) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], "Git.AutoInterrupt"]: + """List references in a remote repository. + + :param allow_unsafe_options: + Allow unsafe options, like ``--upload-pack`` or ``--exec``. ++ ++ :param allow_unsafe_protocols: ++ Allow unsafe protocols to be used, like ``ext``. Positional arguments ++ and split short-option values are checked. + """ + if not allow_unsafe_options: + candidate_options = self._option_candidates(args, kwargs) + Git.check_unsafe_options(options=candidate_options, unsafe_options=self.unsafe_git_ls_remote_options) ++ if not allow_unsafe_protocols: ++ protocol_args = list(args) ++ if kwargs.get("split_single_char_options", True): ++ # Split short-option values can become the URL after parsing earlier options. ++ protocol_args.extend(value for key, value in kwargs.items() if len(key) == 1) ++ for arg in self._unpack_args(protocol_args): ++ self.check_unsafe_protocols(arg) + return self._call_process("ls_remote", *args, **kwargs) + + @property +diff --git a/git/index/base.py b/git/index/base.py +index 45ba1cb..51249c1 100644 +--- a/git/index/base.py ++++ b/git/index/base.py +@@ -1081,6 +1081,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + self, + items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]], + skip_errors: bool = False, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> List[Tuple[str, str]]: + """Rename/move the items, whereas the last item is considered the destination of +@@ -1101,6 +1102,10 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + If ``True``, errors such as ones resulting from missing source files will be + skipped. + ++ :param allow_unsafe_options: ++ Allow unsafe options such as ``--pathspec-from-file`` to be passed to ++ :manpage:`git-mv(1)`. ++ + :param kwargs: + Additional arguments you would like to pass to :manpage:`git-mv(1)`, such as + ``dry_run`` or ``force``. +@@ -1117,6 +1122,11 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + :raise git.exc.GitCommandError: + If git could not handle your request. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=Git.unsafe_git_pathspec_from_file_options, ++ ) + args = [] + if skip_errors: + args.append("-k") +diff --git a/git/repo/base.py b/git/repo/base.py +index d4f4c22..acf9e53 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -894,7 +894,7 @@ class Repo: + **kwargs, + ) + +- def merge_base(self, *rev: TBD, **kwargs: Any) -> List[Commit]: ++ def merge_base(self, *rev: TBD, allow_unsafe_options: bool = False, **kwargs: Any) -> List[Commit]: + R"""Find the closest common ancestor for the given revision + (:class:`~git.objects.commit.Commit`\s, :class:`~git.refs.tag.Tag`\s, + :class:`~git.refs.reference.Reference`\s, etc.). +@@ -902,6 +902,9 @@ class Repo: + :param rev: + At least two revs to find the common ancestor for. + ++ :param allow_unsafe_options: ++ Allow unsafe options in the revision arguments, like ``--output``. ++ + :param kwargs: + Additional arguments to be passed to the ``repo.git.merge_base()`` command + which does all the work. +@@ -914,18 +917,25 @@ class Repo: + + :raise ValueError: + If fewer than two revisions are provided. ++ ++ :raise git.exc.GitCommandError: ++ If git fails for a reason other than having no common merge base. + """ + if len(rev) < 2: + raise ValueError("Please specify at least two revs, got only %i" % len(rev)) + # END handle input + ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates(rev, kwargs), unsafe_options=self.unsafe_git_revision_options ++ ) ++ + res: List[Commit] = [] + try: + lines: List[str] = self.git.merge_base(*rev, **kwargs).splitlines() + except GitCommandError as err: +- if err.status == 128: ++ if err.status != 1: + raise +- # END handle invalid rev + # Status code 1 is returned if there is no merge-base. + # (See: https://github.com/git/git/blob/v2.44.0/builtin/merge-base.c#L19) + return res +diff --git a/test/test_command_guards.py b/test/test_command_guards.py +new file mode 100644 +index 0000000..1782231 +--- /dev/null ++++ b/test/test_command_guards.py +@@ -0,0 +1,151 @@ ++"""Command wrappers apply safety checks before starting Git.""" ++ ++from pathlib import Path ++from unittest import mock ++ ++import pytest ++ ++from git import Actor, Git, GitCommandError, Repo ++from git.exc import UnsafeOptionError, UnsafeProtocolError ++ ++ ++@pytest.mark.parametrize("allow_unsafe_options", [False, True]) ++@pytest.mark.parametrize( ++ "args, kwargs", ++ [ ++ (("ext::helper",), {}), ++ (("ext::",), {}), ++ (("custom::address",), {}), ++ (("1custom+v2.test-name::address",), {}), ++ (("::address",), {}), ++ (("custom::\naddress",), {}), ++ ((["--refs", ("ext::helper",)],), {}), ++ ((None, "--", "ext::helper", "HEAD"), {}), ++ ((Path("ext::helper"),), {}), ++ ((), {"q": "ext::helper"}), ++ ((), {"h": ["ext::helper"]}), ++ ((), {"-": "ext::helper"}), ++ ((), {"o": [True, "ext::helper"]}), ++ (("--server-option",), {"o": "ext::helper", "insert_kwargs_after": "--server-option"}), ++ ], ++) ++def test_ls_remote_rejects_unsafe_protocols(args, kwargs, allow_unsafe_options): ++ with mock.patch.object(Git, "execute", side_effect=AssertionError("Git must not run")) as run: ++ with pytest.raises(UnsafeProtocolError): ++ Git().ls_remote(*args, allow_unsafe_options=allow_unsafe_options, **kwargs) ++ run.assert_not_called() ++ ++ ++@pytest.mark.parametrize( ++ "args, kwargs", ++ [ ++ ((), {}), ++ ((None,), {}), ++ (("origin", "HEAD"), {"h": True}), ++ (("https://example.com/repo.git",), {}), ++ (("git@example.com:repo.git",), {}), ++ (("origin",), {"o": "key=value"}), ++ (("origin",), {"server_option": "key::value"}), ++ ], ++) ++def test_ls_remote_preserves_safe_arguments(args, kwargs): ++ with mock.patch.object(Git, "execute", return_value="refs") as run: ++ assert Git().ls_remote(*args, **kwargs) == "refs" ++ run.assert_called_once() ++ ++ ++@pytest.mark.parametrize( ++ "url", ++ [ ++ "https://[::1]/repo.git", ++ "ssh://git@[2001:db8::1]/repo.git", ++ "https://example.com/repo::name", ++ "git@example.com:repo::name", ++ "./repo::name", ++ ], ++) ++def test_ls_remote_preserves_double_colons_outside_helper_selector(url): ++ assert Git().ls_remote(url, get_url=True) == url ++ ++ ++def test_ls_remote_unsafe_opt_ins_are_independent(): ++ with mock.patch.object(Git, "execute", return_value="refs") as run: ++ with pytest.raises(UnsafeOptionError): ++ Git().ls_remote("origin", upload_pack="helper", allow_unsafe_protocols=True) ++ run.assert_not_called() ++ assert ( ++ Git().ls_remote("ext::helper", upload_pack="helper", allow_unsafe_protocols=True, allow_unsafe_options=True) ++ == "refs" ++ ) ++ run.assert_called_once_with([Git.GIT_PYTHON_GIT_EXECUTABLE, "ls-remote", "--upload-pack=helper", "ext::helper"]) ++ ++ ++@pytest.mark.parametrize("allow_unsafe_options", [False, True]) ++@pytest.mark.parametrize( ++ "revs, kwargs", ++ [ ++ (("HEAD", "--output=unused"), {}), ++ (("HEAD", ["--out=unused"]), {}), ++ (("HEAD", "-ounused"), {}), ++ (("HEAD", "HEAD"), {"output": "unused"}), ++ (("HEAD", "HEAD"), {"out": "unused"}), ++ (("HEAD", "HEAD"), {"o": "unused"}), ++ ], ++) ++def test_merge_base_checks_unsafe_options(tmp_path, revs, kwargs, allow_unsafe_options): ++ repo = Repo.init(tmp_path) ++ with mock.patch.object(Git, "execute", return_value="") as run: ++ if allow_unsafe_options: ++ assert repo.merge_base(*revs, allow_unsafe_options=True, **kwargs) == [] ++ run.assert_called_once() ++ assert "--allow-unsafe-options" not in run.call_args[0][0] ++ else: ++ with pytest.raises(UnsafeOptionError): ++ repo.merge_base(*revs, **kwargs) ++ run.assert_not_called() ++ ++ ++@pytest.mark.parametrize("status", [-9, 2, 128, 129]) ++def test_merge_base_propagates_errors(tmp_path, status): ++ repo = Repo.init(tmp_path) ++ error = GitCommandError("git merge-base", status) ++ with mock.patch.object(Git, "execute", side_effect=error): ++ with pytest.raises(GitCommandError) as raised: ++ repo.merge_base("HEAD", "HEAD") ++ assert raised.value is error ++ ++ ++def test_merge_base_distinguishes_unrelated_history_from_invalid_options(tmp_path): ++ repo = Repo.init(tmp_path) ++ actor = Actor("Test", "test@example.com") ++ first = repo.index.commit("first", author=actor, committer=actor) ++ second = repo.index.commit("second", parent_commits=[], head=False, author=actor, committer=actor) ++ assert repo.merge_base(first, first) == [first] ++ assert repo.merge_base(first, second) == [] ++ with pytest.raises(GitCommandError) as raised: ++ repo.merge_base(first, second, invalid_option=True) ++ assert raised.value.status == 129 ++ ++ ++@pytest.mark.parametrize("allow_unsafe_options", [False, True]) ++@pytest.mark.parametrize("option", ["pathspec_from_file", "pathspec-from-file", "pathspec_from"]) ++@pytest.mark.parametrize("dry_run", [False, True]) ++def test_move_checks_unsafe_options(tmp_path, option, dry_run, allow_unsafe_options): ++ repo = Repo.init(tmp_path) ++ with mock.patch.object(Git, "execute", return_value="Renaming source to destination\n") as run: ++ kwargs = {option: "unused", "dry_run": dry_run} ++ if allow_unsafe_options: ++ assert repo.index.move(["source", "destination"], True, allow_unsafe_options=True, **kwargs) == [ ++ ("source", "destination") ++ ] ++ assert run.call_count == (1 if dry_run else 2) ++ for call in run.call_args_list: ++ argv = call[0][0] ++ assert "-k" in argv ++ assert f"--{option.replace('_', '-')}=unused" in argv ++ assert "--allow-unsafe-options" not in argv ++ assert argv[-3:] == ["--", "source", "destination"] ++ else: ++ with pytest.raises(UnsafeOptionError): ++ repo.index.move(["source", "destination"], **kwargs) ++ run.assert_not_called() diff -Nru python-git-3.1.44/debian/patches/CVE-2026-110858.patch python-git-3.1.44/debian/patches/CVE-2026-110858.patch --- python-git-3.1.44/debian/patches/CVE-2026-110858.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-110858.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,60 @@ +From: Byron +Date: Sun, 20 Sep 2026 11:01:35 +0200 +Subject: fix(remote): reject option-shaped update targets + + +`Remote.update()` also crosses an internal Git command boundary: Git's +`remote update` parses options, then forwards the remaining operands to +`fetch --multiple` without an option terminator. A leading-dash remote +name can therefore change the requested operation or its target set. + +Reject such names before dispatch. Document at `_call_process()` why +high-level wrappers must protect operands, while the low-level runner +must continue supporting deliberately positional options. Shell quoting +does not affect Git's own option parsing. Add an unreleased changelog entry. + +Git reference: `builtin/remote.c:update()` at Git commit +`12cb6293d6288865c1a133cf22accbaf99d13eb6` constructs the internal fetch +command without preserving `--`. + +Assisted-by: GPT 6.0 +Co-authored-by: GPT 6.0 + +Origin: https://github.com/gitpython-developers/GitPython/commit/dafafff7e5438879d0167c17008342231f7af314 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-110858 +--- + git/cmd.py | 6 ++++++ + git/remote.py | 3 +++ + 2 files changed, 9 insertions(+) + +diff --git a/git/cmd.py b/git/cmd.py +index 0c0f726..761cbe7 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -1674,6 +1674,12 @@ class Git(metaclass=_GitMeta): + This allows your commands to call git more conveniently, as ``None`` is + realized as non-existent. + ++ Positional arguments may intentionally contain command options. Higher-level ++ APIs must separate their operands with ``--`` where the Git command supports ++ it, or reject option-shaped operands where Git reparses them internally (for ++ example, ``pull`` and ``remote update``). Shell quoting cannot prevent Git ++ from interpreting a leading-dash argument as an option. ++ + :param kwargs: + Contains key-values for the following: + +diff --git a/git/remote.py b/git/remote.py +index 17cd217..0ea2509 100644 +--- a/git/remote.py ++++ b/git/remote.py +@@ -864,6 +864,9 @@ class Remote(LazyMixin, IterableObj): + :return: + self + """ ++ # Like pull, remote update forwards operands to fetch without `--`. ++ if self.name.startswith("-"): ++ raise UnsafeOptionError("Remote names used by update must not start with '-'.") + scmd = "update" + kwargs["insert_kwargs_after"] = scmd + self.repo.git.remote(scmd, self.name, **kwargs) diff -Nru python-git-3.1.44/debian/patches/CVE-2026-110859.patch python-git-3.1.44/debian/patches/CVE-2026-110859.patch --- python-git-3.1.44/debian/patches/CVE-2026-110859.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-110859.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,81 @@ +From: Byron +Date: Thu, 10 Sep 2026 08:07:55 +0200 +Subject: fix: validate checkout positional reference options + + +Head.checkout checked keyword options but omitted the serialized reference from +its existing unsafe-option validation (GHSA-23mf-xhv8-69c2). Reference names can +originate in a cloned repository, so callers could reach behavior that normally +requires explicit opt-in without supplying any checkout options themselves. + +Pass self through the shared option-candidate helper, matching the argument +actually sent to Git. This applies the existing policy to direct and +cloned references, including abbreviated option spellings, while retaining +allow_unsafe_options=True and ordinary checkout semantics. A leading -- +separator would instead make the reference a pathspec and break branch +switching. + +Add direct-reference regression coverage and a local clone regression +using synthetic file content; the latter also verifies explicit opt-in. +Both regression tests failed before the guard change. All 30 tests in +test/test_refs.py pass with Python 3.12.14 and Apple Git 2.50.1; git diff +--check passes. + +Git behavior reference: local git/git checkout at +1630431f326e15fcde608827b5ff38422528eb59, builtin/checkout.c checkout_main +pathspec_from_file handling, which parses file contents as pathspecs. No Git +source was copied. + +Assisted-by: GPT 6.0 +Co-authored-by: GPT 6.0 + +Origin: https://github.com/gitpython-developers/GitPython/commit/dcb6b14a9f6af9ece60d014b84c154f83f6906c8 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-110859 +--- + git/refs/head.py | 2 +- + test/test_refs.py | 19 +++++++++++++++++++ + 2 files changed, 20 insertions(+), 1 deletion(-) + +diff --git a/git/refs/head.py b/git/refs/head.py +index 624028f..f671f23 100644 +--- a/git/refs/head.py ++++ b/git/refs/head.py +@@ -287,7 +287,7 @@ class Head(Reference): + """ + if not allow_unsafe_options: + Git.check_unsafe_options( +- options=Git._option_candidates([], kwargs), ++ options=Git._option_candidates([self], kwargs), + unsafe_options=Git.unsafe_git_pathspec_from_file_options, + ) + kwargs["f"] = force +diff --git a/test/test_refs.py b/test/test_refs.py +index 127422b..ba4849a 100644 +--- a/test/test_refs.py ++++ b/test/test_refs.py +@@ -265,6 +265,25 @@ class TestRefs(TestBase): + pathspec_file_nul=True, + **{option_name: str(pathspecs)}, + ) ++ for option_name in ("--pathspec-from-file", "--pathspec-from"): ++ branch = Head(rw_repo, f"refs/heads/{option_name}={pathspecs}") ++ with self.assertRaises(UnsafeOptionError): ++ branch.checkout() ++ ++ def test_cloned_head_checkout_rejects_pathspec_from_file(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ base_dir = Path(tdir) ++ with self._repo_with_initial_commit(base_dir) as source: ++ branch = source.create_head("--pathspec-from-file=pathspecs") ++ source.head.reference = branch ++ with Repo.clone_from(source.working_tree_dir, base_dir / "clone") as cloned: ++ (base_dir / "clone" / "pathspecs").write_text("unmatched-private-content\n", encoding="utf-8") ++ assert cloned.active_branch.name == branch.name ++ with self.assertRaises(UnsafeOptionError): ++ cloned.active_branch.checkout() ++ with self.assertRaises(GitCommandError) as error: ++ cloned.active_branch.checkout(allow_unsafe_options=True) ++ assert "unmatched-private-content" in str(error.exception) + + @with_rw_repo("HEAD") + def test_head_reset_rejects_pathspec_from_file(self, rw_repo): diff -Nru python-git-3.1.44/debian/patches/CVE-2026-110860.patch python-git-3.1.44/debian/patches/CVE-2026-110860.patch --- python-git-3.1.44/debian/patches/CVE-2026-110860.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-110860.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,750 @@ +From: Byron +Date: Thu, 10 Sep 2026 13:06:52 +0200 +Subject: Reject submodule paths through intermediate symlinks + (GHSA-gq48-pqfc-9p58) + +Included commits: + 24b6f95d fix: Reject submodule move destinations through intermediate symlinks + 43a43cd8 fix: Validate submodule checkout and metadata paths before mutation + 2bfd829f fix: Preserve submodule paths and release checkout handles on Windows + 415c2117 test: Simplify submodule path simulation and verify metadata removal + 958002b4 test: Disable automatic maintenance in remote timeout tests + e9e271bd fix: Clone into dangling submodule metadata symlink targets on Windows + 2aca1778 test: Pass strings to readlink for Windows Python 3.7 + +Origin: backport: https://github.com/gitpython-developers/GitPython/commit/fb7e089ee0a3394c67057c11bb7f92682ac7960d +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-110860 +--- + git/objects/submodule/base.py | 107 +++++++++-- + test/test_remote.py | 22 ++- + test/test_submodule.py | 418 ++++++++++++++++++++++++++++++++++++++++++ + 3 files changed, 517 insertions(+), 30 deletions(-) + +diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py +index add762c..a02422c 100644 +--- a/git/objects/submodule/base.py ++++ b/git/objects/submodule/base.py +@@ -9,6 +9,7 @@ import logging + import ntpath + import os + import os.path as osp ++from pathlib import Path + import shlex + import stat + import sys +@@ -252,7 +253,7 @@ class Submodule(IndexObject, TraversableIterableObj): + # END handle parent_commit + fp_module: Union[str, BytesIO] + if not repo.bare and parent_matches_head and repo.working_tree_dir: +- fp_module = osp.join(repo.working_tree_dir, cls.k_modules_file) ++ fp_module = cls._checked_abspath(repo.working_tree_dir, cls.k_modules_file) + else: + assert parent_commit is not None, "need valid parent_commit in bare repositories" + try: +@@ -318,7 +319,7 @@ class Submodule(IndexObject, TraversableIterableObj): + if cls._need_gitfile_submodules(parent_repo.git): + return osp.join(parent_repo.git_dir, "modules", cls._validated_name(name)) + if parent_repo.working_tree_dir: +- return osp.join(parent_repo.working_tree_dir, path) ++ return cls._checked_abspath(parent_repo.working_tree_dir, cls._to_relative_path(parent_repo, path)) + raise NotADirectoryError() + + @classmethod +@@ -357,8 +358,11 @@ class Submodule(IndexObject, TraversableIterableObj): + :param kwargs: + Additional arguments given to :manpage:`git-clone(1)`. + """ ++ path = cls._to_relative_path(repo, path) ++ if repo.working_tree_dir is None: ++ raise NotADirectoryError("Submodules require a working tree") ++ module_checkout_path = cls._checked_abspath(repo.working_tree_dir, path) + module_abspath = cls._module_abspath(repo, path, name) +- module_checkout_path = module_abspath + if cls._need_gitfile_submodules(repo.git): + if not allow_unsafe_options: + Git.check_unsafe_options(Git._option_candidates([], kwargs), repo.unsafe_git_clone_options) +@@ -369,11 +373,22 @@ class Submodule(IndexObject, TraversableIterableObj): + repo.unsafe_git_clone_options, + ) + allow_unsafe_options = True ++ if osp.islink(module_abspath): ++ # Clone into the target while retaining the metadata alias. Git for ++ # Windows cannot initialize through a dangling directory symlink. ++ # Read the link explicitly for Python 3.7, and remove the Windows ++ # namespace prefix returned by newer Python versions for Git. ++ target = os.readlink(module_abspath) ++ if sys.platform == "win32": ++ if target.startswith("\\\\?\\UNC\\"): ++ target = "\\\\" + target[8:] ++ elif target.startswith("\\\\?\\"): ++ target = target[4:] ++ module_abspath = to_native_path_linux(osp.join(osp.dirname(module_abspath), target)) + kwargs["separate_git_dir"] = module_abspath + module_abspath_dir = osp.dirname(module_abspath) + if not osp.isdir(module_abspath_dir): + os.makedirs(module_abspath_dir) +- module_checkout_path = osp.join(str(repo.working_tree_dir), path) + + clone = git.Repo.clone_from( + url, +@@ -419,13 +434,43 @@ class Submodule(IndexObject, TraversableIterableObj): + root = self.repo.working_tree_dir + if root is None: + return super().abspath +- path = root +- for component in os.fspath(self._to_relative_path(self.repo, self.path)).split("/"): +- path = join_path_native(path, component) ++ return self._checkout_abspath(self._to_relative_path(self.repo, self.path)) ++ ++ def _checkout_abspath(self, relative_path: PathLike, allow_final_symlink: bool = False) -> PathLike: ++ """Check a checkout path already normalized by :meth:`_to_relative_path`.""" ++ return self._checked_abspath(self.repo.working_tree_dir, relative_path, allow_final_symlink) ++ ++ @classmethod ++ def _checked_abspath( ++ cls, root: Union[PathLike, None], relative_path: PathLike, allow_final_symlink: bool = False ++ ) -> str: ++ """Reject symlinks below a trusted root before accessing submodule paths.""" ++ if root is None: ++ raise NotADirectoryError("Submodules require a working tree") ++ path = os.fspath(root) ++ components = to_native_path_linux(relative_path).split("/") ++ for index, component in enumerate(components): ++ path = os.fspath(join_path_native(path, component)) ++ if allow_final_symlink and index == len(components) - 1: ++ break + if osp.islink(path): +- raise ValueError("Submodule checkout path %r contains a symbolic link" % self.path) ++ raise ValueError("Submodule path %r contains a symbolic link" % relative_path) + return path + ++ @staticmethod ++ def _renames(source: PathLike, destination: PathLike) -> None: ++ os.makedirs(osp.dirname(destination), exist_ok=True) ++ os.rename(source, destination) ++ # Match renames() cleanup, but stop before directory symlinks: Windows ++ # rmdir() removes the link even when its target is nonempty. ++ parent = osp.dirname(source) ++ while parent and not osp.islink(parent): ++ try: ++ os.rmdir(parent) ++ except OSError: ++ break ++ parent = osp.dirname(parent) ++ + @classmethod + def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_abspath: PathLike) -> None: + """Write a ``.git`` file containing a (preferably) relative path to the actual +@@ -449,14 +494,19 @@ class Submodule(IndexObject, TraversableIterableObj): + :param module_abspath: + Absolute path to the bare repository. + """ ++ # Git resolves metadata symlinks before interpreting core.worktree. ++ # Path.resolve() also handles Windows symlinks on Python 3.7. ++ module_abspath = str(Path(module_abspath).resolve()) ++ working_tree_dir = str(Path(working_tree_dir).resolve()) + git_file = osp.join(working_tree_dir, ".git") ++ module_config = osp.join(module_abspath, "config") + rela_path = osp.relpath(module_abspath, start=working_tree_dir) + if sys.platform == "win32" and osp.isfile(git_file): + os.remove(git_file) + with open(git_file, "wb") as fp: + fp.write(("gitdir: %s" % rela_path).encode(defenc)) + +- with GitConfigParser(osp.join(module_abspath, "config"), read_only=False, merge_includes=False) as writer: ++ with GitConfigParser(module_config, read_only=False, merge_includes=False) as writer: + writer.set_value( + "core", + "worktree", +@@ -567,6 +617,8 @@ class Submodule(IndexObject, TraversableIterableObj): + name, + url="invalid-temporary", + ) ++ cls._checked_abspath(repo.working_tree_dir, cls.k_modules_file) ++ sm._checkout_abspath(path) + if sm.exists(): + # Reretrieve submodule from tree. + try: +@@ -661,7 +713,9 @@ class Submodule(IndexObject, TraversableIterableObj): + + # We deliberately assume that our head matches our index! + if mrepo: +- sm.binsha = mrepo.head.commit.binsha ++ # Release cat-file processes before callers move the checkout on Windows. ++ with mrepo: ++ sm.binsha = mrepo.head.commit.binsha + index.add([sm], write=True) + + return sm +@@ -1026,7 +1080,8 @@ class Submodule(IndexObject, TraversableIterableObj): + self + + :raise ValueError: +- If the module path existed and was not empty, or was a file. ++ If the module path existed and was not empty, was a file, or had a ++ symbolic link in an intermediate component. + + :note: + Currently the method is not atomic, and it could leave the repository in an +@@ -1044,7 +1099,11 @@ class Submodule(IndexObject, TraversableIterableObj): + return self + # END handle no change + +- module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path) ++ if configuration: ++ self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file) ++ # Validate the source before removing the destination. ++ cur_path = self.abspath ++ module_checkout_abspath = self._checkout_abspath(module_checkout_path, allow_final_symlink=True) + if osp.isfile(module_checkout_abspath): + raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath) + # END handle target files +@@ -1076,10 +1135,9 @@ class Submodule(IndexObject, TraversableIterableObj): + # END handle module + + # Move the module into place if possible. +- cur_path = self.abspath + renamed_module = False + if module and osp.exists(cur_path): +- os.renames(cur_path, module_checkout_abspath) ++ self._renames(cur_path, module_checkout_abspath) + renamed_module = True + + if osp.isfile(osp.join(module_checkout_abspath, ".git")): +@@ -1110,7 +1168,7 @@ class Submodule(IndexObject, TraversableIterableObj): + # END handle configuration flag + except Exception: + if renamed_module: +- os.renames(module_checkout_abspath, cur_path) ++ self._renames(module_checkout_abspath, cur_path) + # END undo module renaming + raise + # END handle undo rename +@@ -1167,6 +1225,12 @@ class Submodule(IndexObject, TraversableIterableObj): + Doesn't work atomically, as failure to remove any part of the submodule will + leave an inconsistent state. + ++ :note: ++ Metadata-directory aliases under ``.git/modules`` are retained. A link ++ directly to the deleted repository becomes dangling; adding or initializing ++ the submodule again recreates its target. Linked parent directories remain ++ available to sibling submodules. ++ + :raise git.exc.InvalidGitRepositoryError: + Thrown if the repository cannot be deleted. + +@@ -1178,6 +1242,8 @@ class Submodule(IndexObject, TraversableIterableObj): + # END handle parameters + + self._validated_name(self.name) ++ if configuration: ++ self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file) + # Recursively remove children of this submodule. + nc = 0 + for csm in self.children(): +@@ -1196,7 +1262,7 @@ class Submodule(IndexObject, TraversableIterableObj): + ################################ + if module and self.module_exists(): + mod = self.module() +- git_dir = mod.git_dir ++ git_dir = str(Path(mod.git_dir).resolve()) + if force: + # Take the fast lane and just delete everything in our module path. + # TODO: If we run into permission problems, we have a highly +@@ -1406,6 +1472,9 @@ class Submodule(IndexObject, TraversableIterableObj): + + self._validated_name(self.name) + self._validated_name(new_name) ++ destination_module_abspath = self._module_abspath(self.repo, self.path, new_name) ++ mod = self.module() ++ self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file) + + # .git/config + with self.repo.config_writer() as pw: +@@ -1422,17 +1491,15 @@ class Submodule(IndexObject, TraversableIterableObj): + self._name = new_name + + # .git/modules +- mod = self.module() + if mod.has_separate_working_tree(): +- destination_module_abspath = self._module_abspath(self.repo, self.path, new_name) + source_dir = mod.git_dir + # Let's be sure the submodule name is not so obviously tied to a directory. + if str(destination_module_abspath).startswith(str(mod.git_dir)): + tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4())) +- os.renames(source_dir, tmp_dir) ++ self._renames(source_dir, tmp_dir) + source_dir = tmp_dir + # END handle self-containment +- os.renames(source_dir, destination_module_abspath) ++ self._renames(source_dir, destination_module_abspath) + if mod.working_tree_dir: + self._write_git_file_and_module_config(mod.working_tree_dir, destination_module_abspath) + # END move separate git repository +diff --git a/test/test_remote.py b/test/test_remote.py +index bd8cbcc..f1d5aeb 100644 +--- a/test/test_remote.py ++++ b/test/test_remote.py +@@ -1082,14 +1082,16 @@ class TestRemote(TestBase): + class TestTimeouts(TestBase): + @with_rw_repo("HEAD", bare=False) + def test_timeout_funcs(self, repo): ++ # Maintenance may outlive a timed-out fetch and race with fixture cleanup. ++ with repo.config_writer() as config: ++ config.set_value("maintenance", "auto", False) ++ config.set_value("gc", "auto", 0) # Older Git versions use auto-gc. ++ + # Force error code to prevent a race condition if the python thread is slow. +- default = Git.AutoInterrupt._status_code_if_terminate +- Git.AutoInterrupt._status_code_if_terminate = -15 +- for function in ["pull", "fetch"]: # Can't get push to time out. +- f = getattr(repo.remotes.origin, function) +- assert f is not None # Make sure these functions exist. +- _ = f() # Make sure the function runs. +- with pytest.raises(GitCommandError, match="kill_after_timeout=0 s"): +- f(kill_after_timeout=0) +- +- Git.AutoInterrupt._status_code_if_terminate = default ++ with mock.patch.object(Git.AutoInterrupt, "_status_code_if_terminate", -15): ++ for function in ["pull", "fetch"]: # Can't get push to time out. ++ f = getattr(repo.remotes.origin, function) ++ assert f is not None # Make sure these functions exist. ++ _ = f() # Make sure the function runs. ++ with pytest.raises(GitCommandError, match="kill_after_timeout=0 s"): ++ f(kill_after_timeout=0) +diff --git a/test/test_submodule.py b/test/test_submodule.py +index 678e6b9..2013034 100644 +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -9,6 +9,7 @@ from pathlib import Path + import shutil + import sys + import tempfile ++from types import SimpleNamespace + from unittest import mock, skipUnless + + import pytest +@@ -79,6 +80,423 @@ def test_submodule_update_preserves_literal_name(tmp_path, monkeypatch, caplog, + + assert "sensitive-value" not in caplog.text + ++@pytest.fixture ++def movable_submodule(tmp_path): ++ """Create a committed local submodule whose logical name stays fixed when moved.""" ++ with git.Repo.init(tmp_path / "source") as source, git.Repo.init(tmp_path / "parent") as parent: ++ (tmp_path / "source" / "file").write_text("content", encoding="utf-8") ++ source.index.add(["file"]) ++ source.index.commit("Create source") ++ with _patch_git_config("protocol.file.allow", "always"): ++ submodule = parent.create_submodule("logical-name", "module", source.working_tree_dir) ++ parent.index.commit("Create submodule") ++ # Release clone handles before Windows moves the checkout. ++ submodule.module().close() ++ yield submodule ++ ++ ++def _move_snapshot(submodule): ++ """Capture index, configuration, and path state to detect side effects of rejected moves.""" ++ parent = submodule.repo ++ with submodule.module() as module: ++ config = Path(module.git_dir, "config").read_bytes() ++ return ( ++ Path(parent.index.path).read_bytes(), ++ Path(parent.working_tree_dir, ".gitmodules").read_bytes(), ++ Path(submodule.abspath, ".git").read_bytes(), ++ config, ++ submodule.path, ++ ) ++ ++ ++@pytest.mark.parametrize("target_kind", ["relative", "absolute", "internal", "dangling"]) ++@pytest.mark.parametrize("configuration,module", [(True, True), (False, True), (True, False)]) ++@pytest.mark.parametrize("absolute_path", [False, True]) ++def test_move_rejects_intermediate_symlink( ++ movable_submodule, tmp_path, target_kind, configuration, module, absolute_path ++): ++ """Reject intermediate symlinks before changing repository state or their targets. ++ ++ Cover relative and absolute destinations in every move mode, including links ++ within the repository and dangling links, which must also be rejected. ++ """ ++ submodule = movable_submodule ++ parent = submodule.repo ++ root = Path(parent.working_tree_dir) ++ target = root / "target" if target_kind == "internal" else tmp_path / "outside" ++ if target_kind != "dangling": ++ target.mkdir() ++ (root / "nested").mkdir() ++ link = root / "nested" / "link" ++ link.symlink_to( ++ target if target_kind == "absolute" else os.path.relpath(target, link.parent), target_is_directory=True ++ ) ++ parent.index.add(["nested/link"]) ++ parent.index.commit("Record layout") ++ tree = parent.git.write_tree() ++ before = _move_snapshot(submodule) ++ destination = root / "nested/link/new/moved" if absolute_path else "nested/link/new/moved" ++ ++ with pytest.raises(ValueError, match="contains a symbolic link"): ++ submodule.move(destination, configuration=configuration, module=module) ++ ++ assert _move_snapshot(submodule) == before ++ assert parent.git.write_tree() == tree ++ assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content" ++ if target_kind == "dangling": ++ assert not target.exists() ++ else: ++ assert list(target.iterdir()) == [] ++ ++ ++@pytest.mark.parametrize("absolute_path", [False, True]) ++def test_move_normal_destination(movable_submodule, absolute_path): ++ """Allow ordinary relative and absolute moves, and make a repeated move a no-op.""" ++ submodule = movable_submodule ++ root = Path(submodule.repo.working_tree_dir) ++ destination = root / "nested/moved" if absolute_path else "nested/moved" ++ assert submodule.move(destination) is submodule ++ assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content" ++ assert not (root / "module").exists() ++ assert submodule.path == "nested/moved" ++ submodule.repo.git.write_tree() ++ before = _move_snapshot(submodule) ++ assert submodule.move(destination) is submodule ++ assert _move_snapshot(submodule) == before ++ ++ ++@pytest.mark.parametrize("kind", ["empty", "nonempty", "file", "dangling"]) ++def test_move_leaf_symlink_compatibility(movable_submodule, tmp_path, kind): ++ """Preserve leaf-symlink replacement without modifying the external target. ++ ++ Moving onto a link to an empty directory replaces the link; nonempty, file, ++ and dangling targets fail without changing repository state. Direct checkout ++ path access must still reject every leaf symlink. ++ """ ++ submodule = movable_submodule ++ root = Path(submodule.repo.working_tree_dir) ++ target = tmp_path / "outside" ++ if kind in ("empty", "nonempty"): ++ target.mkdir() ++ if kind == "nonempty": ++ (target / "keep").write_text("keep", encoding="utf-8") ++ if kind == "file": ++ target.write_text("keep", encoding="utf-8") ++ destination = root / "destination" ++ destination.symlink_to(target, target_is_directory=kind != "file") ++ with pytest.raises(ValueError, match="contains a symbolic link"): ++ Submodule(submodule.repo, Submodule.NULL_BIN_SHA, name="unused", path="destination").abspath ++ before = _move_snapshot(submodule) ++ if kind == "empty": ++ assert submodule.move("destination") is submodule ++ assert not destination.is_symlink() ++ assert (destination / "file").is_file() ++ assert list(target.iterdir()) == [] ++ else: ++ with pytest.raises(OSError if kind == "dangling" else ValueError): ++ submodule.move("destination") ++ assert _move_snapshot(submodule) == before ++ assert destination.is_symlink() ++ if kind == "nonempty": ++ assert (target / "keep").read_text(encoding="utf-8") == "keep" ++ elif kind == "file": ++ assert target.read_text(encoding="utf-8") == "keep" ++ else: ++ assert not target.exists() ++ ++ ++@pytest.mark.parametrize("leaf", [False, True]) ++@pytest.mark.parametrize("dangling", [False, True]) ++@pytest.mark.parametrize("operation", ["add", "clone"]) ++@pytest.mark.parametrize("gitfile", [False, True]) ++def test_clone_rejects_checkout_symlinks(movable_submodule, tmp_path, leaf, dangling, operation, gitfile): ++ """Reject checkout symlinks before add or clone creates metadata or touches the target. ++ ++ Cover leaf and intermediate links, including dangling targets, with both ++ embedded and separate Git directories. ++ """ ++ sm = movable_submodule ++ root = Path(sm.repo.working_tree_dir) ++ target = tmp_path / "outside" ++ if not dangling: ++ target.mkdir() ++ (root / "link").symlink_to(target, target_is_directory=True) ++ path = "link" if leaf else "link/new/module" ++ before = _move_snapshot(sm) ++ with mock.patch.object(Submodule, "_need_gitfile_submodules", return_value=gitfile): ++ with pytest.raises(ValueError, match="contains a symbolic link"): ++ if operation == "add": ++ Submodule.add(sm.repo, "new", path, sm.url) ++ else: ++ Submodule._clone_repo(sm.repo, sm.url, path, "new") ++ assert _move_snapshot(sm) == before ++ assert not (Path(sm.repo.git_dir) / "modules/new").exists() ++ assert not target.exists() if dangling else list(target.iterdir()) == [] ++ ++ ++@pytest.mark.parametrize("link_kind", ["gitmodules", "checkout"]) ++@pytest.mark.parametrize("operation", ["update", "move", "rename", "remove"]) ++def test_submodule_rejects_checkout_and_gitmodules_symlinks(movable_submodule, tmp_path, link_kind, operation): ++ """Reject operations on symlinked checkouts or .gitmodules without side effects. ++ ++ Update, move, rename, and forced removal must preserve the external target, ++ repository configuration, index, checkout, and any existing move destination. ++ """ ++ sm = movable_submodule ++ sm.rename("nested/module") ++ root = Path(sm.repo.working_tree_dir) ++ path = root / (".gitmodules" if link_kind == "gitmodules" else "module") ++ target = tmp_path / "outside" ++ path.rename(target) ++ path.symlink_to(target, target_is_directory=target.is_dir()) ++ before = ( ++ {p.relative_to(target): p.read_bytes() for p in target.rglob("*") if p.is_file()} ++ if target.is_dir() ++ else target.read_bytes() ++ ) ++ config = Path(sm.repo.git_dir, "config").read_bytes() ++ index = Path(sm.repo.index.path).read_bytes() ++ gitmodules = (root / ".gitmodules").read_bytes() ++ (root / "moved").mkdir() ++ with pytest.raises(ValueError, match="contains a symbolic link"): ++ if operation == "update": ++ sm.update() ++ elif operation == "move": ++ sm.move("moved") ++ elif operation == "rename": ++ sm.rename("renamed") ++ else: ++ sm.remove(force=True) ++ after = ( ++ {p.relative_to(target): p.read_bytes() for p in target.rglob("*") if p.is_file()} ++ if target.is_dir() ++ else target.read_bytes() ++ ) ++ assert after == before ++ assert Path(sm.repo.git_dir, "config").read_bytes() == config ++ assert Path(sm.repo.index.path).read_bytes() == index ++ assert (root / ".gitmodules").read_bytes() == gitmodules ++ assert (root / "module/file").read_text() == "content" ++ assert path.is_symlink() ++ assert (root / "moved").is_dir() ++ ++ ++def test_add_closes_checkout_processes(movable_submodule, monkeypatch): ++ """Adding a submodule must not leave a child process holding its checkout open.""" ++ sm = movable_submodule ++ checkout = Path(sm.repo.working_tree_dir, "new") ++ execute = Git.execute ++ processes = [] ++ ++ def capture_process(self, command, *args, **kwargs): ++ result = execute(self, command, *args, **kwargs) ++ if ( ++ kwargs.get("as_process") ++ and "cat-file" in command ++ and Path(self.working_dir).resolve() == checkout.resolve() ++ ): ++ processes.append(result.proc) ++ return result ++ ++ monkeypatch.setattr(Git, "execute", capture_process) ++ try: ++ added = Submodule.add(sm.repo, "new", "new", sm.url) ++ assert processes, "The HEAD read must exercise a persistent cat-file process" ++ assert all(process.poll() is not None for process in processes) ++ added.move("moved") ++ finally: ++ for process in processes: ++ if process.poll() is None: ++ process.terminate() ++ process.wait() ++ ++ ++@pytest.fixture ++def windows_directory_symlink_removal(monkeypatch): ++ """Exercise Windows rmdir semantics on POSIX, where rmdir rejects symlinks.""" ++ if sys.platform != "win32": ++ original_rmdir = os.rmdir ++ ++ def rmdir(path, *args, **kwargs): ++ if osp.islink(path): ++ return os.unlink(path, *args, **kwargs) ++ return original_rmdir(path, *args, **kwargs) ++ ++ monkeypatch.setattr(os, "rmdir", rmdir) ++ ++ ++def test_submodule_allows_symlink_above_worktree( ++ movable_submodule, tmp_path, windows_directory_symlink_removal, metadata_realpath ++): ++ """Allow adding and moving submodules when the parent is opened through a symlink.""" ++ sm = movable_submodule ++ alias = tmp_path / "alias" ++ alias.symlink_to(sm.repo.working_tree_dir, target_is_directory=True) ++ with git.Repo(alias) as parent: ++ added = Submodule.add(parent, "new", "new", sm.url) ++ added.move("moved") ++ assert alias.is_symlink() ++ with added.module() as module: ++ assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(added.abspath).resolve() ++ assert Path(added.abspath, "file").read_text() == "content" ++ ++ ++@pytest.fixture(params=[False, True], ids=["native-realpath", "windows37-realpath"]) ++def metadata_realpath(request): ++ """Model Python 3.7 on Windows without altering pathlib's own resolver.""" ++ if request.param: ++ paths = SimpleNamespace(**vars(osp)) ++ paths.realpath = osp.abspath ++ with mock.patch("git.objects.submodule.base.osp", paths): ++ yield ++ else: ++ yield ++ ++ ++@pytest.mark.parametrize("operation", ["add", "reconnect", "rename"]) ++def test_submodule_allows_metadata_destination_symlinks(movable_submodule, tmp_path, operation, metadata_realpath): ++ """Allow linked metadata destinations while keeping the checkout correctly connected. ++ ++ Adding, reconnecting after deinit, and renaming may store metadata outside the ++ parent repository through a symlink under .git/modules, preserving that link. ++ """ ++ sm = movable_submodule ++ root = Path(sm.repo.working_tree_dir) ++ outside = tmp_path / "outside" ++ outside.mkdir() ++ link = Path(sm.repo.git_dir) / "modules/link" ++ link.symlink_to(outside, target_is_directory=True) ++ if operation == "rename": ++ sm.rename("link/new") ++ else: ++ sm = Submodule.add(sm.repo, "link/new", "new", sm.url) ++ if operation == "reconnect": ++ sm.repo.index.commit("Add linked metadata submodule") ++ sm.repo.git.submodule("deinit", "--force", "new") ++ sm.update(init=True) ++ assert link.is_symlink() ++ assert (outside / "new/HEAD").is_file() ++ with sm.module() as module: ++ assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve() ++ assert Path(sm.abspath, "file").read_text() == "content" ++ assert (root / ".gitmodules").is_file() ++ ++ ++@pytest.mark.parametrize("kind", ["modules", "intermediate", "leaf", "gitfile", "config", "alias"]) ++@pytest.mark.parametrize("operation", ["update", "move", "rename", "remove"]) ++def test_submodule_allows_existing_metadata_symlinks( ++ movable_submodule, tmp_path, kind, operation, windows_directory_symlink_removal, metadata_realpath ++): ++ """Keep submodule operations compatible with existing symlinks in Git metadata. ++ ++ Cover linked metadata directories, gitfiles, configs, and internal aliases. ++ Update, move, and rename must retain a usable checkout; forced removal must ++ still remove both the checkout and the resolved metadata directory. ++ """ ++ sm = movable_submodule ++ sm.rename("nested/module") ++ root = Path(sm.repo.working_tree_dir) ++ modules = Path(sm.repo.git_dir) / "modules" ++ paths = { ++ "modules": modules, ++ "intermediate": modules / "nested", ++ "leaf": modules / "nested/module", ++ "gitfile": root / "module/.git", ++ "config": modules / "nested/module/config", ++ "alias": modules / "alias", ++ } ++ link = paths[kind] ++ target = tmp_path / "outside" ++ if kind == "alias": ++ target = modules / "nested" ++ (root / "module/.git").write_text("gitdir: ../.git/modules/alias/module") ++ else: ++ link.rename(target) ++ link.symlink_to(target, target_is_directory=target.is_dir()) ++ # Relocating metadata changes the base of a relative core.worktree setting. ++ sm.repo.git.config("--file", str(modules / "nested/module/config"), "core.worktree", str(root / "module")) ++ assert sm.module_exists() ++ if operation == "remove": ++ metadata_dir = (modules / "nested/module").resolve() ++ assert metadata_dir.is_dir() ++ url = sm.url ++ sm.remove(force=True) ++ assert not (root / "module").exists() ++ assert not metadata_dir.exists() ++ if kind in ("modules", "intermediate", "alias"): ++ assert link.is_symlink() and link.is_dir() ++ replacement = Submodule.add(sm.repo, "nested/module", "module", url) ++ if kind == "leaf": ++ assert link.is_symlink() and link.is_dir() ++ assert target.is_dir() ++ with replacement.module() as module: ++ assert Path(module.git.rev_parse("--show-toplevel")).resolve() == (root / "module").resolve() ++ return ++ if operation == "update": ++ sm.update() ++ elif operation == "move": ++ sm.move("moved") ++ else: ++ sm.rename("renamed") ++ if kind == "modules" or (operation == "rename" and kind in ("intermediate", "alias")): ++ assert link.is_symlink() ++ assert link.is_dir() ++ if operation == "rename" and kind == "leaf": ++ assert (modules / "renamed").is_symlink() ++ assert target.is_dir() ++ with sm.module() as module: ++ assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve() ++ assert Path(sm.abspath, "file").read_text() == "content" ++ ++ ++@pytest.mark.parametrize("kind", ["modules", "intermediate", "leaf"]) ++def test_remove_linked_metadata_keeps_siblings_and_can_reinitialize( ++ movable_submodule, tmp_path, kind, metadata_realpath ++): ++ sm = movable_submodule ++ sm.rename("nested/module") ++ sibling = Submodule.add(sm.repo, "nested/sibling", "sibling", sm.url) ++ sm.repo.index.commit("Add sibling") ++ modules = Path(sm.repo.git_dir) / "modules" ++ link = {"modules": modules, "intermediate": modules / "nested", "leaf": modules / "nested/module"}[kind] ++ target = tmp_path / "outside" ++ link.rename(target) ++ link.symlink_to(target, target_is_directory=True) ++ for child in (sm, sibling): ++ sm.repo.git.config("--file", str(modules / child.name / "config"), "core.worktree", str(child.abspath)) ++ ++ sm.remove(force=True, configuration=False) ++ ++ assert link.is_symlink() ++ assert link.exists() == (kind != "leaf") ++ with sibling.module() as module: ++ assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sibling.abspath).resolve() ++ assert Path(sibling.abspath, "file").read_text() == "content" ++ sm.update(init=True) ++ assert link.is_symlink() and link.is_dir() ++ assert Path(sm.abspath, "file").read_text() == "content" ++ with sm.module() as module: ++ assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve() ++ ++ ++@pytest.mark.parametrize("relative_target", [False, True], ids=["absolute-target", "relative-target"]) ++def test_add_to_dangling_metadata_symlink(movable_submodule, tmp_path, metadata_realpath, relative_target): ++ sm = movable_submodule ++ link = Path(sm.repo.git_dir) / "modules/new" ++ target = tmp_path / "missing" / "metadata" ++ link.symlink_to(osp.relpath(target, link.parent) if relative_target else target, target_is_directory=True) ++ link_target = os.readlink(str(link)) ++ ++ added = Submodule.add(sm.repo, "new", "new", sm.url) ++ ++ assert link.is_symlink() and link.is_dir() ++ assert os.readlink(str(link)) == link_target ++ assert (target / "HEAD").is_file() ++ with added.module() as module: ++ assert Path(module.git_dir).resolve() == target.resolve() ++ assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(added.abspath).resolve() ++ assert Path(added.abspath, "file").read_text() == "content" ++ + + class TestRootProgress(RootUpdateProgress): + """Just prints messages, for now without checking the correctness of the states""" diff -Nru python-git-3.1.44/debian/patches/CVE-2026-114145.patch python-git-3.1.44/debian/patches/CVE-2026-114145.patch --- python-git-3.1.44/debian/patches/CVE-2026-114145.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-114145.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,551 @@ +From: Byron +Date: Thu, 1 Oct 2026 19:17:13 +0200 +Subject: fix(submodule): validate destinations before mutation + + + +Pretty much a rubber-stamp. It won't be out there long as the replacement +with CLI + Gix is already on the way. + + +Submodule checkout destinations could pass the containment check and be +rejected by the index only after cloning had changed the filesystem. This +addresses `GHSA-83vg-56qc-22m7` at the shared destination boundaries, including +initialization and moves as well as creation. + +Reuse `_validate_repo_path` before checkout mutations to enforce portable +NTFS/HFS metadata-alias checks and invalid-path rejection. Validate Windows +filenames and submodule-name NULs before creating directories. Compare path +components with `Repo.git_dir` and `Repo.common_dir` by filesystem identity, +so separately named metadata directories and their aliases are protected too. + +Reject metadata destinations nested inside another submodule's Git directory +before cloning, reuse, or renaming. Repeat the check after cloning and disable +a clone that became nested. Preflight implicit metadata renames during moves, +while preserving supported metadata symlinks and relocation of a submodule's +own metadata directory. + +Git reference: `d38352cd43ab9745686d697872408bc3249a153f`, particularly +`read-cache.c::verify_path_internal`, NTFS/HFS recognition, +`compat/mingw.c::is_valid_win32_path`, and +`submodule.c::validate_submodule_git_dir`. Related Git tests are in +`t/t7450-bad-git-dotfiles.sh` and `t/t7406-submodule-update.sh`. + +Regression tests first demonstrated writes before rejection and acceptance +of nested and separately named metadata destinations. Tests use harmless +file content and compare portable aliases with native Git index validation. +Coverage includes all 16 HFS ignored characters, Windows filename rules, +relative and absolute paths, metadata reuse, and nesting during cloning. + +Assisted-by: GPT 6.0 Astra +Co-authored-by: GPT 6.0 Astra + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/97eadb888d2f99567e3113f4e455be4ca128f697 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/TEMP-0000000-AFF94E + +--- + git/objects/submodule/base.py | 92 +++++++++++++++-- + git/util.py | 28 +++++ + test/test_submodule.py | 224 ++++++++++++++++++++++++++++++++++++++++++ + 3 files changed, 333 insertions(+), 11 deletions(-) + +--- a/git/objects/submodule/base.py ++++ b/git/objects/submodule/base.py +@@ -9,6 +9,7 @@ import logging + import ntpath + import os + import os.path as osp ++import re + from pathlib import Path + import shlex + import stat +@@ -30,6 +31,7 @@ from git.objects.util import Traversable + from git.util import ( + IterableList, + RemoteProgress, ++ _validate_repo_path, + join_path_native, + rmtree, + to_native_path_linux, +@@ -307,19 +309,55 @@ class Submodule(IndexObject, Traversable + def _validated_name(cls, name: str) -> str: + if ( + not name ++ or "\0" in name + or name.startswith(("/", "\\")) + or ntpath.splitdrive(name)[0] + or ".." in name.replace("\\", "/").split("/") + ): + raise ValueError("Invalid submodule name %r" % name) ++ cls._validate_windows_path(name) + return name + ++ @staticmethod ++ def _validate_windows_path(path: PathLike) -> None: ++ """Apply Git for Windows' filename checks before creating directories.""" ++ if sys.platform == "win32": ++ for component in ntpath.splitdrive(os.fspath(path))[1].replace("\\", "/").split("/"): ++ if component in (".", ".."): ++ continue ++ stem = component.split(".", 1)[0].rstrip(" ").upper() ++ if ( ++ component.endswith((" ", ".")) ++ or any(ord(char) < 32 or char in '<>:"|?*' for char in component) ++ or re.fullmatch(r"CON(?:IN\$|OUT\$)?|PRN|AUX|NUL|COM[1-9]|LPT[1-9]", stem) ++ ): ++ raise ValueError("Invalid submodule path on Windows: %r" % path) ++ + @classmethod +- def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> PathLike: ++ def _module_abspath( ++ cls, parent_repo: "Repo", path: PathLike, name: str, *, moving_from: Union[PathLike, None] = None ++ ) -> PathLike: ++ """Reject nested Git directories, allowing the source of a pending rename.""" ++ from git.repo.fun import is_git_dir ++ ++ name = cls._validated_name(name) + if cls._need_gitfile_submodules(parent_repo.git): ++ directory = osp.join(parent_repo.git_dir, "modules") ++ for component in to_native_path_linux(name).split("/")[:-1]: ++ directory = osp.join(directory, component) ++ if is_git_dir(directory) and ( ++ moving_from is None or Path(directory).resolve() != Path(moving_from).resolve() ++ ): ++ raise ValueError( ++ "Submodule metadata for %r is inside another Git directory: %r" % (name, directory) ++ ) + return osp.join(parent_repo.git_dir, "modules", cls._validated_name(name)) + if parent_repo.working_tree_dir: +- return cls._checked_abspath(parent_repo.working_tree_dir, cls._to_relative_path(parent_repo, path)) ++ return cls._checked_abspath( ++ parent_repo.working_tree_dir, ++ cls._to_relative_path(parent_repo, path), ++ git_dirs=(parent_repo.git_dir, parent_repo.common_dir), ++ ) + raise NotADirectoryError() + + @classmethod +@@ -361,7 +399,9 @@ class Submodule(IndexObject, Traversable + path = cls._to_relative_path(repo, path) + if repo.working_tree_dir is None: + raise NotADirectoryError("Submodules require a working tree") +- module_checkout_path = cls._checked_abspath(repo.working_tree_dir, path) ++ module_checkout_path = cls._checked_abspath( ++ repo.working_tree_dir, path, git_dirs=(repo.git_dir, repo.common_dir) ++ ) + module_abspath = cls._module_abspath(repo, path, name) + if cls._need_gitfile_submodules(repo.git): + if not allow_unsafe_options: +@@ -398,6 +438,16 @@ class Submodule(IndexObject, Traversable + **kwargs, + ) + if cls._need_gitfile_submodules(repo.git): ++ # A concurrent clone may have turned a leading directory into a repository. ++ try: ++ cls._module_abspath(repo, path, name) ++ except ValueError: ++ clone.close() ++ try: ++ os.remove(osp.join(clone.git_dir, "HEAD")) ++ except FileNotFoundError: ++ pass ++ raise + cls._write_git_file_and_module_config(module_checkout_path, module_abspath) + + return clone +@@ -407,8 +457,9 @@ class Submodule(IndexObject, Traversable + """:return: A path guaranteed to be relative to the given parent repository + + :raise ValueError: +- If path is not contained in the parent repository's working tree. ++ If path is outside the working tree or is unsafe as a submodule checkout. + """ ++ cls._validate_windows_path(path) + path = to_native_path_linux(path) + if path.endswith("/"): + path = path[:-1] +@@ -427,6 +478,7 @@ class Submodule(IndexObject, Traversable + # END verify converted relative path makes sense + # END convert to a relative path + ++ _validate_repo_path(path) + return path + + @property +@@ -438,22 +490,35 @@ class Submodule(IndexObject, Traversable + + def _checkout_abspath(self, relative_path: PathLike, allow_final_symlink: bool = False) -> PathLike: + """Check a checkout path already normalized by :meth:`_to_relative_path`.""" +- return self._checked_abspath(self.repo.working_tree_dir, relative_path, allow_final_symlink) ++ return self._checked_abspath( ++ self.repo.working_tree_dir, ++ relative_path, ++ allow_final_symlink, ++ git_dirs=(self.repo.git_dir, self.repo.common_dir), ++ ) + + @classmethod + def _checked_abspath( +- cls, root: Union[PathLike, None], relative_path: PathLike, allow_final_symlink: bool = False ++ cls, ++ root: Union[PathLike, None], ++ relative_path: PathLike, ++ allow_final_symlink: bool = False, ++ *, ++ git_dirs: Sequence[PathLike] = (), + ) -> str: +- """Reject symlinks below a trusted root before accessing submodule paths.""" ++ """Reject symlinks and checkout aliases of Git directories below a trusted root.""" + if root is None: + raise NotADirectoryError("Submodules require a working tree") + path = os.fspath(root) ++ metadata_dirs = set(git_dirs) + components = to_native_path_linux(relative_path).split("/") + for index, component in enumerate(components): + path = os.fspath(join_path_native(path, component)) +- if allow_final_symlink and index == len(components) - 1: +- break ++ if metadata_dirs and osp.exists(path) and any(osp.samefile(path, directory) for directory in metadata_dirs): ++ raise ValueError("Submodule checkout path aliases Git metadata: %r" % relative_path) + if osp.islink(path): ++ if allow_final_symlink and index == len(components) - 1: ++ break + raise ValueError("Submodule path %r contains a symbolic link" % relative_path) + return path + +@@ -1103,6 +1168,11 @@ class Submodule(IndexObject, Traversable + self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file) + # Validate the source before removing the destination. + cur_path = self.abspath ++ module_abspath = self._module_abspath(self.repo, self.path, self.name) ++ if self.path == self.name: ++ self._module_abspath( ++ self.repo, module_checkout_path, os.fspath(module_checkout_path), moving_from=module_abspath ++ ) + module_checkout_abspath = self._checkout_abspath(module_checkout_path, allow_final_symlink=True) + if osp.isfile(module_checkout_abspath): + raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath) +@@ -1141,7 +1211,6 @@ class Submodule(IndexObject, Traversable + renamed_module = True + + if osp.isfile(osp.join(module_checkout_abspath, ".git")): +- module_abspath = self._module_abspath(self.repo, self.path, self.name) + self._write_git_file_and_module_config(module_checkout_abspath, module_abspath) + # END handle git file rewrite + # END move physical module +@@ -1472,8 +1541,8 @@ class Submodule(IndexObject, Traversable + + self._validated_name(self.name) + self._validated_name(new_name) +- destination_module_abspath = self._module_abspath(self.repo, self.path, new_name) + mod = self.module() ++ destination_module_abspath = self._module_abspath(self.repo, self.path, new_name, moving_from=mod.git_dir) + self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file) + + # .git/config +@@ -1523,6 +1592,7 @@ class Submodule(IndexObject, Traversable + """ + self._validated_name(self.name) + module_checkout_abspath = self.abspath ++ self._module_abspath(self.repo, self.path, self.name) + try: + repo = git.Repo(module_checkout_abspath) + if repo != self.repo: +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -84,6 +84,7 @@ def test_submodule_update_preserves_lite + def movable_submodule(tmp_path): + """Create a committed local submodule whose logical name stays fixed when moved.""" + with git.Repo.init(tmp_path / "source") as source, git.Repo.init(tmp_path / "parent") as parent: ++ source.git.symbolic_ref("HEAD", "refs/heads/master") + (tmp_path / "source" / "file").write_text("content", encoding="utf-8") + source.index.add(["file"]) + source.index.commit("Create source") +@@ -109,6 +110,206 @@ def _move_snapshot(submodule): + ) + + ++@pytest.mark.parametrize( ++ "path", ++ [ ++ ".git/child", ++ ".GiT/child", ++ "nested/.git/child", ++ "git~1/child", ++ "GIT~1 . /child", ++ ".git. /child", ++ ".git:stream/child", ++ ".git::$INDEX_ALLOCATION/child", ++ "nested\\.git\\child", ++ "C:relative", ++ "nul\0name", ++ ] ++ + [ ++ f".g{chr(codepoint)}it/child" ++ for codepoint in (*range(0x200C, 0x2010), *range(0x202A, 0x202F), *range(0x206A, 0x2070), 0xFEFF) ++ ], ++) ++@pytest.mark.parametrize("operation", ["add", "clone", "update", "move", "move-module", "move-config"]) ++def test_submodule_rejects_unsafe_checkout_before_mutation(movable_submodule, tmp_path, path, operation): ++ sm = movable_submodule ++ root = Path(sm.repo.working_tree_dir) ++ before = _move_snapshot(sm) ++ paths = set(root.rglob("*")) ++ # Check the portable metadata aliases against Git's index validation as well. ++ if operation == "clone" and path not in ("C:relative", "nul\0name"): ++ with sm.repo.git.custom_environment(GIT_INDEX_FILE=str(tmp_path / "validation-index")): ++ with _patch_git_config("core.protectHFS", "true"), _patch_git_config("core.protectNTFS", "true"): ++ with pytest.raises(GitCommandError): ++ sm.repo.git.update_index("--add", "--cacheinfo", f"160000,{sm.hexsha},{path}") ++ with mock.patch.object(git.Repo, "clone_from", side_effect=AssertionError("clone attempted")): ++ with pytest.raises(ValueError): ++ if operation == "add": ++ sm.repo.create_submodule("new", path, sm.url) ++ elif operation == "clone": ++ Submodule._clone_repo(sm.repo, sm.url, path, "new") ++ elif operation == "update": ++ Submodule(sm.repo, sm.binsha, name="new", path=path, url=sm.url).update(init=True) ++ else: ++ sm.move(path, configuration=operation != "move-module", module=operation != "move-config") ++ assert _move_snapshot(sm) == before ++ assert set(root.rglob("*")) == paths ++ ++ ++def test_add_rejects_metadata_checkout_without_writing_files(movable_submodule): ++ sm = movable_submodule ++ before = _move_snapshot(sm) ++ with pytest.raises(ValueError, match="Git metadata"): ++ sm.repo.create_submodule("new", ".git/new", sm.url) ++ assert not Path(sm.repo.git_dir, "new").exists() ++ assert not Path(sm.repo.git_dir, "modules/new").exists() ++ assert _move_snapshot(sm) == before ++ ++ ++@pytest.mark.parametrize("absolute_path", [False, True]) ++@pytest.mark.parametrize("metadata_name", ["metadata", "MeTaDaTa"]) ++@pytest.mark.parametrize("operation", ["add", "clone", "update", "move"]) ++def test_submodule_rejects_checkout_in_separate_metadata( ++ movable_submodule, tmp_path, absolute_path, metadata_name, operation ++): ++ root = tmp_path / "separate" ++ with git.Repo.init(root, separate_git_dir=str(root / "metadata"), allow_unsafe_options=True) as parent: ++ if not (root / metadata_name).is_dir(): ++ pytest.skip("Requires a case-insensitive filesystem") ++ sm = parent.create_submodule("module", "module", movable_submodule.url) ++ parent.index.commit("Add submodule") ++ before = _move_snapshot(sm) ++ paths = set(root.rglob("*")) ++ path = root / metadata_name / "new" if absolute_path else f"{metadata_name}/new" ++ with pytest.raises(ValueError, match="Git metadata"): ++ if operation == "add": ++ parent.create_submodule("new", path, sm.url) ++ elif operation == "clone": ++ Submodule._clone_repo(parent, sm.url, path, "new") ++ elif operation == "update": ++ Submodule(parent, sm.binsha, name="new", path=path, url=sm.url).update(init=True) ++ else: ++ sm.move(path) ++ assert _move_snapshot(sm) == before ++ assert set(root.rglob("*")) == paths ++ ++ ++@pytest.mark.parametrize("operation", ["add", "clone", "update", "rename", "move"]) ++def test_submodule_rejects_nested_metadata_before_mutation(movable_submodule, operation): ++ sm = movable_submodule ++ other = sm.repo.create_submodule("other", "other", sm.url) ++ other.module().close() ++ root = Path(sm.repo.working_tree_dir) ++ before = _move_snapshot(sm), _move_snapshot(other) ++ paths = set(root.rglob("*")) ++ name = f"{sm.name}/child" ++ with pytest.raises(ValueError, match="inside.*Git directory"): ++ if operation == "add": ++ sm.repo.create_submodule(name, "new", sm.url) ++ elif operation == "clone": ++ Submodule._clone_repo(sm.repo, sm.url, "new", name) ++ elif operation == "update": ++ Submodule(sm.repo, sm.binsha, name=name, path="new", url=sm.url).update(init=True) ++ elif operation == "rename": ++ other.rename(name) ++ else: ++ other.move(name) ++ assert (_move_snapshot(sm), _move_snapshot(other)) == before ++ assert set(root.rglob("*")) == paths ++ ++ ++@pytest.mark.parametrize("state", ["retained", "checked-out"]) ++def test_update_rejects_existing_nested_metadata(movable_submodule, state): ++ sm = movable_submodule ++ name = f"{sm.name}/child" ++ checkout = Path(sm.repo.working_tree_dir, "new") ++ with git.Repo.clone_from( ++ sm.url, checkout, separate_git_dir=str(Path(sm.repo.git_dir, "modules", name)), allow_unsafe_options=True ++ ): ++ pass ++ if state == "retained": ++ shutil.rmtree(checkout) ++ before = _move_snapshot(sm) ++ paths = set(Path(sm.repo.working_tree_dir).rglob("*")) ++ with pytest.raises(ValueError, match="inside.*Git directory"): ++ Submodule(sm.repo, sm.binsha, name=name, path="new", url=sm.url).update(init=True) ++ assert _move_snapshot(sm) == before ++ assert set(Path(sm.repo.working_tree_dir).rglob("*")) == paths ++ ++ ++@pytest.mark.parametrize( ++ "path", ++ [ ++ "CON", ++ "con.txt", ++ "CONIN$", ++ "conout$.txt", ++ "AUX .txt", ++ "PRN", ++ "NUL", ++ "COM9", ++ "LPT1", ++ "name:stream", ++ "space ", ++ "period.", ++ "line\nbreak", ++ "star*", ++ 'quote"', ++ "question?", ++ "angle<", ++ "angle>", ++ "pipe|", ++ ], ++) ++def test_submodule_rejects_windows_destination_names_before_mutation(movable_submodule, path): ++ sm = movable_submodule ++ before = _move_snapshot(sm) ++ paths = set(Path(sm.repo.working_tree_dir).rglob("*")) ++ with mock.patch("git.objects.submodule.base.sys", SimpleNamespace(platform="win32")): ++ with mock.patch.object(git.Repo, "clone_from", side_effect=AssertionError("clone attempted")): ++ with pytest.raises(ValueError): ++ sm.repo.create_submodule("new", f"nested/{path}", sm.url) ++ with pytest.raises(ValueError): ++ sm.repo.create_submodule(f"nested/{path}", "new", sm.url) ++ assert _move_snapshot(sm) == before ++ assert set(Path(sm.repo.working_tree_dir).rglob("*")) == paths ++ ++ ++@pytest.mark.parametrize("path", ["nested/space ", "nested/period."]) ++def test_windows_destination_validation_precedes_normalization(tmp_path, path): ++ parent = SimpleNamespace(working_tree_dir=str(tmp_path)) ++ # Windows' GetFullPathName removes trailing spaces and periods. ++ with mock.patch("git.objects.submodule.base.sys", SimpleNamespace(platform="win32")): ++ with pytest.raises(ValueError, match="Invalid submodule path on Windows"): ++ Submodule._to_relative_path(parent, path) ++ ++ ++def test_submodule_can_relocate_its_own_metadata(movable_submodule): ++ sm = movable_submodule ++ sm.rename(f"{sm.name}/child") ++ assert Path(sm.abspath, "file").read_text() == "content" ++ with sm.module() as module: ++ assert Path(module.git_dir) == Path(sm.repo.git_dir, "modules", sm.name) ++ ++ ++def test_clone_disables_metadata_that_becomes_nested(movable_submodule, monkeypatch): ++ sm = movable_submodule ++ clone_from = git.Repo.clone_from ++ ancestor = Path(sm.repo.git_dir, "modules/new") ++ ++ def clone_and_create_ancestor(*args, **kwargs): ++ clone = clone_from(*args, **kwargs) ++ with git.Repo.init(ancestor, bare=True): ++ pass ++ return clone ++ ++ monkeypatch.setattr(git.Repo, "clone_from", clone_and_create_ancestor) ++ with pytest.raises(ValueError, match="inside.*Git directory"): ++ Submodule._clone_repo(sm.repo, sm.url, "new", "new/child") ++ assert not (ancestor / "child/HEAD").exists() ++ assert (ancestor / "HEAD").is_file() ++ ++ + @pytest.mark.parametrize("target_kind", ["relative", "absolute", "internal", "dangling"]) + @pytest.mark.parametrize("configuration,module", [(True, True), (False, True), (True, False)]) + @pytest.mark.parametrize("absolute_path", [False, True]) +@@ -165,6 +366,28 @@ def test_move_normal_destination(movable + assert _move_snapshot(submodule) == before + + ++@pytest.mark.parametrize("metadata_dir", ["git_dir", "common_dir"]) ++def test_move_rejects_leaf_symlink_to_metadata(movable_submodule, tmp_path, metadata_dir): ++ root = tmp_path / "worktree" ++ movable_submodule.repo.git.worktree("add", "--detach", str(root)) ++ with git.Repo(root) as parent: ++ assert not osp.samefile(parent.git_dir, parent.common_dir) ++ submodule = parent.submodules[0] ++ submodule.update(init=True) ++ target = Path(getattr(parent, metadata_dir)) ++ destination = root / "destination" ++ destination.symlink_to(target, target_is_directory=True) ++ before = _move_snapshot(submodule) ++ ++ with pytest.raises(ValueError, match="Git metadata"): ++ submodule.move("destination", module=False) ++ ++ assert _move_snapshot(submodule) == before ++ assert destination.is_symlink() ++ assert destination.samefile(target) ++ assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content" ++ ++ + @pytest.mark.parametrize("kind", ["empty", "nonempty", "file", "dangling"]) + def test_move_leaf_symlink_compatibility(movable_submodule, tmp_path, kind): + """Preserve leaf-symlink replacement without modifying the external target. +@@ -1233,6 +1456,7 @@ class TestSubmodule(TestBase): + + invalid_names = ( + "", ++ "nul\0name", + "..", + "../module", + R"..\module", +--- a/git/util.py ++++ b/git/util.py +@@ -34,6 +34,7 @@ import contextlib + from functools import wraps + import getpass + import logging ++import ntpath + import os + import os.path as osp + import pathlib +@@ -317,6 +318,33 @@ def join_path_native(a: PathLike, *p: Pa + return to_native_path(join_path(a, *p)) + + ++_HFS_IGNORABLES = str.maketrans( ++ "", "", "\u200c\u200d\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u206a\u206b\u206c\u206d\u206e\u206f\ufeff" ++) ++ ++ ++def _validate_repo_path(path: PathLike) -> None: ++ """Reject unsafe tree/index paths without normalizing away their components. ++ ++ Protect Git metadata aliases on NTFS and HFS even when writing on another ++ platform. Other POSIX filename characters, including newlines, remain valid. ++ """ ++ name = os.fspath(path) ++ if not name or "\0" in name or ntpath.splitdrive(name)[0] or name.startswith("/"): ++ raise ValueError("Invalid repository path %r" % name) ++ if os.name == "nt" and "\\" in name: ++ raise ValueError("Index paths must use '/' separators: %r" % name) ++ for component in name.split("/"): ++ if component in ("", ".", ".."): ++ raise ValueError("Invalid repository path %r" % name) ++ # NTFS recognizes backslashes and alternate data streams in metadata names. ++ for part in component.split("\\"): ++ ntfs_name = part.split(":", 1)[0].rstrip(" .").lower() ++ hfs_name = part.translate(_HFS_IGNORABLES).lower() ++ if ntfs_name in (".git", "git~1") or hfs_name == ".git": ++ raise ValueError("Repository path aliases Git metadata: %r" % name) ++ ++ + def assure_directory_exists(path: PathLike, is_file: bool = False) -> bool: + """Make sure that the directory pointed to by path exists. + diff -Nru python-git-3.1.44/debian/patches/CVE-2026-42215.patch python-git-3.1.44/debian/patches/CVE-2026-42215.patch --- python-git-3.1.44/debian/patches/CVE-2026-42215.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-42215.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,224 @@ +From: w +Date: Mon, 20 Apr 2026 23:29:50 -0400 +Subject: Block unsafe underscored git kwargs / Fix for GHSA-rpm5-65cw-6hj4 + +Origin: https://github.com/gitpython-developers/GitPython/commit/0f68db0710f9125762fca5dbc2328593537ae923 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-42215 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-42215 +Bug-Debian: https://bugs.debian.org/1135349 +--- + git/cmd.py | 28 +++++++++++++++++------- + test/test_clone.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++- + test/test_git.py | 16 ++++++++++++++ + test/test_remote.py | 18 +++++++--------- + 4 files changed, 104 insertions(+), 19 deletions(-) + +diff --git a/git/cmd.py b/git/cmd.py +index 2048a43..3bb3089 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -711,6 +711,21 @@ class Git(metaclass=_GitMeta): + f"The `{protocol}::` protocol looks suspicious, use `allow_unsafe_protocols=True` to allow it." + ) + ++ @classmethod ++ def _canonicalize_option_name(cls, option: str) -> str: ++ """Return the option name used for unsafe-option checks. ++ ++ Examples: ++ ``"--upload-pack=/tmp/helper"`` -> ``"upload-pack"`` ++ ``"upload_pack"`` -> ``"upload-pack"`` ++ ``"--config core.filemode=false"`` -> ``"config"`` ++ """ ++ option_name = option.lstrip("-").split("=", 1)[0] ++ option_tokens = option_name.split(None, 1) ++ if not option_tokens: ++ return "" ++ return dashify(option_tokens[0]) ++ + @classmethod + def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None: + """Check for unsafe options. +@@ -718,15 +733,12 @@ class Git(metaclass=_GitMeta): + Some options that are passed to ``git `` can be used to execute + arbitrary commands. These are blocked by default. + """ +- # Options can be of the form `foo`, `--foo bar`, or `--foo=bar`, so we need to +- # check if they start with "--foo" or if they are equal to "foo". +- bare_unsafe_options = [option.lstrip("-") for option in unsafe_options] ++ # Options can be of the form `foo`, `--foo`, `--foo bar`, or `--foo=bar`. ++ canonical_unsafe_options = {cls._canonicalize_option_name(option): option for option in unsafe_options} + for option in options: +- for unsafe_option, bare_option in zip(unsafe_options, bare_unsafe_options): +- if option.startswith(unsafe_option) or option == bare_option: +- raise UnsafeOptionError( +- f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." +- ) ++ unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option)) ++ if unsafe_option is not None: ++ raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.") + + class AutoInterrupt: + """Process wrapper that terminates the wrapped process on finalization. +diff --git a/test/test_clone.py b/test/test_clone.py +index 126ef00..4a4fba9 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -3,13 +3,72 @@ + + from pathlib import Path + import re ++import tempfile + + import git + +-from test.lib import TestBase, with_rw_directory ++from test.lib import TestBase, with_rw_directory, with_rw_repo ++from git.exc import UnsafeOptionError + + + class TestClone(TestBase): ++ ++ @with_rw_repo("HEAD") ++ def test_clone_unsafe_options(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ tmp_dir = Path(tdir) ++ tmp_file = tmp_dir / "pwn" ++ unsafe_options = [ ++ f"--upload-pack='touch {tmp_file}'", ++ f"-u 'touch {tmp_file}'", ++ "--config=protocol.ext.allow=always", ++ "-c protocol.ext.allow=always", ++ ] ++ for unsafe_option in unsafe_options: ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) ++ assert not tmp_file.exists() ++ ++ unsafe_options = [ ++ {"upload-pack": f"touch {tmp_file}"}, ++ {"upload_pack": f"touch {tmp_file}"}, ++ {"u": f"touch {tmp_file}"}, ++ {"config": "protocol.ext.allow=always"}, ++ {"c": "protocol.ext.allow=always"}, ++ ] ++ for unsafe_option in unsafe_options: ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.clone(tmp_dir, **unsafe_option) ++ assert not tmp_file.exists() ++ ++ @with_rw_repo("HEAD") ++ def test_clone_from_unsafe_options(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ tmp_dir = Path(tdir) ++ tmp_file = tmp_dir / "pwn" ++ unsafe_options = [ ++ f"--upload-pack='touch {tmp_file}'", ++ f"-u 'touch {tmp_file}'", ++ "--config=protocol.ext.allow=always", ++ "-c protocol.ext.allow=always", ++ ] ++ for unsafe_option in unsafe_options: ++ with self.assertRaises(UnsafeOptionError): ++ git.Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[unsafe_option]) ++ assert not tmp_file.exists() ++ ++ unsafe_options = [ ++ {"upload-pack": f"touch {tmp_file}"}, ++ {"upload_pack": f"touch {tmp_file}"}, ++ {"u": f"touch {tmp_file}"}, ++ {"config": "protocol.ext.allow=always"}, ++ {"c": "protocol.ext.allow=always"}, ++ ] ++ for unsafe_option in unsafe_options: ++ with self.assertRaises(UnsafeOptionError): ++ git.Repo.clone_from(rw_repo.working_dir, tmp_dir, **unsafe_option) ++ assert not tmp_file.exists() ++ + @with_rw_directory + def test_checkout_in_non_empty_dir(self, rw_dir): + non_empty_dir = Path(rw_dir) +diff --git a/test/test_git.py b/test/test_git.py +index 94e68ec..886742c 100644 +--- a/test/test_git.py ++++ b/test/test_git.py +@@ -26,6 +26,7 @@ else: + import ddt + + from git import Git, GitCommandError, GitCommandNotFound, Repo, cmd, refresh ++from git.exc import UnsafeOptionError + from git.util import cwd, finalize_process + + from test.lib import TestBase, fixture_path, with_rw_directory +@@ -153,6 +154,21 @@ class TestGit(TestBase): + res = self.git.transform_kwargs(**{"s": True, "t": True}) + self.assertEqual({"-s", "-t"}, set(res)) + ++ def test_check_unsafe_options_normalizes_kwargs(self): ++ cases = [ ++ (["upload_pack"], ["--upload-pack"]), ++ (["receive_pack"], ["--receive-pack"]), ++ (["exec"], ["--exec"]), ++ (["u"], ["-u"]), ++ (["c"], ["-c"]), ++ (["--upload-pack=/tmp/helper"], ["--upload-pack"]), ++ (["--config core.filemode=false"], ["--config"]), ++ ] ++ ++ for options, unsafe_options in cases: ++ with self.assertRaises(UnsafeOptionError): ++ Git.check_unsafe_options(options=options, unsafe_options=unsafe_options) ++ + _shell_cases = ( + # value_in_call, value_from_class, expected_popen_arg + (None, False, False), +diff --git a/test/test_remote.py b/test/test_remote.py +index 5ddb41b..ddf6f4f 100644 +--- a/test/test_remote.py ++++ b/test/test_remote.py +@@ -824,7 +824,7 @@ class TestRemote(TestBase): + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" +- unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] ++ unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.fetch(**unsafe_option) +@@ -892,7 +892,7 @@ class TestRemote(TestBase): + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" +- unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] ++ unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.pull(**unsafe_option) +@@ -961,10 +961,9 @@ class TestRemote(TestBase): + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ +- { +- "receive-pack": f"touch {tmp_file}", +- "exec": f"touch {tmp_file}", +- } ++ {"receive-pack": f"touch {tmp_file}"}, ++ {"receive_pack": f"touch {tmp_file}"}, ++ {"exec": f"touch {tmp_file}"}, + ] + for unsafe_option in unsafe_options: + assert not tmp_file.exists() +@@ -988,10 +987,9 @@ class TestRemote(TestBase): + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ +- { +- "receive-pack": f"touch {tmp_file}", +- "exec": f"touch {tmp_file}", +- } ++ {"receive-pack": f"touch {tmp_file}"}, ++ {"receive_pack": f"touch {tmp_file}"}, ++ {"exec": f"touch {tmp_file}"}, + ] + for unsafe_option in unsafe_options: + # The options will be allowed, but the command will fail. diff -Nru python-git-3.1.44/debian/patches/CVE-2026-42284.patch python-git-3.1.44/debian/patches/CVE-2026-42284.patch --- python-git-3.1.44/debian/patches/CVE-2026-42284.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-42284.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,89 @@ +From: "GPT 5.4" +Date: Tue, 21 Apr 2026 09:30:29 +0800 +Subject: Make sure that multi-options are checked after splitting them with + `shlex` + +Co-authored-by: Sebastian Thiel + +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-42284 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-42284 +Bug-Debian: https://bugs.debian.org/1135350 +--- + git/repo/base.py | 4 ++-- + test/test_clone.py | 18 ++++++++++++++++++ + test/test_submodule.py | 11 +++++++++++ + 3 files changed, 31 insertions(+), 2 deletions(-) + +diff --git a/git/repo/base.py b/git/repo/base.py +index db89cdf..5ad88fe 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -1381,8 +1381,8 @@ class Repo: + Git.check_unsafe_protocols(str(url)) + if not allow_unsafe_options: + Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options) +- if not allow_unsafe_options and multi_options: +- Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options) ++ if not allow_unsafe_options and multi: ++ Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options) + + proc = git.clone( + multi, +diff --git a/test/test_clone.py b/test/test_clone.py +index 4a4fba9..5b8c960 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -41,6 +41,15 @@ class TestClone(TestBase): + rw_repo.clone(tmp_dir, **unsafe_option) + assert not tmp_file.exists() + ++ @with_rw_repo("HEAD") ++ def test_clone_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ tmp_dir = Path(tdir) ++ payload = "--single-branch --config protocol.ext.allow=always" ++ ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.clone(tmp_dir, multi_options=[payload]) ++ + @with_rw_repo("HEAD") + def test_clone_from_unsafe_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: +@@ -69,6 +78,15 @@ class TestClone(TestBase): + git.Repo.clone_from(rw_repo.working_dir, tmp_dir, **unsafe_option) + assert not tmp_file.exists() + ++ @with_rw_repo("HEAD") ++ def test_clone_from_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ tmp_dir = Path(tdir) ++ payload = "--single-branch --config protocol.ext.allow=always" ++ ++ with self.assertRaises(UnsafeOptionError): ++ git.Repo.clone_from(rw_repo.working_dir, tmp_dir, multi_options=[payload]) ++ + @with_rw_directory + def test_checkout_in_non_empty_dir(self, rw_dir): + non_empty_dir = Path(rw_dir) +diff --git a/test/test_submodule.py b/test/test_submodule.py +index d88f9da..f1d4375 100644 +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -1310,6 +1310,17 @@ class TestSubmodule(TestBase): + submodule.update(clone_multi_options=[unsafe_option]) + assert not tmp_file.exists() + ++ @with_rw_repo("HEAD") ++ def test_submodule_update_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ tmp_dir = Path(tdir) ++ payload = "--single-branch --config protocol.ext.allow=always" ++ submodule = Submodule(rw_repo, b"\0" * 20, name="new", path="new", url=str(tmp_dir)) ++ ++ with self.assertRaises(UnsafeOptionError): ++ submodule.update(clone_multi_options=[payload]) ++ assert not submodule.module_exists() ++ + @with_rw_repo("HEAD") + def test_submodule_update_unsafe_options_allowed(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: diff -Nru python-git-3.1.44/debian/patches/CVE-2026-44243_1.patch python-git-3.1.44/debian/patches/CVE-2026-44243_1.patch --- python-git-3.1.44/debian/patches/CVE-2026-44243_1.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-44243_1.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,110 @@ +From: "GPT 5.5" +Date: Tue, 28 Apr 2026 09:30:41 +0800 +Subject: address review feedback and CI failures + +Consolidate follow-up fixes from review and CI: + +- fix lint and mypy issues in reference log path handling +- validate remote reference paths before invoking git branch deletion +- add symlink escape coverage where realpath resolves symlinks +- ensure temporary test repositories release git resources during cleanup + +Co-authored-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/dbfa26476445169cdc9d64a539ba6959fd0a2643 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-44243 +Bug-Debian-Freexian: https://security-tracker.debian.org/tracker/CVE-2026-44243 +--- + git/refs/log.py | 4 +++- + git/refs/remote.py | 3 +++ + git/util.py | 2 +- + test/test_refs.py | 23 +++++++++++++++++++++++ + 4 files changed, 30 insertions(+), 2 deletions(-) + +diff --git a/git/refs/log.py b/git/refs/log.py +index 17e3a94..c076396 100644 +--- a/git/refs/log.py ++++ b/git/refs/log.py +@@ -4,7 +4,6 @@ + __all__ = ["RefLog", "RefLogEntry"] + + from mmap import mmap +-import os.path as osp + import re + import time as _time + +@@ -212,6 +211,9 @@ class RefLog(List[RefLogEntry], Serializable): + + :param ref: + :class:`~git.refs.symbolic.SymbolicReference` instance ++ ++ :raise ValueError: ++ If `ref.path` is invalid or escapes the repository's reflog directory. + """ + return osp.join(ref.repo.git_dir, "logs", to_native_path(ref.path)) + +diff --git a/git/refs/remote.py b/git/refs/remote.py +index b4f4f7b..298bd36 100644 +--- a/git/refs/remote.py ++++ b/git/refs/remote.py +@@ -58,6 +58,9 @@ class RemoteReference(Head): + `kwargs` are given for comparability with the base class method as we + should not narrow the signature. + """ ++ for ref in refs: ++ cls._check_ref_name_valid(ref.path) ++ + repo.git.branch("-d", "-r", *refs) + # The official deletion method will ignore remote symbolic refs - these are + # generally ignored in the refs/ folder. We don't though and delete remainders +diff --git a/git/util.py b/git/util.py +index 9e8ac82..3f820ad 100644 +--- a/git/util.py ++++ b/git/util.py +@@ -289,7 +289,7 @@ def join_path(a: PathLike, *p: PathLike) -> PathLike: + + if sys.platform == "win32": + +- def to_native_path_windows(path: PathLike) -> PathLike: ++ def to_native_path_windows(path: PathLike) -> str: + path = str(path) + return path.replace("/", "\\") + +diff --git a/test/test_refs.py b/test/test_refs.py +index 08096e6..354c2d7 100644 +--- a/test/test_refs.py ++++ b/test/test_refs.py +@@ -3,6 +3,7 @@ + # This module is part of GitPython and is released under the + # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ + ++import contextlib + from itertools import chain + import os.path as osp + from pathlib import Path +@@ -679,3 +680,25 @@ class TestRefs(TestBase): + + # Valid reference name should not raise. + check_ref("valid/ref/name") ++ ++ def test_symbolic_reference_set_reference_rejects_symlink_escape(self): ++ with tempfile.TemporaryDirectory() as tmp_dir: ++ base_dir = Path(tmp_dir) ++ with self._repo_with_initial_commit(base_dir) as repo: ++ outside_dir = base_dir / "outside_refs" ++ outside_dir.mkdir() ++ outside_path = outside_dir / "escaped" ++ ++ refs_heads_dir = Path(repo.common_dir) / "refs" / "heads" ++ refs_heads_dir.mkdir(parents=True, exist_ok=True) ++ symlink_path = refs_heads_dir / "link_out" ++ try: ++ symlink_path.symlink_to(outside_dir, target_is_directory=True) ++ except (OSError, NotImplementedError) as ex: ++ self.skipTest("symlinks unavailable on this platform: %s" % ex) ++ if osp.realpath(symlink_path / "escaped") == osp.abspath(symlink_path / "escaped"): ++ self.skipTest("realpath does not resolve directory symlinks on this platform") ++ ++ ref = SymbolicReference(repo, "refs/heads/link_out/escaped") ++ self.assertRaises(ValueError, ref.set_reference, "HEAD") ++ assert not outside_path.exists() diff -Nru python-git-3.1.44/debian/patches/CVE-2026-44243_2.patch python-git-3.1.44/debian/patches/CVE-2026-44243_2.patch --- python-git-3.1.44/debian/patches/CVE-2026-44243_2.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-44243_2.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,245 @@ +From: "GPT 5.5" +Date: Tue, 28 Apr 2026 09:17:31 +0800 +Subject: prevent out-of-repo access when manipulating references. + +This previously made it possible to create, modify and delete files outside outside +of the repository, which is a problem if inputs aren't trusted. + +Co-authored-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/dbfa26476445169cdc9d64a539ba6959fd0a2643 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-44243 +Bug-Debian-Freexian: https://security-tracker.debian.org/tracker/CVE-2026-44243 +--- + git/refs/log.py | 2 +- + git/refs/remote.py | 5 +-- + git/refs/symbolic.py | 37 +++++++++++++++++---- + test/test_refs.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++ + 4 files changed, 126 insertions(+), 9 deletions(-) + +diff --git a/git/refs/log.py b/git/refs/log.py +index c076396..642b182 100644 +--- a/git/refs/log.py ++++ b/git/refs/log.py +@@ -215,7 +215,7 @@ class RefLog(List[RefLogEntry], Serializable): + :raise ValueError: + If `ref.path` is invalid or escapes the repository's reflog directory. + """ +- return osp.join(ref.repo.git_dir, "logs", to_native_path(ref.path)) ++ return to_native_path(ref._get_validated_reflog_path(ref.repo, ref.path)) + + @classmethod + def iter_entries(cls, stream: Union[str, "BytesIO", mmap]) -> Iterator[RefLogEntry]: +diff --git a/git/refs/remote.py b/git/refs/remote.py +index 298bd36..014b7ea 100644 +--- a/git/refs/remote.py ++++ b/git/refs/remote.py +@@ -66,12 +66,13 @@ class RemoteReference(Head): + # generally ignored in the refs/ folder. We don't though and delete remainders + # manually. + for ref in refs: ++ cls._check_ref_name_valid(ref.path) + try: +- os.remove(os.path.join(repo.common_dir, ref.path)) ++ os.remove(cls._get_validated_path(repo.common_dir, ref.path)) + except OSError: + pass + try: +- os.remove(os.path.join(repo.git_dir, ref.path)) ++ os.remove(cls._get_validated_path(repo.git_dir, ref.path)) + except OSError: + pass + # END for each ref +diff --git a/git/refs/symbolic.py b/git/refs/symbolic.py +index 510850b..ba24f2c 100644 +--- a/git/refs/symbolic.py ++++ b/git/refs/symbolic.py +@@ -109,6 +109,32 @@ class SymbolicReference: + def abspath(self) -> PathLike: + return join_path_native(_git_dir(self.repo, self.path), self.path) + ++ @staticmethod ++ def _get_validated_path(base: PathLike, path: PathLike) -> str: ++ path = os.fspath(path) ++ base_path = os.path.realpath(os.fspath(base)) ++ abs_path = os.path.realpath(os.path.join(base_path, path)) ++ try: ++ common_path = os.path.commonpath([base_path, abs_path]) ++ except ValueError as e: ++ raise ValueError("Reference path %r escapes the repository" % path) from e ++ if os.path.normcase(common_path) != os.path.normcase(base_path): ++ raise ValueError("Reference path %r escapes the repository" % path) ++ return abs_path ++ ++ @classmethod ++ def _get_validated_ref_path(cls, repo: "Repo", path: PathLike) -> str: ++ """Return the absolute filesystem path for a ref after validating it.""" ++ cls._check_ref_name_valid(path) ++ ref_path = os.fspath(path) ++ return cls._get_validated_path(_git_dir(repo, ref_path), ref_path) ++ ++ @classmethod ++ def _get_validated_reflog_path(cls, repo: "Repo", path: PathLike) -> str: ++ """Return the absolute filesystem path for a reflog after validating it.""" ++ cls._check_ref_name_valid(path) ++ return cls._get_validated_path(os.path.join(repo.git_dir, "logs"), path) ++ + @classmethod + def _get_packed_refs_path(cls, repo: "Repo") -> str: + return os.path.join(repo.common_dir, "packed-refs") +@@ -478,7 +504,7 @@ class SymbolicReference: + # END handle non-existing + # END retrieve old hexsha + +- fpath = self.abspath ++ fpath = self._get_validated_ref_path(self.repo, self.path) + assure_directory_exists(fpath, is_file=True) + + lfd = LockedFD(fpath) +@@ -623,7 +649,7 @@ class SymbolicReference: + Alternatively the symbolic reference to be deleted. + """ + full_ref_path = cls.to_full_path(path) +- abs_path = os.path.join(repo.common_dir, full_ref_path) ++ abs_path = cls._get_validated_ref_path(repo, full_ref_path) + if os.path.exists(abs_path): + os.remove(abs_path) + else: +@@ -686,9 +712,8 @@ class SymbolicReference: + symbolic reference. Otherwise it will be resolved to the corresponding object + and a detached symbolic reference will be created instead. + """ +- git_dir = _git_dir(repo, path) + full_ref_path = cls.to_full_path(path) +- abs_ref_path = os.path.join(git_dir, full_ref_path) ++ abs_ref_path = cls._get_validated_ref_path(repo, full_ref_path) + + # Figure out target data. + target = reference +@@ -780,8 +805,8 @@ class SymbolicReference: + if self.path == new_path: + return self + +- new_abs_path = os.path.join(_git_dir(self.repo, new_path), new_path) +- cur_abs_path = os.path.join(_git_dir(self.repo, self.path), self.path) ++ new_abs_path = self._get_validated_ref_path(self.repo, new_path) ++ cur_abs_path = self._get_validated_ref_path(self.repo, self.path) + if os.path.isfile(new_abs_path): + if not force: + # If they point to the same file, it's not an error. +diff --git a/test/test_refs.py b/test/test_refs.py +index 354c2d7..49c6e82 100644 +--- a/test/test_refs.py ++++ b/test/test_refs.py +@@ -19,6 +19,7 @@ from git import ( + RefLog, + Reference, + RemoteReference, ++ Repo, + SymbolicReference, + TagReference, + ) +@@ -30,6 +31,14 @@ from test.lib import TestBase, with_rw_repo + + + class TestRefs(TestBase): ++ def _repo_with_initial_commit(self, base_dir): ++ repo_dir = base_dir / "repo" ++ repo = Repo.init(repo_dir) ++ (repo_dir / "file.txt").write_text("initial\n", encoding="utf-8") ++ repo.index.add(["file.txt"]) ++ repo.index.commit("initial") ++ return repo ++ + def test_from_path(self): + # Should be able to create any reference directly. + for ref_type in (Reference, Head, TagReference, RemoteReference): +@@ -630,6 +639,88 @@ class TestRefs(TestBase): + ref_file_name = Path(ref_file.name).name + self.assertRaises(BadName, self.rorepo.commit, f"../../{ref_file_name}") + ++ def test_reference_create_rejects_path_traversal(self): ++ with tempfile.TemporaryDirectory() as tmp_dir: ++ base_dir = Path(tmp_dir) ++ repo = self._repo_with_initial_commit(base_dir) ++ outside_path = base_dir / "outside_write.txt" ++ ++ self.assertRaises(ValueError, Reference.create, repo, "../../../outside_write.txt", "HEAD") ++ assert not outside_path.exists() ++ ++ def test_symbolic_reference_create_rejects_path_traversal(self): ++ with tempfile.TemporaryDirectory() as tmp_dir: ++ base_dir = Path(tmp_dir) ++ repo = self._repo_with_initial_commit(base_dir) ++ outside_path = base_dir / "outside_write.txt" ++ ++ self.assertRaises(ValueError, SymbolicReference.create, repo, "../../outside_write.txt", "HEAD") ++ assert not outside_path.exists() ++ ++ def test_symbolic_reference_set_reference_rejects_path_traversal(self): ++ with tempfile.TemporaryDirectory() as tmp_dir: ++ base_dir = Path(tmp_dir) ++ repo = self._repo_with_initial_commit(base_dir) ++ outside_path = base_dir / "outside_write.txt" ++ ++ self.assertRaises(ValueError, SymbolicReference(repo, "../../outside_write.txt").set_reference, "HEAD") ++ assert not outside_path.exists() ++ ++ def test_symbolic_reference_rename_rejects_path_traversal(self): ++ with tempfile.TemporaryDirectory() as tmp_dir: ++ base_dir = Path(tmp_dir) ++ repo = self._repo_with_initial_commit(base_dir) ++ outside_path = base_dir / "outside_move.txt" ++ ref = SymbolicReference.create(repo, "SAFE_RENAME_SOURCE", "HEAD") ++ ++ self.assertRaises(ValueError, ref.rename, "../../outside_move.txt") ++ assert not outside_path.exists() ++ assert Path(ref.abspath).is_file() ++ ++ def test_symbolic_reference_delete_rejects_path_traversal(self): ++ with tempfile.TemporaryDirectory() as tmp_dir: ++ base_dir = Path(tmp_dir) ++ repo = self._repo_with_initial_commit(base_dir) ++ outside_path = base_dir / "outside_delete.txt" ++ outside_path.write_text("do not delete\n", encoding="utf-8") ++ ++ self.assertRaises(ValueError, SymbolicReference.delete, repo, "../../outside_delete.txt") ++ assert outside_path.read_text(encoding="utf-8") == "do not delete\n" ++ ++ def test_symbolic_reference_log_append_rejects_path_traversal(self): ++ with tempfile.TemporaryDirectory() as tmp_dir: ++ base_dir = Path(tmp_dir) ++ repo = self._repo_with_initial_commit(base_dir) ++ outside_path = base_dir / "outside_reflog.txt" ++ ++ ref = SymbolicReference(repo, "../../../outside_reflog.txt") ++ self.assertRaises(ValueError, ref.log_append, Commit.NULL_BIN_SHA, "do not write", repo.head.commit.binsha) ++ assert not outside_path.exists() ++ ++ def test_remote_reference_delete_cleanup_rejects_path_traversal(self): ++ with tempfile.TemporaryDirectory() as tmp_dir: ++ base_dir = Path(tmp_dir) ++ git_dir = base_dir / "repo" / ".git" ++ git_dir.mkdir(parents=True) ++ outside_path = base_dir / "outside_remote_delete.txt" ++ outside_path.write_text("do not delete\n", encoding="utf-8") ++ ++ class GitStub: ++ def branch(self, *args): ++ pass ++ ++ class RepoStub: ++ pass ++ ++ repo = RepoStub() ++ repo.git = GitStub() ++ repo.common_dir = str(git_dir) ++ repo.git_dir = str(git_dir) ++ ref = RemoteReference(repo, "../../outside_remote_delete.txt", check_path=False) ++ ++ self.assertRaises(ValueError, RemoteReference.delete, repo, ref) ++ assert outside_path.read_text(encoding="utf-8") == "do not delete\n" ++ + def test_validity_ref_names(self): + """Ensure ref names are checked for validity. + diff -Nru python-git-3.1.44/debian/patches/CVE-2026-44244_1.patch python-git-3.1.44/debian/patches/CVE-2026-44244_1.patch --- python-git-3.1.44/debian/patches/CVE-2026-44244_1.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-44244_1.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,141 @@ +From: "GPT 5.5" +Date: Wed, 29 Apr 2026 05:47:57 +0800 +Subject: reject control chars in written values in configuration + +Reject CR, LF, and NUL in GitConfigParser values before writing them +to git config files (which also is a deviation from Git which escapes them). + +GitConfigParser._write() serializes embedded newlines as indented +continuation lines by replacing "\n" with "\n\t". Git itself skips +leading whitespace before parsing config tokens, so an injected value +such as: + + foo + [core] + hooksPath=/tmp/hooks + +is written in a form where the indented "[core]" line is still parsed by +Git as a real section header. This lets attacker-controlled input passed +to config_writer().set_value() poison repository config, including +core.hooksPath, and redirect hook execution for later Git operations. + +Fail closed instead of stripping or normalizing these characters. Silent +normalization can hide unsanitized caller input, and GitPython does not +currently round-trip Git-style escaped values such as "\n" as embedded +newlines. + +Apply the validation to set_value(), add_value(), and the public set() +path so callers cannot bypass the safer helper API. Add regression tests +for the advisory payload and for CR, LF, NUL, and bytes values. + +This preserves existing read behavior for config files that already +contain multiline values while preventing GitPython from writing new +unsafe values. + +Co-authored-by: Sebastian Thiel + +Origin: https://github.com/gitpython-developers/GitPython/commit/c417af469f9aa3da8dfef78f996c0fb8c5d1f4c2 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-44244 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-44244 +--- + git/config.py | 24 ++++++++++++++++++++++-- + test/test_config.py | 33 +++++++++++++++++++++++++++++++++ + 2 files changed, 55 insertions(+), 2 deletions(-) + +diff --git a/git/config.py b/git/config.py +index de35083..24d9d92 100644 +--- a/git/config.py ++++ b/git/config.py +@@ -863,6 +863,24 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + return str(value) + return force_text(value) + ++ def _value_to_string_safe(self, value: Union[str, bytes, int, float, bool]) -> str: ++ value_str = self._value_to_string(value) ++ if re.search(r"[\r\n\x00]", value_str): ++ raise ValueError("Git config values must not contain CR, LF, or NUL") ++ return value_str ++ ++ @needs_values ++ @set_dirty_and_flush_changes ++ def set( ++ self, ++ section: str, ++ option: str, ++ value: Union[str, bytes, int, float, bool, None] = None, ++ ) -> None: ++ if value is not None: ++ value = self._value_to_string_safe(value) ++ return super().set(section, option, value) ++ + @needs_values + @set_dirty_and_flush_changes + def set_value(self, section: str, option: str, value: Union[str, bytes, int, float, bool]) -> "GitConfigParser": +@@ -883,9 +901,10 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + :return: + This instance + """ ++ value_str = self._value_to_string_safe(value) + if not self.has_section(section): + self.add_section(section) +- self.set(section, option, self._value_to_string(value)) ++ self.set(section, option, value_str) + return self + + @needs_values +@@ -910,9 +929,10 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + :return: + This instance + """ ++ value_str = self._value_to_string_safe(value) + if not self.has_section(section): + self.add_section(section) +- self._sections[section].add(option, self._value_to_string(value)) ++ self._sections[section].add(option, value_str) + return self + + def rename_section(self, section: str, new_name: str) -> "GitConfigParser": +diff --git a/test/test_config.py b/test/test_config.py +index 9299742..737f45b 100644 +--- a/test/test_config.py ++++ b/test/test_config.py +@@ -150,6 +150,39 @@ class TestBase(TestCase): + git_config = GitConfigParser(config_file) + git_config.read() # This should not throw an exception + ++ @with_rw_directory ++ def test_set_value_rejects_config_injection(self, rw_dir): ++ config_path = osp.join(rw_dir, "config") ++ payload = "foo\n[core]\nhooksPath=/tmp/hooks" ++ ++ with GitConfigParser(config_path, read_only=False) as git_config: ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.set_value("user", "name", payload) ++ ++ with GitConfigParser(config_path, read_only=True) as git_config: ++ self.assertFalse(git_config.has_section("user")) ++ self.assertFalse(git_config.has_section("core")) ++ ++ @with_rw_directory ++ def test_set_and_add_value_reject_unsafe_value_characters(self, rw_dir): ++ config_path = osp.join(rw_dir, "config") ++ bad_values = ("foo\rbar", "foo\nbar", "foo\x00bar", b"foo\nbar") ++ ++ with GitConfigParser(config_path, read_only=False) as git_config: ++ git_config.add_section("user") ++ for bad_value in bad_values: ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.set("user", "name", bad_value) ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.set_value("user", "name", bad_value) ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.add_value("user", "name", bad_value) ++ ++ git_config.set_value("user", "name", "safe") ++ ++ with GitConfigParser(config_path, read_only=True) as git_config: ++ self.assertEqual(git_config.get_value("user", "name"), "safe") ++ + def test_base(self): + path_repo = fixture_path("git_config") + path_global = fixture_path("git_config_global") diff -Nru python-git-3.1.44/debian/patches/CVE-2026-44244_2.patch python-git-3.1.44/debian/patches/CVE-2026-44244_2.patch --- python-git-3.1.44/debian/patches/CVE-2026-44244_2.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-44244_2.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,26 @@ +From: "GPT 5.5" +Date: Wed, 29 Apr 2026 06:39:02 +0800 +Subject: avoid duplicate validation in set_value + +Co-authored-by: Sebastian Thiel + +Origin: https://github.com/gitpython-developers/GitPython/commit/8e24503b42c1d63dd98e8b2e6a2f655bdd0821e3 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-44244 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-44244 +--- + git/config.py | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/git/config.py b/git/config.py +index 24d9d92..10789ac 100644 +--- a/git/config.py ++++ b/git/config.py +@@ -904,7 +904,7 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + value_str = self._value_to_string_safe(value) + if not self.has_section(section): + self.add_section(section) +- self.set(section, option, value_str) ++ super().set(section, option, value_str) + return self + + @needs_values diff -Nru python-git-3.1.44/debian/patches/CVE-2026-67322.patch python-git-3.1.44/debian/patches/CVE-2026-67322.patch --- python-git-3.1.44/debian/patches/CVE-2026-67322.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-67322.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,223 @@ +From: "GPT 5.6" +Date: Wed, 15 Jul 2026 16:44:13 +0200 +Subject: fix: prevent environment expansion in clone URLs + +Repo.clone_from() expanded environment-variable references in caller-supplied +URLs before passing them to git clone. This could expose process environment +values to an untrusted remote and made protocol validation apply to a different +value than Git received. + +Polish clone URLs without variable or home expansion on native and Cygwin +Git, then apply unsafe-protocol validation to that exact normalized value. +Preserve the literal URL when normalizing the stored origin after a successful +clone, while retaining the existing Git.polish_url() default for callers +that intentionally normalize local paths. Add regression coverage for +POSIX and Windows variable syntax, Cygwin conversion, stored origins, and +post-normalization protocol validation. + +Git baseline: git clone passes URL arguments through literally; t/t5601-clone.sh +covers the accepted URL forms without shell-style environment expansion. + +Security: GHSA-rwj8-pgh3-r573. + +Co-authored-by: Sebastian Thiel + +Origin: https://github.com/gitpython-developers/GitPython/commit/8ac5a30519b6f4af85398b9b9d7064ff4d452da2, +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67322 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67322 +--- + git/cmd.py | 19 ++++++++++++------- + git/repo/base.py | 7 ++++--- + git/util.py | 15 +++++++++------ + test/test_clone.py | 42 +++++++++++++++++++++++++++++++++++++++++- + 4 files changed, 66 insertions(+), 17 deletions(-) + +diff --git a/git/cmd.py b/git/cmd.py +index 3bb3089..64d9216 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -665,29 +665,34 @@ class Git(metaclass=_GitMeta): + + @overload + @classmethod +- def polish_url(cls, url: str, is_cygwin: Literal[False] = ...) -> str: ... ++ def polish_url(cls, url: str, is_cygwin: Literal[False] = ..., expand_vars: bool = ...) -> str: ... + + @overload + @classmethod +- def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None) -> str: ... ++ def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None, expand_vars: bool = True) -> str: ... + + @classmethod +- def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None) -> PathLike: ++ def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None, expand_vars: bool = True) -> PathLike: + """Remove any backslashes from URLs to be written in config files. + + Windows might create config files containing paths with backslashes, but git + stops liking them as it will escape the backslashes. Hence we undo the escaping + just to be sure. ++ ++ :param expand_vars: ++ Expand environment variables and an initial ``~``. Disable this for values ++ obtained from an untrusted source, such as remote URLs. + """ + if is_cygwin is None: + is_cygwin = cls.is_cygwin() + + if is_cygwin: +- url = cygpath(url) ++ url = cygpath(url, expand_vars=expand_vars) + else: +- url = os.path.expandvars(url) +- if url.startswith("~"): +- url = os.path.expanduser(url) ++ if expand_vars: ++ url = os.path.expandvars(url) ++ if url.startswith("~"): ++ url = os.path.expanduser(url) + url = url.replace("\\\\", "\\").replace("\\", "/") + return url + +diff --git a/git/repo/base.py b/git/repo/base.py +index 5ad88fe..2e97ef4 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -1377,8 +1377,9 @@ class Repo: + if multi_options: + multi = shlex.split(" ".join(multi_options)) + ++ clone_url = Git.polish_url(str(url), expand_vars=False) + if not allow_unsafe_protocols: +- Git.check_unsafe_protocols(str(url)) ++ Git.check_unsafe_protocols(clone_url) + if not allow_unsafe_options: + Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options) + if not allow_unsafe_options and multi: +@@ -1387,7 +1388,7 @@ class Repo: + proc = git.clone( + multi, + "--", +- Git.polish_url(str(url)), ++ clone_url, + clone_path, + with_extended_output=True, + as_process=True, +@@ -1427,7 +1428,7 @@ class Repo: + # escape the backslashes. Hence we undo the escaping just to be sure. + if repo.remotes: + with repo.remotes[0].config_writer as writer: +- writer.set_value("url", Git.polish_url(repo.remotes[0].url)) ++ writer.set_value("url", Git.polish_url(repo.remotes[0].url, expand_vars=False)) + # END handle remote repo + return repo + +diff --git a/git/util.py b/git/util.py +index 3f820ad..b02e994 100644 +--- a/git/util.py ++++ b/git/util.py +@@ -382,13 +382,13 @@ def py_where(program: str, path: Optional[PathLike] = None) -> List[str]: + return progs + + +-def _cygexpath(drive: Optional[str], path: str) -> str: ++def _cygexpath(drive: Optional[str], path: str, expand_vars: bool = True) -> str: + if osp.isabs(path) and not drive: + # Invoked from `cygpath()` directly with `D:Apps\123`? + # It's an error, leave it alone just slashes) + p = path # convert to str if AnyPath given + else: +- p = path and osp.normpath(osp.expandvars(osp.expanduser(path))) ++ p = path and osp.normpath(osp.expandvars(osp.expanduser(path)) if expand_vars else path) + if osp.isabs(p): + if drive: + # Confusing, maybe a remote system should expand vars. +@@ -416,7 +416,7 @@ _cygpath_parsers: Tuple[Tuple[Pattern[str], Callable, bool], ...] = ( + ) + + +-def cygpath(path: str) -> str: ++def cygpath(path: str, expand_vars: bool = True) -> str: + """Use :meth:`git.cmd.Git.polish_url` instead, that works on any environment.""" + path = str(path) # Ensure is str and not AnyPath. + # Fix to use Paths when 3.5 dropped. Or to be just str if only for URLs? +@@ -424,12 +424,15 @@ def cygpath(path: str) -> str: + for regex, parser, recurse in _cygpath_parsers: + match = regex.match(path) + if match: +- path = parser(*match.groups()) ++ if parser is _cygexpath: ++ path = parser(*match.groups(), expand_vars=expand_vars) ++ else: ++ path = parser(*match.groups()) + if recurse: +- path = cygpath(path) ++ path = cygpath(path, expand_vars=expand_vars) + break + else: +- path = _cygexpath(None, path) ++ path = _cygexpath(None, path, expand_vars=expand_vars) + + return path + +diff --git a/test/test_clone.py b/test/test_clone.py +index 5b8c960..47565fa 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -3,12 +3,14 @@ + + from pathlib import Path + import re ++import os + import tempfile ++from unittest import mock + + import git + + from test.lib import TestBase, with_rw_directory, with_rw_repo +-from git.exc import UnsafeOptionError ++from git.exc import UnsafeOptionError, UnsafeProtocolError + + + class TestClone(TestBase): +@@ -106,3 +108,41 @@ class TestClone(TestBase): + ) + else: + self.fail("GitCommandError not raised") ++ ++ def test_clone_from_does_not_expand_environment_variables_in_url(self): ++ urls = [ ++ "https://example.com/$GITPYTHON_TEST_SECRET/repo.git", ++ "https://example.com/${GITPYTHON_TEST_SECRET}/repo.git", ++ "https://example.com/%GITPYTHON_TEST_SECRET%/repo.git", ++ ] ++ with mock.patch.dict(os.environ, {"GITPYTHON_TEST_SECRET": "sensitive-value"}): ++ for url in urls: ++ with mock.patch.object(git.Git, "_call_process", side_effect=RuntimeError) as call_process: ++ with self.assertRaises(RuntimeError): ++ git.Repo.clone_from(url, "unused") ++ ++ assert call_process.call_args[0][3] == url ++ ++ @with_rw_directory ++ def test_clone_from_does_not_expand_environment_variables_in_stored_url(self, rw_dir): ++ url = Path(rw_dir) / "$GITPYTHON_TEST_SECRET" / "source" ++ git.Git().init(url) ++ ++ with mock.patch.dict(os.environ, {"GITPYTHON_TEST_SECRET": "sensitive-value"}): ++ cloned = git.Repo.clone_from(url, Path(rw_dir) / "clone") ++ ++ assert cloned.remotes.origin.url == git.Git.polish_url(str(url), expand_vars=False) ++ ++ def test_clone_from_checks_polished_url_for_unsafe_protocol(self): ++ with mock.patch.object(git.Git, "polish_url", return_value="ext::command"): ++ with mock.patch.object(git.Git, "_call_process") as call_process: ++ with self.assertRaises(UnsafeProtocolError): ++ git.Repo.clone_from("$GITPYTHON_TEST_URL", "unused") ++ ++ call_process.assert_not_called() ++ ++ def test_polish_url_does_not_expand_environment_variables_for_cygwin(self): ++ urls = ["$GITPYTHON_TEST_SECRET/repo", "user@example.com:$GITPYTHON_TEST_SECRET/repo"] ++ with mock.patch.dict(os.environ, {"GITPYTHON_TEST_SECRET": "sensitive-value"}): ++ for url in urls: ++ assert git.Git.polish_url(url, is_cygwin=True, expand_vars=False) == url diff -Nru python-git-3.1.44/debian/patches/CVE-2026-67323.patch python-git-3.1.44/debian/patches/CVE-2026-67323.patch --- python-git-3.1.44/debian/patches/CVE-2026-67323.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-67323.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,610 @@ +From: "GPT 5.6" +Date: Sun, 12 Jul 2026 15:06:42 +0200 +Subject: fix: Guard unsafe git command options (GHSA-956x-8gvw-wg5v) + +Reject command options that can execute arbitrary helpers or write files when passed through archive, revision traversal, blame, clone, fetch, pull, push, and ls-remote APIs. Validate positional option tokens and transformed keyword options consistently, including Git-supported long-option abbreviations, attached short-option values, and falsey stringifiable remotes. Preserve explicit allow_unsafe_options and allow_unsafe_protocols escape hatches. + +Add regression coverage for each guarded API, option spelling and abbreviation forms, inert keyword values, mixed positional and keyword candidates, tuple blame options, stringifiable remotes, and unsafe commands that must not create marker files. + +Reviewed-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/701ce32fe5ba8cb622c0e0342a376a6beb47d738 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67323 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67323 +Bug-Debian: https://bugs.debian.org/1143454 +--- + git/cmd.py | 38 +++++++++++++++++++++++- + git/objects/commit.py | 12 ++++++++ + git/remote.py | 15 ++++++++-- + git/repo/base.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++----- + test/test_clone.py | 6 ++++ + test/test_commit.py | 13 +++++++++ + test/test_git.py | 15 ++++++++++ + test/test_remote.py | 43 ++++++++++++++++++++++++++- + test/test_repo.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ + 9 files changed, 279 insertions(+), 12 deletions(-) + +diff --git a/git/cmd.py b/git/cmd.py +index 64d9216..2688ec1 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -416,6 +416,11 @@ class Git(metaclass=_GitMeta): + + re_unsafe_protocol = re.compile(r"(.+)::.+") + ++ unsafe_git_ls_remote_options = [ ++ # This option allows arbitrary command execution in git-ls-remote. ++ "--upload-pack", ++ ] ++ + def __getstate__(self) -> Dict[str, Any]: + return slots_to_dict(self, exclude=self._excluded_) + +@@ -745,6 +750,21 @@ class Git(metaclass=_GitMeta): + if unsafe_option is not None: + raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.") + ++ @classmethod ++ def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[str, Any]] = None) -> List[str]: ++ """Collect possible option spellings before command-line transformation.""" ++ options = [ ++ option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-") ++ ] ++ if kwargs: ++ for key, value in kwargs.items(): ++ values = value if isinstance(value, (list, tuple)) else (value,) ++ if any(value is True or (value is not False and value is not None) for value in values): ++ key = str(key) ++ options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}") ++ return options ++ ++ + class AutoInterrupt: + """Process wrapper that terminates the wrapped process on finalization. + +@@ -1014,6 +1034,22 @@ class Git(metaclass=_GitMeta): + + self._persistent_git_options = self.transform_kwargs(split_single_char_options=True, **kwargs) + ++ def ls_remote( ++ self, ++ *args: Any, ++ allow_unsafe_options: bool = False, ++ **kwargs: Any, ++ ) -> Union[str, bytes, Tuple[int, Union[str, bytes], str], "Git.AutoInterrupt"]: ++ """List references in a remote repository. ++ ++ :param allow_unsafe_options: ++ Allow unsafe options, like ``--upload-pack``. ++ """ ++ if not allow_unsafe_options: ++ candidate_options = self._option_candidates(args, kwargs) ++ Git.check_unsafe_options(options=candidate_options, unsafe_options=self.unsafe_git_ls_remote_options) ++ return self._call_process("ls_remote", *args, **kwargs) ++ + @property + def working_dir(self) -> Union[None, PathLike]: + """:return: Git directory we are working on""" +@@ -1492,7 +1528,7 @@ class Git(metaclass=_GitMeta): + return args + + @classmethod +- def _unpack_args(cls, arg_list: Sequence[str]) -> List[str]: ++ def _unpack_args(cls, arg_list: Sequence[Any]) -> List[str]: + outlist = [] + if isinstance(arg_list, (list, tuple)): + for arg in arg_list: +diff --git a/git/objects/commit.py b/git/objects/commit.py +index 0ceb466..3af0f63 100644 +--- a/git/objects/commit.py ++++ b/git/objects/commit.py +@@ -86,6 +86,12 @@ class Commit(base.Object, TraversableIterableObj, Diffable, Serializable): + # INVARIANTS + default_encoding = "UTF-8" + ++ # Options to :manpage:`git-rev-list(1)` that can overwrite files. ++ unsafe_git_rev_options = [ ++ "--output", ++ "-o", ++ ] ++ + type: Literal["commit"] = "commit" + + __slots__ = ( +@@ -302,6 +308,7 @@ class Commit(base.Object, TraversableIterableObj, Diffable, Serializable): + repo: "Repo", + rev: Union[str, "Commit", "SymbolicReference"], + paths: Union[PathLike, Sequence[PathLike]] = "", ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> Iterator["Commit"]: + R"""Find all commits matching the given criteria. +@@ -330,6 +337,11 @@ class Commit(base.Object, TraversableIterableObj, Diffable, Serializable): + raise ValueError("--pretty cannot be used as parsing expects single sha's only") + # END handle pretty + ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([rev], kwargs), unsafe_options=cls.unsafe_git_rev_options ++ ) ++ + # Use -- in all cases, to prevent possibility of ambiguous arguments. + # See https://github.com/gitpython-developers/GitPython/issues/264. + +diff --git a/git/remote.py b/git/remote.py +index 20e42b4..289c1ac 100644 +--- a/git/remote.py ++++ b/git/remote.py +@@ -1068,7 +1068,10 @@ class Remote(LazyMixin, IterableObj): + Git.check_unsafe_protocols(ref) + + if not allow_unsafe_options: +- Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_fetch_options) ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=self.unsafe_git_fetch_options, ++ ) + + proc = self.repo.git.fetch( + "--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs +@@ -1122,7 +1125,10 @@ class Remote(LazyMixin, IterableObj): + Git.check_unsafe_protocols(ref) + + if not allow_unsafe_options: +- Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_pull_options) ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=self.unsafe_git_pull_options, ++ ) + + proc = self.repo.git.pull( + "--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs +@@ -1195,7 +1201,10 @@ class Remote(LazyMixin, IterableObj): + Git.check_unsafe_protocols(ref) + + if not allow_unsafe_options: +- Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_push_options) ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=self.unsafe_git_push_options, ++ ) + + proc = self.repo.git.push( + "--", +diff --git a/git/repo/base.py b/git/repo/base.py +index 2e97ef4..e2e3a28 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -160,6 +160,20 @@ class Repo: + https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt + """ + ++ unsafe_git_archive_options = [ ++ # Allows arbitrary command execution through the remote git-upload-archive command. ++ "--exec", ++ # Writes output to a caller-controlled filesystem path. ++ "--output", ++ "-o", ++ ] ++ ++ unsafe_git_revision_options = [ ++ # This option allows output to be written to arbitrary files before revision parsing. ++ "--output", ++ "-o", ++ ] ++ + # Invariants + config_level: ConfigLevels_Tup = ("system", "user", "global", "repository") + """Represents the configuration level of a configuration file.""" +@@ -759,6 +773,7 @@ class Repo: + self, + rev: Union[str, Commit, "SymbolicReference", None] = None, + paths: Union[PathLike, Sequence[PathLike]] = "", ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> Iterator[Commit]: + """An iterator of :class:`~git.objects.commit.Commit` objects representing the +@@ -776,6 +791,9 @@ class Repo: + Arguments to be passed to :manpage:`git-rev-list(1)`. + Common ones are ``max_count`` and ``skip``. + ++ :param allow_unsafe_options: ++ Allow unsafe options in the revision argument, like ``--output``. ++ + :note: + To receive only commits between two named revisions, use the + ``"revA...revB"`` revision specifier. +@@ -786,7 +804,18 @@ class Repo: + if rev is None: + rev = self.head.commit + +- return Commit.iter_items(self, rev, paths, **kwargs) ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options ++ ) ++ ++ return Commit.iter_items( ++ self, ++ rev, ++ paths, ++ allow_unsafe_options=allow_unsafe_options, ++ **kwargs, ++ ) + + def merge_base(self, *rev: TBD, **kwargs: Any) -> List[Commit]: + R"""Find the closest common ancestor for the given revision +@@ -1051,7 +1080,9 @@ class Repo: + # reveal_type(self.head.reference) # => Reference + return self.head.reference + +- def blame_incremental(self, rev: str | HEAD | None, file: str, **kwargs: Any) -> Iterator["BlameEntry"]: ++ def blame_incremental( ++ self, rev: str | HEAD | None, file: str, allow_unsafe_options: bool = False, **kwargs: Any ++ ) -> Iterator["BlameEntry"]: + """Iterator for blame information for the given file at the given revision. + + Unlike :meth:`blame`, this does not return the actual file's contents, only a +@@ -1062,6 +1093,9 @@ class Repo: + uncommitted changes. Otherwise, anything successfully parsed by + :manpage:`git-rev-parse(1)` is a valid option. + ++ :param allow_unsafe_options: ++ Allow unsafe options in revision argument, like ``--output``. ++ + :return: + Lazy iterator of :class:`BlameEntry` tuples, where the commit indicates the + commit to blame for the line, and range indicates a span of line numbers in +@@ -1070,6 +1104,10 @@ class Repo: + If you combine all line number ranges outputted by this command, you should get + a continuous range spanning all line numbers in the file. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options ++ ) + + data: bytes = self.git.blame(rev, "--", file, p=True, incremental=True, stdout_as_string=False, **kwargs) + commits: Dict[bytes, Commit] = {} +@@ -1148,7 +1186,8 @@ class Repo: + rev: Union[str, HEAD, None], + file: str, + incremental: bool = False, +- rev_opts: Optional[List[str]] = None, ++ rev_opts: Optional[Sequence[str]] = None, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> List[List[Commit | List[str | bytes] | None]] | Iterator[BlameEntry] | None: + """The blame information for the given file at the given revision. +@@ -1158,6 +1197,9 @@ class Repo: + uncommitted changes. Otherwise, anything successfully parsed by + :manpage:`git-rev-parse(1)` is a valid option. + ++ :param allow_unsafe_options: ++ Allow unsafe options in revision argument, like ``--output``. ++ + :return: + list: [git.Commit, list: []] + +@@ -1167,9 +1209,14 @@ class Repo: + appearance. + """ + if incremental: +- return self.blame_incremental(rev, file, **kwargs) +- rev_opts = rev_opts or [] +- data: bytes = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs) ++ return self.blame_incremental(rev, file, allow_unsafe_options=allow_unsafe_options, **kwargs) ++ rev_opts_list = list(rev_opts or []) ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([rev, rev_opts_list], kwargs), ++ unsafe_options=self.unsafe_git_revision_options, ++ ) ++ data: bytes = self.git.blame(rev, *rev_opts_list, "--", file, p=True, stdout_as_string=False, **kwargs) + commits: Dict[str, Commit] = {} + blames: List[List[Commit | List[str | bytes] | None]] = [] + +@@ -1381,7 +1428,10 @@ class Repo: + if not allow_unsafe_protocols: + Git.check_unsafe_protocols(clone_url) + if not allow_unsafe_options: +- Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options) ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=cls.unsafe_git_clone_options, ++ ) + if not allow_unsafe_options and multi: + Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options) + +@@ -1556,6 +1606,8 @@ class Repo: + ostream: Union[TextIO, BinaryIO], + treeish: Optional[str] = None, + prefix: Optional[str] = None, ++ allow_unsafe_options: bool = False, ++ allow_unsafe_protocols: bool = False, + **kwargs: Any, + ) -> Repo: + """Archive the tree at the given revision. +@@ -1578,6 +1630,12 @@ class Repo: + repository-relative path to a directory or file to place into the archive, + or a list or tuple of multiple paths. + ++ :param allow_unsafe_options: ++ Allow unsafe options, like ``--exec`` or ``--output``. ++ ++ :param allow_unsafe_protocols: ++ Allow unsafe protocols to be used in ``remote``, like ``ext``. ++ + :raise git.exc.GitCommandError: + If something went wrong. + +@@ -1588,6 +1646,14 @@ class Repo: + treeish = self.head.commit + if prefix and "prefix" not in kwargs: + kwargs["prefix"] = prefix ++ remote = kwargs.get("remote") ++ if not allow_unsafe_protocols and remote is not None: ++ Git.check_unsafe_protocols(str(remote)) ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=self.unsafe_git_archive_options, ++ ) + kwargs["output_stream"] = ostream + path = kwargs.pop("path", []) + path = cast(Union[PathLike, List[PathLike], Tuple[PathLike, ...]], path) +diff --git a/test/test_clone.py b/test/test_clone.py +index 47565fa..0c74e36 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -22,9 +22,11 @@ class TestClone(TestBase): + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", ++ f"--upl='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", ++ f"--upl='touch {tmp_file}'", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +@@ -34,8 +36,10 @@ class TestClone(TestBase): + unsafe_options = [ + {"upload-pack": f"touch {tmp_file}"}, + {"upload_pack": f"touch {tmp_file}"}, ++ {"upl": f"touch {tmp_file}"}, + {"u": f"touch {tmp_file}"}, + {"config": "protocol.ext.allow=always"}, ++ {"conf": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + ] + for unsafe_option in unsafe_options: +@@ -71,8 +75,10 @@ class TestClone(TestBase): + unsafe_options = [ + {"upload-pack": f"touch {tmp_file}"}, + {"upload_pack": f"touch {tmp_file}"}, ++ {"upl": f"touch {tmp_file}"}, + {"u": f"touch {tmp_file}"}, + {"config": "protocol.ext.allow=always"}, ++ {"conf": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + ] + for unsafe_option in unsafe_options: +diff --git a/test/test_commit.py b/test/test_commit.py +index 37c66e3..80cd55d 100644 +--- a/test/test_commit.py ++++ b/test/test_commit.py +@@ -6,6 +6,7 @@ + import copy + from datetime import datetime + from io import BytesIO ++import tempfile + import os.path as osp + import re + import sys +@@ -15,6 +16,7 @@ from unittest.mock import Mock + from gitdb import IStream + + from git import Actor, Commit, Repo ++from git.exc import UnsafeOptionError + from git.objects.util import tzoffset, utc + from git.repo.fun import touch + +@@ -288,6 +290,17 @@ class TestCommit(TestCommitSerialization): + # pretty not allowed. + self.assertRaises(ValueError, Commit.iter_items, self.rorepo, "master", pretty="raw") + ++ def test_iter_items_rejects_unsafe_revision(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ marker = osp.join(tdir, "pwn") ++ self.assertRaises(UnsafeOptionError, Commit.iter_items, self.rorepo, f"--output={marker}") ++ ++ def test_iter_items_rejects_unsafe_options(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ marker = osp.join(tdir, "pwn") ++ with self.assertRaises(UnsafeOptionError): ++ list(Commit.iter_items(self.rorepo, "HEAD", output=marker)) ++ + def test_rev_list_bisect_all(self): + """ + 'git rev-list --bisect-all' returns additional information +diff --git a/test/test_git.py b/test/test_git.py +index 886742c..5f02282 100644 +--- a/test/test_git.py ++++ b/test/test_git.py +@@ -163,11 +163,26 @@ class TestGit(TestBase): + (["c"], ["-c"]), + (["--upload-pack=/tmp/helper"], ["--upload-pack"]), + (["--config core.filemode=false"], ["--config"]), ++ (["--upl=/tmp/helper"], ["--upload-pack"]), ++ (["conf"], ["--config"]), ++ (["--out=/tmp/output"], ["--output"]), + ] + + for options, unsafe_options in cases: + with self.assertRaises(UnsafeOptionError): + Git.check_unsafe_options(options=options, unsafe_options=unsafe_options) ++ def test_option_candidates_ignore_untransformed_kwargs(self): ++ options = Git._option_candidates( ++ kwargs={ ++ "output": None, ++ "upload_pack": False, ++ "exec": [], ++ "config": [None, False], ++ "max_count": 1, ++ } ++ ) ++ ++ self.assertEqual(options, ["--max-count"]) + + _shell_cases = ( + # value_in_call, value_from_class, expected_popen_arg +diff --git a/test/test_remote.py b/test/test_remote.py +index ddf6f4f..2358072 100644 +--- a/test/test_remote.py ++++ b/test/test_remote.py +@@ -9,7 +9,7 @@ from pathlib import Path + import random + import sys + import tempfile +-from unittest import skipIf ++from unittest import mock, skipIf + + import pytest + +@@ -999,6 +999,47 @@ class TestRemote(TestBase): + assert tmp_file.exists() + tmp_file.unlink() + ++ @with_rw_repo("HEAD") ++ def test_ls_remote_unsafe_options(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ tmp_dir = Path(tdir) ++ tmp_file = tmp_dir / "pwn" ++ unsafe_options = [ ++ {"upload-pack": f"touch {tmp_file}"}, ++ {"upload_pack": f"touch {tmp_file}"}, ++ {"upl": f"touch {tmp_file}"}, ++ ] ++ for unsafe_option in unsafe_options: ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.git.ls_remote(".", **unsafe_option) ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.git.ls_remote([f"--upload-pack={tmp_file}"], ".") ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.git.ls_remote([f"--upl={tmp_file}"], ".") ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.git.ls_remote(f"--upload-pack={tmp_file}", ".") ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.git.ls_remote(f"--upl={tmp_file}", ".") ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.git.ls_remote("--upload-pack", "touch", ".") ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.git.ls_remote("--refs", ".", upl=f"touch {tmp_file}") ++ ++ def test_ls_remote_allows_operand_named_like_unsafe_option(self): ++ with mock.patch.object(Git, "_call_process") as git: ++ Git().ls_remote("upload-pack") ++ git.assert_called_once() ++ ++ @with_rw_repo("HEAD") ++ def test_ls_remote_unsafe_options_allowed(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ tmp_dir = Path(tdir) ++ tmp_file = tmp_dir / "pwn" ++ unsafe_options = [{"upload-pack": f"touch {tmp_file}"}] ++ for unsafe_option in unsafe_options: ++ with self.assertRaises(GitCommandError): ++ rw_repo.git.ls_remote(".", **unsafe_option, allow_unsafe_options=True) ++ + @with_rw_and_rw_remote_repo("0.1.6") + def test_fetch_unsafe_branch_name(self, rw_repo, remote_repo): + # Create branch with a name containing a NBSP +diff --git a/test/test_repo.py b/test/test_repo.py +index e38da5b..6a417df 100644 +--- a/test/test_repo.py ++++ b/test/test_repo.py +@@ -669,6 +669,54 @@ class TestRepo(TestBase): + assert stream.tell() + os.remove(stream.name) # Do it this way so we can inspect the file on failure. + ++ def test_archive_rejects_unsafe_options(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ output_marker = osp.join(tdir, "pwn") ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.archive(io.BytesIO(), "0.1.6", exec=f"touch {output_marker}") ++ assert not osp.exists(output_marker) ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.archive(io.BytesIO(), "0.1.6", output=output_marker) ++ assert not osp.exists(output_marker) ++ ++ def test_archive_rejects_unsafe_remote_protocol(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ output_marker = osp.join(tdir, "pwn") ++ with self.assertRaises(UnsafeProtocolError): ++ self.rorepo.archive(io.BytesIO(), "HEAD", remote=f"ext::sh -c touch% {output_marker}") ++ assert not osp.exists(output_marker) ++ ++ def test_archive_preserves_positional_allow_unsafe_options(self): ++ with mock.patch.object(Git, "_call_process") as git: ++ self.rorepo.archive(io.BytesIO(), "HEAD", None, True, exec="git-upload-archive") ++ git.assert_called_once() ++ ++ def test_archive_accepts_stringifiable_remote(self): ++ class StringifiableRemote: ++ def __str__(self): ++ return "origin" ++ ++ with mock.patch.object(Git, "_call_process") as git: ++ self.rorepo.archive(io.BytesIO(), "HEAD", remote=StringifiableRemote()) ++ git.assert_called_once() ++ ++ def test_archive_rejects_unsafe_falsey_remote_protocol(self): ++ class FalseyRemote: ++ def __bool__(self): ++ return False ++ ++ def __str__(self): ++ return "ext::sh -c true" ++ ++ with self.assertRaises(UnsafeProtocolError): ++ self.rorepo.archive(io.BytesIO(), "HEAD", remote=FalseyRemote()) ++ ++ def test_iter_commits_rejects_unsafe_revision(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ target = osp.join(tdir, "pwn") ++ with self.assertRaises(UnsafeOptionError): ++ list(self.rorepo.iter_commits(f"--output={target}", max_count=1)) ++ + @mock.patch.object(Git, "_call_process") + def test_should_display_blame_information(self, git): + git.return_value = fixture("blame") +@@ -718,6 +766,27 @@ class TestRepo(TestBase): + assert c, "Should have executed at least one blame command" + assert nml, "There should at least be one blame commit that contains multiple lines" + ++ def test_blame_rejects_unsafe_revision(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ output_marker = osp.join(tdir, "pwn") ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.blame(f"--output={output_marker}", "README.md") ++ assert not osp.exists(output_marker) ++ ++ def test_blame_rejects_unsafe_options(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ output_marker = osp.join(tdir, "pwn") ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.blame("HEAD", "README.md", output=output_marker) ++ assert not osp.exists(output_marker) ++ ++ def test_blame_rejects_unsafe_rev_opts(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ output_marker = osp.join(tdir, "pwn") ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.blame("HEAD", "README.md", rev_opts=(f"--output={output_marker}",)) ++ assert not osp.exists(output_marker) ++ + @mock.patch.object(Git, "_call_process") + def test_blame_incremental(self, git): + # Loop over two fixtures, create a test fixture for 2.11.1+ syntax. diff -Nru python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_1.patch python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_1.patch --- python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_1.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_1.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,69 @@ +From: abhiprd2000 <8292aniarc@gmail.com> +Date: Thu, 9 Jul 2026 21:22:50 +0530 +Subject: Reject abbreviated forms of unsafe git options + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/2ab051697940144545a14d2f4a639c6409c3187f +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67324 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67324 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67325 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67325 +Bug-Debian: https://bugs.debian.org/1143454 +--- + git/cmd.py | 15 ++++++++++++--- + test/test_clone.py | 15 +++++++++++++++ + 2 files changed, 27 insertions(+), 3 deletions(-) + +diff --git a/git/cmd.py b/git/cmd.py +index 2688ec1..cd7a37d 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -744,11 +744,20 @@ class Git(metaclass=_GitMeta): + arbitrary commands. These are blocked by default. + """ + # Options can be of the form `foo`, `--foo`, `--foo bar`, or `--foo=bar`. ++ # Git accepts any unambiguous prefix of a long option, so an abbreviated ++ # spelling such as `--upl` for `--upload-pack` must be rejected too. An ++ # option is unsafe if its canonical name is a prefix of any blocked ++ # option's canonical name. + canonical_unsafe_options = {cls._canonicalize_option_name(option): option for option in unsafe_options} + for option in options: +- unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option)) +- if unsafe_option is not None: +- raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.") ++ candidate = cls._canonicalize_option_name(option) ++ if not candidate: ++ continue ++ for canonical, unsafe_option in canonical_unsafe_options.items(): ++ if canonical.startswith(candidate): ++ raise UnsafeOptionError( ++ f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." ++ ) + + @classmethod + def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[str, Any]] = None) -> List[str]: +diff --git a/test/test_clone.py b/test/test_clone.py +index 0c74e36..dcd69f2 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -47,6 +47,21 @@ class TestClone(TestBase): + rw_repo.clone(tmp_dir, **unsafe_option) + assert not tmp_file.exists() + ++ @with_rw_repo("HEAD") ++ def test_clone_unsafe_options_abbreviated(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ tmp_dir = pathlib.Path(tdir) ++ tmp_file = tmp_dir / "pwn" ++ unsafe_options = [ ++ f"--upl='touch {tmp_file}'", ++ f"--upload-pac='touch {tmp_file}'", ++ "--conf=protocol.ext.allow=always", ++ ] ++ for unsafe_option in unsafe_options: ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) ++ assert not tmp_file.exists() ++ + @with_rw_repo("HEAD") + def test_clone_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: diff -Nru python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_2.patch python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_2.patch --- python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_2.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_2.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,90 @@ +From: "GPT 5.6" +Date: Sun, 12 Jul 2026 13:45:42 +0200 +Subject: Address review feedback about unsafe option abbreviations + +The prefix check could reject otherwise allowed one-letter short options when an unrelated blocked long option began with the same character. Keep exact matches for every spelling, but restrict prefix matching to explicit long options and multi-character kwargs. + +This addresses the review comment: single-letter short options and kwargs must not be treated as long-option abbreviations. It also adds coverage for abbreviated unsafe kwargs so both clone validation paths remain protected. + +Reviewd-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/181e8ede7543dbbdcee92756cea260f675316c63 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67324 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67324 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67325 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67325 +Bug-Debian: https://bugs.debian.org/1143454 +--- + git/cmd.py | 10 +++++++++- + test/test_clone.py | 10 ++++++++++ + test/test_git.py | 10 ++++++++++ + 3 files changed, 29 insertions(+), 1 deletion(-) + +diff --git a/git/cmd.py b/git/cmd.py +index cd7a37d..23feb47 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -747,12 +747,20 @@ class Git(metaclass=_GitMeta): + # Git accepts any unambiguous prefix of a long option, so an abbreviated + # spelling such as `--upl` for `--upload-pack` must be rejected too. An + # option is unsafe if its canonical name is a prefix of any blocked +- # option's canonical name. ++ # option's canonical name. Only long options and multi-character kwargs ++ # can be abbreviations; single-character short options remain exact-match ++ # only. + canonical_unsafe_options = {cls._canonicalize_option_name(option): option for option in unsafe_options} ++ options_are_kwargs = all(not option.startswith("-") for option in options) + for option in options: + candidate = cls._canonicalize_option_name(option) + if not candidate: + continue ++ unsafe_option = canonical_unsafe_options.get(candidate) ++ if unsafe_option is not None: ++ raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.") ++ if not (option.startswith("--") or (options_are_kwargs and len(candidate) > 1)): ++ continue + for canonical, unsafe_option in canonical_unsafe_options.items(): + if canonical.startswith(candidate): + raise UnsafeOptionError( +diff --git a/test/test_clone.py b/test/test_clone.py +index dcd69f2..e76633b 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -62,6 +62,16 @@ class TestClone(TestBase): + rw_repo.clone(tmp_dir, multi_options=[unsafe_option]) + assert not tmp_file.exists() + ++ unsafe_kwargs = [ ++ {"upl": f"touch {tmp_file}"}, ++ {"upload_pac": f"touch {tmp_file}"}, ++ {"conf": "protocol.ext.allow=always"}, ++ ] ++ for unsafe_option in unsafe_kwargs: ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.clone(tmp_dir, **unsafe_option) ++ assert not tmp_file.exists() ++ + @with_rw_repo("HEAD") + def test_clone_unsafe_options_are_checked_after_splitting_multi_options(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: +diff --git a/test/test_git.py b/test/test_git.py +index 5f02282..b95887e 100644 +--- a/test/test_git.py ++++ b/test/test_git.py +@@ -184,6 +184,16 @@ class TestGit(TestBase): + + self.assertEqual(options, ["--max-count"]) + ++ def test_check_unsafe_options_does_not_treat_short_options_as_abbreviations(self): ++ unsafe_options = ["--upload-pack"] ++ ++ Git.check_unsafe_options(options=["-u", "u", "-upl"], unsafe_options=unsafe_options) ++ with self.assertRaises(UnsafeOptionError): ++ Git.check_unsafe_options(options=["--u"], unsafe_options=unsafe_options) ++ ++ def test_check_unsafe_options_does_not_treat_option_values_as_abbreviations(self): ++ Git.check_unsafe_options(options=["--branch", "conf"], unsafe_options=["--config"]) ++ + _shell_cases = ( + # value_in_call, value_from_class, expected_popen_arg + (None, False, False), diff -Nru python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_3.patch python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_3.patch --- python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_3.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-67324_CVE-2026-67325_3.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,211 @@ +From: "GPT 5.6" +Date: Sun, 12 Jul 2026 14:12:16 +0200 +Subject: fix: reject joined unsafe short options + +GitPython blocks options such as --upload-pack/-u and --config/-c +because they can execute arbitrary commands unless unsafe options are +explicitly allowed. Git also accepts a short option with its value +joined to the same token, including after clusterable flags. Forms +such as -uVALUE, -fuVALUE, -cVALUE, and -vcVALUE could therefore +bypass an exact-token check. + +Parse single-dash option tokens sufficiently to recognize blocked +short options while preserving safe attached values such as +-oupstream. Also distinguish clone multi-option values from bare +kwargs so positional values are not treated as long-option +abbreviations. + +This completes the option-validation hardening for +GHSA-2f96-g7mh-g2hx. + +Reviewed-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/56806080c1348749b07daa4a2024ce47b3cad285 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67324 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67324 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67325 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67325 +Bug-Debian: https://bugs.debian.org/1143454 +--- + git/cmd.py | 40 ++++++++++++++++++++++++++++++++++++---- + test/test_clone.py | 12 +++++++++++- + test/test_git.py | 18 ++++++++++++++++++ + test/test_remote.py | 14 ++++++++++++-- + 4 files changed, 77 insertions(+), 7 deletions(-) + +diff --git a/git/cmd.py b/git/cmd.py +index 23feb47..870509a 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -738,10 +738,23 @@ class Git(metaclass=_GitMeta): + + @classmethod + def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None: +- """Check for unsafe options. +- +- Some options that are passed to ``git `` can be used to execute +- arbitrary commands. These are blocked by default. ++ """Raise :class:`~git.exc.UnsafeOptionError` for blocked option spellings. ++ ++ In addition to exact matches, this rejects abbreviated long options accepted ++ by Git (for example, ``--upl`` for ``--upload-pack``) and unsafe short options ++ whose values are joined to the same token, including after clusterable flags ++ (for example, ``-uVALUE`` and ``-fuVALUE``). ++ ++ A list containing only bare names is treated as normalized keyword arguments, ++ so multi-character names such as ``upload_p`` are checked as long-option ++ abbreviations. If any item starts with ``-``, the list is treated as tokenized ++ command-line input: bare items can be option values and are not checked as ++ abbreviations. Thus ``["--origin", "upload"]`` is allowed. Single-dash options ++ use short-option parsing rather than broad prefix matching, preserving safe ++ attached values such as ``-oupstream`` and ``-bcurrent``. ++ ++ Some options passed to ``git `` can execute arbitrary commands and ++ are therefore blocked by default unless the caller explicitly allows them. + """ + # Options can be of the form `foo`, `--foo`, `--foo bar`, or `--foo=bar`. + # Git accepts any unambiguous prefix of a long option, so an abbreviated +@@ -751,6 +764,15 @@ class Git(metaclass=_GitMeta): + # can be abbreviations; single-character short options remain exact-match + # only. + canonical_unsafe_options = {cls._canonicalize_option_name(option): option for option in unsafe_options} ++ unsafe_short_options = { ++ canonical: option ++ for canonical, option in canonical_unsafe_options.items() ++ if option.startswith("-") and not option.startswith("--") and len(canonical) == 1 ++ } ++ # These value-less Git flags can be clustered before another short option ++ # (for example, ``-fuVALUE``). Stop at any other character because it may ++ # begin an attached value, as ``o`` does in the safe option ``-oupstream``. ++ clusterable_short_options = frozenset("46flnqsv") + options_are_kwargs = all(not option.startswith("-") for option in options) + for option in options: + candidate = cls._canonicalize_option_name(option) +@@ -759,6 +781,16 @@ class Git(metaclass=_GitMeta): + unsafe_option = canonical_unsafe_options.get(candidate) + if unsafe_option is not None: + raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.") ++ option_token = option.split("=", 1)[0].split(None, 1)[0] ++ if option_token.startswith("-") and not option_token.startswith("--"): ++ for option_char in option_token[1:]: ++ unsafe_option = unsafe_short_options.get(option_char) ++ if unsafe_option is not None: ++ raise UnsafeOptionError( ++ f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." ++ ) ++ if option_char not in clusterable_short_options: ++ break + if not (option.startswith("--") or (options_are_kwargs and len(candidate) > 1)): + continue + for canonical, unsafe_option in canonical_unsafe_options.items(): +diff --git a/test/test_clone.py b/test/test_clone.py +index e76633b..d205e56 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -24,9 +24,14 @@ class TestClone(TestBase): + f"--upload-pack='touch {tmp_file}'", + f"--upl='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", ++ f"-utouch {tmp_file}; false", ++ f"-futouch${{IFS}}{tmp_file}; false", ++ f"-qutouch${{IFS}}{tmp_file}; false", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", + f"--upl='touch {tmp_file}'", ++ "-cprotocol.ext.allow=always", ++ "-vcprotocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +@@ -50,7 +55,7 @@ class TestClone(TestBase): + @with_rw_repo("HEAD") + def test_clone_unsafe_options_abbreviated(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: +- tmp_dir = pathlib.Path(tdir) ++ tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" + unsafe_options = [ + f"--upl='touch {tmp_file}'", +@@ -89,8 +94,13 @@ class TestClone(TestBase): + unsafe_options = [ + f"--upload-pack='touch {tmp_file}'", + f"-u 'touch {tmp_file}'", ++ f"-utouch {tmp_file}; false", ++ f"-futouch${{IFS}}{tmp_file}; false", ++ f"-qutouch${{IFS}}{tmp_file}; false", + "--config=protocol.ext.allow=always", + "-c protocol.ext.allow=always", ++ "-cprotocol.ext.allow=always", ++ "-vcprotocol.ext.allow=always", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +diff --git a/test/test_git.py b/test/test_git.py +index b95887e..846e3a4 100644 +--- a/test/test_git.py ++++ b/test/test_git.py +@@ -194,6 +194,24 @@ class TestGit(TestBase): + def test_check_unsafe_options_does_not_treat_option_values_as_abbreviations(self): + Git.check_unsafe_options(options=["--branch", "conf"], unsafe_options=["--config"]) + ++ def test_check_unsafe_options_rejects_joined_unsafe_short_options(self): ++ cases = [ ++ (["-utouch /tmp/pwn"], ["-u"]), ++ (["-futouch /tmp/pwn"], ["-u"]), ++ (["-qutouch${IFS}/tmp/pwn"], ["-u"]), ++ (["-cprotocol.ext.allow=always"], ["-c"]), ++ (["-vcprotocol.ext.allow=always"], ["-c"]), ++ ] ++ ++ for options, unsafe_options in cases: ++ with self.assertRaises(UnsafeOptionError): ++ Git.check_unsafe_options(options=options, unsafe_options=unsafe_options) ++ ++ def test_check_unsafe_options_allows_attached_safe_short_option_values(self): ++ unsafe_options = ["--upload-pack", "-u", "--config", "-c"] ++ ++ Git.check_unsafe_options(options=["-oupstream", "-bcurrent"], unsafe_options=unsafe_options) ++ + _shell_cases = ( + # value_in_call, value_from_class, expected_popen_arg + (None, False, False), +diff --git a/test/test_remote.py b/test/test_remote.py +index 2358072..61c12e5 100644 +--- a/test/test_remote.py ++++ b/test/test_remote.py +@@ -824,7 +824,11 @@ class TestRemote(TestBase): + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" +- unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}] ++ unsafe_options = [ ++ {"upload-pack": f"touch {tmp_file}"}, ++ {"upload_pack": f"touch {tmp_file}"}, ++ {"upload_p": f"touch {tmp_file}"}, ++ ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.fetch(**unsafe_option) +@@ -892,7 +896,11 @@ class TestRemote(TestBase): + remote = rw_repo.remote("origin") + tmp_dir = Path(tdir) + tmp_file = tmp_dir / "pwn" +- unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}] ++ unsafe_options = [ ++ {"upload-pack": f"touch {tmp_file}"}, ++ {"upload_pack": f"touch {tmp_file}"}, ++ {"upload_p": f"touch {tmp_file}"}, ++ ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): + remote.pull(**unsafe_option) +@@ -963,7 +971,9 @@ class TestRemote(TestBase): + unsafe_options = [ + {"receive-pack": f"touch {tmp_file}"}, + {"receive_pack": f"touch {tmp_file}"}, ++ {"receive_p": f"touch {tmp_file}"}, + {"exec": f"touch {tmp_file}"}, ++ {"exe": f"touch {tmp_file}"}, + ] + for unsafe_option in unsafe_options: + assert not tmp_file.exists() diff -Nru python-git-3.1.44/debian/patches/CVE-2026-67326.patch python-git-3.1.44/debian/patches/CVE-2026-67326.patch --- python-git-3.1.44/debian/patches/CVE-2026-67326.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-67326.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,144 @@ +From: "GPT 5.5" +Date: Tue, 5 May 2026 21:34:42 +0800 +Subject: Validate config key section names before writing + +GitConfigParser already rejected CR, LF, and NUL in config values before +writing, but section and option names could still reach configparser. Because +GitPython writes section headers itself, a newline-bearing section name could +split the output into additional headers. + +Reject CR, LF, and NUL in section and option names on write paths that create +or set config keys: add_section(), set(), set_value(), add_value(), and +rename_section() destinations. This matches Git config key validation behavior; +Git source commit 94f057755b7941b321fd11fec1b2e3ca5313a4e0 reports invalid keys +containing newlines from config.c, and local git 2.50.1 rejects newline-bearing +config keys. + +Add a regression test covering unsafe section and option names while preserving +safe writes. + +Co-authored-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/54538428f79b0c91ba52cda5229856a6edf7ac06 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-67326 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-67326 +--- + git/config.py | 17 ++++++++++++++++- + test/test_config.py | 31 +++++++++++++++++++++++++++++++ + 2 files changed, 47 insertions(+), 1 deletion(-) + +diff --git a/git/config.py b/git/config.py +index 10789ac..5d5c75d 100644 +--- a/git/config.py ++++ b/git/config.py +@@ -72,6 +72,9 @@ CONDITIONAL_INCLUDE_REGEXP = re.compile(r"(?<=includeIf )\"(gitdir|gitdir/i|onbr + See: https://git-scm.com/docs/git-config#_conditional_includes + """ + ++UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]") ++"""Characters that cannot be safely written in config names or values.""" ++ + + class MetaParserBuilder(abc.ABCMeta): # noqa: B024 + """Utility class wrapping base-class methods into decorators that assure read-only +@@ -759,6 +762,7 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + + def add_section(self, section: str) -> None: + """Assures added options will stay in order.""" ++ self._assure_config_name_safe(section, "section") + return super().add_section(section) + + @property +@@ -865,10 +869,14 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + + def _value_to_string_safe(self, value: Union[str, bytes, int, float, bool]) -> str: + value_str = self._value_to_string(value) +- if re.search(r"[\r\n\x00]", value_str): ++ if UNSAFE_CONFIG_CHARS_RE.search(value_str): + raise ValueError("Git config values must not contain CR, LF, or NUL") + return value_str + ++ def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None: ++ if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name): ++ raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label) ++ + @needs_values + @set_dirty_and_flush_changes + def set( +@@ -877,6 +885,8 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + option: str, + value: Union[str, bytes, int, float, bool, None] = None, + ) -> None: ++ self._assure_config_name_safe(section, "section") ++ self._assure_config_name_safe(option, "option") + if value is not None: + value = self._value_to_string_safe(value) + return super().set(section, option, value) +@@ -901,6 +911,8 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + :return: + This instance + """ ++ self._assure_config_name_safe(section, "section") ++ self._assure_config_name_safe(option, "option") + value_str = self._value_to_string_safe(value) + if not self.has_section(section): + self.add_section(section) +@@ -929,6 +941,8 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + :return: + This instance + """ ++ self._assure_config_name_safe(section, "section") ++ self._assure_config_name_safe(option, "option") + value_str = self._value_to_string_safe(value) + if not self.has_section(section): + self.add_section(section) +@@ -949,6 +963,7 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + """ + if not self.has_section(section): + raise ValueError("Source section '%s' doesn't exist" % section) ++ self._assure_config_name_safe(new_name, "section") + if self.has_section(new_name): + raise ValueError("Destination section '%s' already exists" % new_name) + +diff --git a/test/test_config.py b/test/test_config.py +index 737f45b..f510d01 100644 +--- a/test/test_config.py ++++ b/test/test_config.py +@@ -163,6 +163,37 @@ class TestBase(TestCase): + self.assertFalse(git_config.has_section("user")) + self.assertFalse(git_config.has_section("core")) + ++ @with_rw_directory ++ def test_set_value_rejects_unsafe_section_and_option_names(self, rw_dir): ++ config_path = osp.join(rw_dir, "config") ++ bad_keys = ("user]\n[core", "user]\r[core", "user]\x00[core") ++ ++ with GitConfigParser(config_path, read_only=False) as git_config: ++ git_config.add_section("user") ++ for bad_key in bad_keys: ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.add_section(bad_key) ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.set(bad_key, "hooksPath", "/tmp/hooks") ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.set("user", bad_key, "/tmp/hooks") ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.set_value(bad_key, "hooksPath", "/tmp/hooks") ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.set_value("user", bad_key, "/tmp/hooks") ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.add_value(bad_key, "hooksPath", "/tmp/hooks") ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.add_value("user", bad_key, "/tmp/hooks") ++ with pytest.raises(ValueError, match="CR, LF, or NUL"): ++ git_config.rename_section("user", bad_key) ++ ++ git_config.set_value("user", "name", "safe") ++ ++ with GitConfigParser(config_path, read_only=True) as git_config: ++ self.assertEqual(git_config.get_value("user", "name"), "safe") ++ self.assertFalse(git_config.has_section("core")) ++ + @with_rw_directory + def test_set_and_add_value_reject_unsafe_value_characters(self, rw_dir): + config_path = osp.join(rw_dir, "config") diff -Nru python-git-3.1.44/debian/patches/CVE-2026-69097.patch python-git-3.1.44/debian/patches/CVE-2026-69097.patch --- python-git-3.1.44/debian/patches/CVE-2026-69097.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-69097.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,112 @@ +From: "GPT 5.6" +Date: Mon, 20 Jul 2026 14:13:22 +0200 +Subject: fix: validate config section delimiters + +GHSA-3rp5-jjmw-4wv2 identified that configuration section names could alter the +structure of serialized config despite the existing control-character checks. + +Reject unquoted closing section delimiters across all writer entry points +while preserving valid delimiters inside quoted subsections and the +existing option-name behavior. Add regression coverage for unsafe plain and +quoted-subsection-shaped names as well as a valid quoted subsection. + +Git baseline: a23bace963d508bd96983cc637131392d3face18. Git config parsing +treats an unquoted closing bracket as the end of a basic section header, while +its extended form permits brackets inside a quoted subsection and requires a +final bracket after the closing quote. + +Co-authored-by: Sebastian Thiel + +Origin: https://github.com/gitpython-developers/GitPython/commit/1ed1b924f4e2d2ee7bab296df77b978af21853f1 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-69097 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-69097 +--- + git/config.py | 16 ++++++++++++++++ + test/test_config.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 63 insertions(+) + +diff --git a/git/config.py b/git/config.py +index 5d5c75d..e94ef55 100644 +--- a/git/config.py ++++ b/git/config.py +@@ -876,6 +876,22 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None: + if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name): + raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label) ++ if label == "section" and isinstance(name, str): ++ in_quotes = False ++ escaped = False ++ for index, char in enumerate(name): ++ if escaped: ++ escaped = False ++ elif in_quotes and char == "\\": ++ escaped = True ++ elif char == '"': ++ if not in_quotes and (index == 0 or name[index - 1] not in " \t"): ++ raise ValueError("Git config quoted subsection names must begin after whitespace") ++ in_quotes = not in_quotes ++ elif char == "]" and not in_quotes: ++ raise ValueError("Git config section names must not contain an unquoted closing bracket") ++ if in_quotes: ++ raise ValueError("Git config section names must not contain an unterminated quote") + + @needs_values + @set_dirty_and_flush_changes +diff --git a/test/test_config.py b/test/test_config.py +index f510d01..f7e9aef 100644 +--- a/test/test_config.py ++++ b/test/test_config.py +@@ -194,6 +194,53 @@ class TestBase(TestCase): + self.assertEqual(git_config.get_value("user", "name"), "safe") + self.assertFalse(git_config.has_section("core")) + ++ @with_rw_directory ++ def test_writer_rejects_unquoted_section_terminators(self, rw_dir): ++ config_path = osp.join(rw_dir, "config") ++ bad_sections = ( ++ "user] [other", ++ 'user"] [other"', ++ 'submodule "docs"] [other', ++ 'submodule "docs] [other', ++ 'submodule "docs] [other\\', ++ ) ++ safe_section = 'submodule "docs]archive"' ++ ++ with GitConfigParser(config_path, read_only=False) as git_config: ++ git_config.add_section("user") ++ for bad_section in bad_sections: ++ with pytest.raises(ValueError, match="section name"): ++ git_config.add_section(bad_section) ++ with pytest.raises(ValueError, match="section name"): ++ git_config.set(bad_section, "name", "value") ++ with pytest.raises(ValueError, match="section name"): ++ git_config.set_value(bad_section, "name", "value") ++ with pytest.raises(ValueError, match="section name"): ++ git_config.add_value(bad_section, "name", "value") ++ with pytest.raises(ValueError, match="section name"): ++ git_config.rename_section("user", bad_section) ++ ++ git_config.set_value("user", "name", "safe") ++ git_config.set_value(safe_section, "name", "safe") ++ self.assertEqual(git_config.get_value(safe_section, "name"), "safe") ++ ++ # A closing bracket inside a quoted subsection name is data, not a section terminator. ++ with open(config_path, "rb") as config_file: ++ self.assertIn( ++ b'[submodule "docs]archive"]\n', ++ config_file.read(), ++ "a closing bracket within a quoted subsection name should be preserved", ++ ) ++ ++ # Reparse the file to verify that rejected names did not inject an [other] section. ++ with GitConfigParser(config_path, read_only=True) as git_config: ++ self.assertEqual( ++ git_config.get_value("user", "name"), ++ "safe", ++ "rejected section names corrupted the existing section", ++ ) ++ self.assertFalse(git_config.has_section("other"), "an unsafe section name injected an [other] section") ++ + @with_rw_directory + def test_set_and_add_value_reject_unsafe_value_characters(self, rw_dir): + config_path = osp.join(rw_dir, "config") diff -Nru python-git-3.1.44/debian/patches/CVE-2026-73619.patch python-git-3.1.44/debian/patches/CVE-2026-73619.patch --- python-git-3.1.44/debian/patches/CVE-2026-73619.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-73619.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,103 @@ +From: "GPT 5.6" +Date: Sun, 26 Jul 2026 06:00:59 +0200 +Subject: Block unsafe archive additions and bundle URI + +GHSA-539m-9xh6-q6rr reports incomplete unsafe option lists for archive inputs +and clone bundle URLs. Caller-controlled archive additions can expose or inject +filesystem content, while clone --bundle-uri can dereference an additional +caller-controlled URL. + +Extend the existing archive denylist with --add-file and --add-virtual-file, +and the clone denylist with --bundle-uri. Regression coverage exercises archive +kwargs plus both clone multi-option and keyword paths. + +Git baseline: a23bace963d508bd96983cc637131392d3face18. +Documentation/git-archive.adoc defines --add-file/--add-virtual-file, and +Documentation/git-clone.adoc defines --bundle-uri as fetching from the supplied +URI. + +Co-authored-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/7a4f5dcb7bf3cbcbf6e438017efcdfe0bc0d36ca +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-73619 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-73619 +--- + git/repo/base.py | 13 ++++++++++++- + test/test_clone.py | 2 ++ + test/test_repo.py | 4 ++++ + 3 files changed, 18 insertions(+), 1 deletion(-) + +diff --git a/git/repo/base.py b/git/repo/base.py +index e2e3a28..243ecb5 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -148,8 +148,10 @@ class Repo: + # Can override configuration variables that execute arbitrary commands: + "--config", + "-c", ++ # Fetches from an additional caller-controlled URI: ++ "--bundle-uri", + ] +- """Options to :manpage:`git-clone(1)` that allow arbitrary commands to be executed. ++ """Options to :manpage:`git-clone(1)` that permit unsafe command execution or I/O. + + The ``--upload-pack``/``-u`` option allows users to execute arbitrary commands + directly: +@@ -158,6 +160,11 @@ class Repo: + The ``--config``/``-c`` option allows users to override configuration variables like + ``protocol.allow`` and ``core.gitProxy`` to execute arbitrary commands: + https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt ++ ++ The ``--bundle-uri`` option fetches from an additional URI before fetching from the ++ clone URL. An untrusted value can therefore make Git access local files or ++ unintended network resources: ++ https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---bundle-uriuri + """ + + unsafe_git_archive_options = [ +@@ -166,6 +173,10 @@ class Repo: + # Writes output to a caller-controlled filesystem path. + "--output", + "-o", ++ # Reads from a caller-controlled filesystem path: ++ "--add-file", ++ # Injects a caller-controlled path and contents: ++ "--add-virtual-file", + ] + + unsafe_git_revision_options = [ +diff --git a/test/test_clone.py b/test/test_clone.py +index d205e56..46738ce 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -32,6 +32,7 @@ class TestClone(TestBase): + f"--upl='touch {tmp_file}'", + "-cprotocol.ext.allow=always", + "-vcprotocol.ext.allow=always", ++ f"--bundle-uri=file://{tmp_dir}", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +@@ -46,6 +47,7 @@ class TestClone(TestBase): + {"config": "protocol.ext.allow=always"}, + {"conf": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, ++ {"bundle_uri": f"file://{tmp_dir}"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +diff --git a/test/test_repo.py b/test/test_repo.py +index 6a417df..b418559 100644 +--- a/test/test_repo.py ++++ b/test/test_repo.py +@@ -678,6 +678,10 @@ class TestRepo(TestBase): + with self.assertRaises(UnsafeOptionError): + self.rorepo.archive(io.BytesIO(), "0.1.6", output=output_marker) + assert not osp.exists(output_marker) ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.archive(io.BytesIO(), "0.1.6", add_file=output_marker) ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.archive(io.BytesIO(), "0.1.6", add_virtual_file="file:content") + + def test_archive_rejects_unsafe_remote_protocol(self): + with tempfile.TemporaryDirectory() as tdir: diff -Nru python-git-3.1.44/debian/patches/CVE-2026-73620.patch python-git-3.1.44/debian/patches/CVE-2026-73620.patch --- python-git-3.1.44/debian/patches/CVE-2026-73620.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-73620.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,192 @@ +From: "GPT 5.6" +Date: Sun, 26 Jul 2026 05:54:27 +0200 +Subject: Block unsafe checkout-index and tag file options + +GHSA-3f7w-8rr8-f37f reports that caller-controlled options forwarded by +IndexFile.checkout() and TagReference.create() can make Git write to or read +from arbitrary filesystem paths. + +Add regression coverage proving the unsafe options are rejected by default while +preserving the explicit allow_unsafe_options escape hatch. Reuse GitPython's +existing option candidate normalization and unsafe-option guard at both public +API boundaries. + +Git baseline: a23bace963d508bd96983cc637131392d3face18. +Documentation/git-checkout-index.adoc defines --prefix as prepending an output +path, and Documentation/git-tag.adoc defines -F/--file as reading a tag message +from a file. + +Co-authored-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/3af0c2516c5e18c829da30338614688f6b69b49c +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-73620 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-73620 +--- + git/index/base.py | 13 +++++++++++++ + git/refs/tag.py | 13 +++++++++++++ + test/test_index.py | 10 ++++++++++ + test/test_refs.py | 13 +++++++++++++ + 4 files changed, 49 insertions(+) + +diff --git a/git/index/base.py b/git/index/base.py +index 39cc914..0ef9606 100644 +--- a/git/index/base.py ++++ b/git/index/base.py +@@ -23,6 +23,7 @@ from gitdb.base import IStream + from gitdb.db import MemoryDB + + from git.compat import defenc, force_bytes ++from git.cmd import Git + import git.diff as git_diff + from git.exc import CheckoutError, GitCommandError, GitError, InvalidGitRepositoryError + from git.objects import Blob, Commit, Object, Submodule, Tree +@@ -129,6 +130,8 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + index directly before operating on it using the git command. + """ + ++ unsafe_git_checkout_index_options = ["--prefix"] ++ + __slots__ = ("repo", "version", "entries", "_extension_data", "_file_path") + + _VERSION = 2 +@@ -1198,6 +1201,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + paths: Union[None, Iterable[PathLike]] = None, + force: bool = False, + fprogress: Callable = lambda *args: None, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> Union[None, Iterator[PathLike], Sequence[PathLike]]: + """Check out the given paths or all files from the version known to the index +@@ -1224,6 +1228,9 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + no explicit paths are given. Otherwise progress information will be send + prior and after a file has been checked out. + ++ :param allow_unsafe_options: ++ Allow unsafe options, such as ``--prefix``. ++ + :param kwargs: + Additional arguments to be passed to :manpage:`git-checkout-index(1)`. + +@@ -1247,6 +1254,12 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + i.e. if you want :manpage:`git-checkout(1)`-like behaviour, use + ``head.checkout`` instead of ``index.checkout``. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=self.unsafe_git_checkout_index_options, ++ ) ++ + args = ["--index"] + if force: + args.append("--force") +diff --git a/git/refs/tag.py b/git/refs/tag.py +index 1e38663..487eaa1 100644 +--- a/git/refs/tag.py ++++ b/git/refs/tag.py +@@ -16,6 +16,7 @@ from .reference import Reference + + from typing import Any, TYPE_CHECKING, Type, Union + ++from git.cmd import Git + from git.types import AnyGitObject, PathLike + + if TYPE_CHECKING: +@@ -42,6 +43,8 @@ class TagReference(Reference): + + __slots__ = () + ++ unsafe_git_tag_options = ["--file", "-F"] ++ + _common_default = "tags" + _common_path_default = Reference._common_path_default + "/" + _common_default + +@@ -92,6 +95,7 @@ class TagReference(Reference): + reference: Union[str, "SymbolicReference"] = "HEAD", + logmsg: Union[str, None] = None, + force: bool = False, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> "TagReference": + """Create a new tag reference. +@@ -121,12 +125,21 @@ class TagReference(Reference): + :param force: + If ``True``, force creation of a tag even though that tag already exists. + ++ :param allow_unsafe_options: ++ Allow unsafe options, such as ``--file``. ++ + :param kwargs: + Additional keyword arguments to be passed to :manpage:`git-tag(1)`. + + :return: + A new :class:`TagReference`. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=cls.unsafe_git_tag_options, ++ ) ++ + if "ref" in kwargs and kwargs["ref"]: + reference = kwargs["ref"] + +diff --git a/test/test_index.py b/test/test_index.py +index c586a0b..75353d7 100644 +--- a/test/test_index.py ++++ b/test/test_index.py +@@ -29,6 +29,7 @@ from git.exc import ( + HookExecutionError, + InvalidGitRepositoryError, + UnmergedEntriesError, ++ UnsafeOptionError, + ) + from git.index.fun import hook_path, run_commit_hook + from git.index.typ import BaseIndexEntry, IndexEntry +@@ -193,6 +194,15 @@ def _make_hook(git_dir, name, content, make_exec=True): + + @ddt.ddt + class TestIndex(TestBase): ++ @with_rw_repo("HEAD") ++ def test_checkout_rejects_unsafe_prefix(self, rw_repo): ++ with tempfile.TemporaryDirectory() as target: ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.index.checkout(prefix=f"{target}/") ++ ++ rw_repo.index.checkout(prefix=f"{target}/", allow_unsafe_options=True) ++ self.assertTrue(osp.isfile(osp.join(target, "CHANGES"))) ++ + def __init__(self, *args): + super().__init__(*args) + self._reset_progress() +diff --git a/test/test_refs.py b/test/test_refs.py +index 49c6e82..2c91886 100644 +--- a/test/test_refs.py ++++ b/test/test_refs.py +@@ -23,6 +23,7 @@ from git import ( + SymbolicReference, + TagReference, + ) ++from git.exc import UnsafeOptionError + from git.objects.tag import TagObject + import git.refs as refs + from git.util import Actor +@@ -54,6 +55,18 @@ class TestRefs(TestBase): + # Works without path check. + TagReference(self.rorepo, "refs/invalid/tag", check_path=False) + ++ @with_rw_repo("HEAD") ++ def test_tag_create_rejects_unsafe_file_options(self, rw_repo): ++ with tempfile.NamedTemporaryFile("w", encoding="utf-8") as message: ++ message.write("private tag message") ++ message.flush() ++ for index, option in enumerate(({"F": message.name}, {"file": message.name})): ++ with self.assertRaises(UnsafeOptionError): ++ TagReference.create(rw_repo, f"unsafe-{index}", **option) ++ ++ tag = TagReference.create(rw_repo, "allowed-file", F=message.name, allow_unsafe_options=True) ++ self.assertEqual(tag.tag.message, "private tag message") ++ + def test_tag_base(self): + tag_object_refs = [] + for tag in self.rorepo.tags: diff -Nru python-git-3.1.44/debian/patches/CVE-2026-73621.patch python-git-3.1.44/debian/patches/CVE-2026-73621.patch --- python-git-3.1.44/debian/patches/CVE-2026-73621.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-73621.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,82 @@ +From: Sebastian Thiel +Date: Sat, 25 Jul 2026 06:27:01 +0200 +Subject: fix: reject unsafe output options in Commit.count + + + +Commit.count forwards keyword arguments to git rev-list but did not apply the +unsafe-option validation used by the sibling revision APIs. + +Validate forwarded options with the existing Git.check_unsafe_options helper +and retain the explicit allow_unsafe_options escape hatch. This covers +GHSA-p538-c434-8v24 without adding another option parser. + +Git baseline: git.git a23bace963 defines --output as a shared diff option +consumed by setup_revisions; t/t6000-rev-list-misc.sh exercises that option +with rev-list. + +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/38553b6fddc7f6a667cdb45a6762343a08fc72b2 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-73621 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-73621 +--- + git/objects/commit.py | 15 ++++++++++++++- + test/test_commit.py | 5 +++++ + 2 files changed, 19 insertions(+), 1 deletion(-) + +diff --git a/git/objects/commit.py b/git/objects/commit.py +index 3af0f63..1504ff2 100644 +--- a/git/objects/commit.py ++++ b/git/objects/commit.py +@@ -269,13 +269,21 @@ class Commit(base.Object, TraversableIterableObj, Diffable, Serializable): + else: + return self.message.split(b"\n", 1)[0] + +- def count(self, paths: Union[PathLike, Sequence[PathLike]] = "", **kwargs: Any) -> int: ++ def count( ++ self, ++ paths: Union[PathLike, Sequence[PathLike]] = "", ++ allow_unsafe_options: bool = False, ++ **kwargs: Any, ++ ) -> int: + """Count the number of commits reachable from this commit. + + :param paths: + An optional path or a list of paths restricting the return value to commits + actually containing the paths. + ++ :param allow_unsafe_options: ++ Allow unsafe options, like ``--output``. ++ + :param kwargs: + Additional options to be passed to :manpage:`git-rev-list(1)`. They must not + alter the output style of the command, or parsing will yield incorrect +@@ -284,6 +292,11 @@ class Commit(base.Object, TraversableIterableObj, Diffable, Serializable): + :return: + An int defining the number of reachable commits + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), unsafe_options=self.unsafe_git_rev_options ++ ) ++ + # Yes, it makes a difference whether empty paths are given or not in our case as + # the empty paths version will ignore merge commits for some reason. + if paths: +diff --git a/test/test_commit.py b/test/test_commit.py +index 80cd55d..8efac1c 100644 +--- a/test/test_commit.py ++++ b/test/test_commit.py +@@ -301,6 +301,11 @@ class TestCommit(TestCommitSerialization): + with self.assertRaises(UnsafeOptionError): + list(Commit.iter_items(self.rorepo, "HEAD", output=marker)) + ++ def test_count_rejects_unsafe_options(self): ++ with tempfile.NamedTemporaryFile() as marker: ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.head.commit.count(output=marker.name) ++ + def test_rev_list_bisect_all(self): + """ + 'git rev-list --bisect-all' returns additional information diff -Nru python-git-3.1.44/debian/patches/CVE-2026-73622.patch python-git-3.1.44/debian/patches/CVE-2026-73622.patch --- python-git-3.1.44/debian/patches/CVE-2026-73622.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-73622.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,107 @@ +From: "GPT 5.6" +Date: Wed, 22 Jul 2026 15:42:44 +0200 +Subject: fix: prevent environment expansion in remote URLs + +Remote and submodule creation expanded environment-variable references in +caller-supplied URLs before storing them in Git configuration. This could +expose process environment values through a later network request or a tracked +.gitmodules file. + +Preserve variables literally in the remaining untrusted URL callers while +retaining Git.polish_url() expansion for intentional local-path normalization. +Add regression tests covering remote configuration and submodule configuration. + +Git baseline: git remote add and git submodule add accept URL arguments +literally; Git does not perform shell-style environment expansion on those +arguments. + +Co-authored-by: Sebastian Thiel + +Origin: https://github.com/gitpython-developers/GitPython/commit/863417457a0633db7ea5aed4fd01e0b291a41162 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-73622 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-73622 +--- + git/objects/submodule/base.py | 2 +- + git/remote.py | 2 +- + test/test_remote.py | 9 +++++++++ + test/test_submodule.py | 14 ++++++++++++++ + 4 files changed, 25 insertions(+), 2 deletions(-) + +diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py +index fa60bcd..43e0ec9 100644 +--- a/git/objects/submodule/base.py ++++ b/git/objects/submodule/base.py +@@ -601,7 +601,7 @@ class Submodule(IndexObject, TraversableIterableObj): + # END verify url + + ## See #525 for ensuring git URLs in config-files are valid under Windows. +- url = Git.polish_url(url) ++ url = Git.polish_url(url, expand_vars=False) + + # It's important to add the URL to the parent config, to let `git submodule` know. + # Otherwise there is a '-' character in front of the submodule listing: +diff --git a/git/remote.py b/git/remote.py +index 289c1ac..17cd217 100644 +--- a/git/remote.py ++++ b/git/remote.py +@@ -805,7 +805,7 @@ class Remote(LazyMixin, IterableObj): + """ + scmd = "add" + kwargs["insert_kwargs_after"] = scmd +- url = Git.polish_url(url) ++ url = Git.polish_url(url, expand_vars=False) + if not allow_unsafe_protocols: + Git.check_unsafe_protocols(url) + repo.git.remote(scmd, "--", name, url, **kwargs) +diff --git a/test/test_remote.py b/test/test_remote.py +index 61c12e5..8ff89ef 100644 +--- a/test/test_remote.py ++++ b/test/test_remote.py +@@ -4,6 +4,7 @@ + # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ + + import gc ++import os + import os.path as osp + from pathlib import Path + import random +@@ -682,6 +683,14 @@ class TestRemote(TestBase): + # Will raise fatal: Will not delete all non-push URLs. + self.assertRaises(GitCommandError, remote.delete_url, test3) + ++ @with_rw_repo("HEAD", bare=False) ++ def test_create_does_not_expand_environment_variables_in_url(self, rw_repo): ++ url = "https://example.com/${GITPYTHON_TEST_SECRET}/repo.git" ++ with mock.patch.dict(os.environ, {"GITPYTHON_TEST_SECRET": "sensitive-value"}): ++ remote = rw_repo.create_remote("untrusted", url) ++ ++ assert remote.url == url ++ + def test_fetch_error(self): + rem = self.rorepo.remote("origin") + with self.assertRaisesRegex(GitCommandError, "[Cc]ouldn't find remote ref __BAD_REF__"): +diff --git a/test/test_submodule.py b/test/test_submodule.py +index f1d4375..94925a5 100644 +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -1178,6 +1178,20 @@ class TestSubmodule(TestBase): + with self.assertRaises(cp.NoOptionError): + sm_config.get_value("core", "eol") + ++ @with_rw_directory ++ def test_add_does_not_expand_environment_variables_in_url(self, rwdir): ++ parent = git.Repo.init(osp.join(rwdir, "parent")) ++ url = osp.join(rwdir, "${GITPYTHON_TEST_SECRET}") ++ source = git.Repo.init(url) ++ Path(source.working_tree_dir, "file").write_text("content") ++ source.index.add(["file"]) ++ source.index.commit("initial") ++ ++ with mock.patch.dict(os.environ, {"GITPYTHON_TEST_SECRET": "sensitive-value"}): ++ sm = Submodule.add(parent, "new", "new", url) ++ ++ assert sm.url == Git.polish_url(url, expand_vars=False) ++ + @with_rw_repo("HEAD") + def test_submodule_add_unsafe_url(self, rw_repo): + with tempfile.TemporaryDirectory() as tdir: diff -Nru python-git-3.1.44/debian/patches/CVE-2026-73623.patch python-git-3.1.44/debian/patches/CVE-2026-73623.patch --- python-git-3.1.44/debian/patches/CVE-2026-73623.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-73623.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,67 @@ +From: "GPT 5.6" +Date: Wed, 22 Jul 2026 04:43:26 +0200 +Subject: fix: reject unsafe clone templates + + + +Treat git clone --template as unsafe because caller-controlled +templates can install hooks that execute during clone. + +Add regression coverage for both direct option and keyword forms. + +References GHSA-6p8h-3wgx-97gf. +Validated against Git baseline a23bace9. + +Co-authored-by: Sebastian Thiel + +Origin: https://github.com/gitpython-developers/GitPython/commit/ffcb5359e87619f4fe4a70a4aff5f08c5580ba97 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-73623 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-73623 +--- + git/repo/base.py | 5 +++++ + test/test_clone.py | 2 ++ + 2 files changed, 7 insertions(+) + +diff --git a/git/repo/base.py b/git/repo/base.py +index 243ecb5..5765437 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -148,6 +148,8 @@ class Repo: + # Can override configuration variables that execute arbitrary commands: + "--config", + "-c", ++ # Can install hooks that execute during clone: ++ "--template", + # Fetches from an additional caller-controlled URI: + "--bundle-uri", + ] +@@ -161,6 +163,9 @@ class Repo: + ``protocol.allow`` and ``core.gitProxy`` to execute arbitrary commands: + https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt + ++ The ``--template`` option can install hooks that execute during clone: ++ https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---templatetemplate-directory ++ + The ``--bundle-uri`` option fetches from an additional URI before fetching from the + clone URL. An untrusted value can therefore make Git access local files or + unintended network resources: +diff --git a/test/test_clone.py b/test/test_clone.py +index 46738ce..6109681 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -103,6 +103,7 @@ class TestClone(TestBase): + "-c protocol.ext.allow=always", + "-cprotocol.ext.allow=always", + "-vcprotocol.ext.allow=always", ++ f"--template={tmp_dir}", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +@@ -117,6 +118,7 @@ class TestClone(TestBase): + {"config": "protocol.ext.allow=always"}, + {"conf": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, ++ {"template": tmp_dir}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): diff -Nru python-git-3.1.44/debian/patches/CVE-2026-73624.patch python-git-3.1.44/debian/patches/CVE-2026-73624.patch --- python-git-3.1.44/debian/patches/CVE-2026-73624.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-73624.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,163 @@ +From: "GPT 5.6" +Date: Wed, 22 Jul 2026 04:46:13 +0200 +Subject: fix: guard diff output options + + + +Reject unsafe diff options before revision parsing or Git invocation so +callers cannot write command output to arbitrary filesystem paths. + +Cover commit and index diffs, including option-like revisions, and +preserve an explicit allow_unsafe_options escape hatch. + +References GHSA-fjr4-x663-mwxc. +Validated against Git baseline a23bace9. + +Co-authored-by: Sebastian Thiel + +Origin: https://github.com/gitpython-developers/GitPython/commit/1d51b891d7f236044a6aa17498ec682b63dad6e6 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-73624 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-73624 +--- + git/diff.py | 14 ++++++++++++-- + git/index/base.py | 17 +++++++++++++++-- + test/test_diff.py | 20 ++++++++++++++++++++ + 3 files changed, 47 insertions(+), 4 deletions(-) + +diff --git a/git/diff.py b/git/diff.py +index 9c6ae59..499f884 100644 +--- a/git/diff.py ++++ b/git/diff.py +@@ -9,7 +9,7 @@ import enum + import re + import warnings + +-from git.cmd import handle_process_output ++from git.cmd import Git, handle_process_output + from git.compat import defenc + from git.objects.blob import Blob + from git.objects.util import mode_str_to_int +@@ -34,7 +34,6 @@ from git.types import Literal, PathLike + if TYPE_CHECKING: + from subprocess import Popen + +- from git.cmd import Git + from git.objects.base import IndexObject + from git.objects.commit import Commit + from git.objects.tree import Tree +@@ -186,6 +185,7 @@ class Diffable: + other: Union[DiffConstants, "Tree", "Commit", str, None] = INDEX, + paths: Union[PathLike, List[PathLike], Tuple[PathLike, ...], None] = None, + create_patch: bool = False, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> "DiffIndex[Diff]": + """Create diffs between two items being trees, trees and index or an index and +@@ -215,6 +215,10 @@ class Diffable: + applied makes the self to other. Patches are somewhat costly as blobs have + to be read and diffed. + ++ :param allow_unsafe_options: ++ If ``True``, allow options such as ``--output`` that can write to arbitrary ++ filesystem paths. ++ + :param kwargs: + Additional arguments passed to :manpage:`git-diff(1)`, such as ``R=True`` to + swap both sides of the diff. +@@ -227,6 +231,12 @@ class Diffable: + an instance of :class:`~git.objects.tree.Tree` or + :class:`~git.objects.commit.Commit`, or a git command error will occur. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([other], kwargs), ++ unsafe_options=self.repo.unsafe_git_revision_options, ++ ) ++ + args: List[Union[PathLike, Diffable]] = [] + args.append("--abbrev=40") # We need full shas. + args.append("--full-index") # Get full index paths, not only filenames. +diff --git a/git/index/base.py b/git/index/base.py +index 0ef9606..4b58ad5 100644 +--- a/git/index/base.py ++++ b/git/index/base.py +@@ -1490,6 +1490,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + ] = git_diff.INDEX, + paths: Union[PathLike, List[PathLike], Tuple[PathLike, ...], None] = None, + create_patch: bool = False, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> git_diff.DiffIndex[git_diff.Diff]: + """Diff this index against the working copy or a :class:`~git.objects.tree.Tree` +@@ -1502,6 +1503,12 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + Will only work with indices that represent the default git index as they + have not been initialized with a stream. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([other], kwargs), ++ unsafe_options=self.repo.unsafe_git_revision_options, ++ ) ++ + # Only run if we are the default repository index. + if self._file_path != self._index_path(): + raise AssertionError("Cannot call %r on indices that do not represent the default git index" % self.diff()) +@@ -1520,7 +1527,13 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + # Invert the existing R flag. + cur_val = kwargs.get("R", False) + kwargs["R"] = not cur_val +- return other.diff(self.INDEX, paths, create_patch, **kwargs) ++ return other.diff( ++ self.INDEX, ++ paths, ++ create_patch, ++ allow_unsafe_options=allow_unsafe_options, ++ **kwargs, ++ ) + # END diff against other item handling + + # If other is not None here, something is wrong. +@@ -1528,4 +1541,4 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + raise ValueError("other must be None, Diffable.INDEX, a Tree or Commit, was %r" % other) + + # Diff against working copy - can be handled by superclass natively. +- return super().diff(other, paths, create_patch, **kwargs) ++ return super().diff(other, paths, create_patch, allow_unsafe_options=allow_unsafe_options, **kwargs) +diff --git a/test/test_diff.py b/test/test_diff.py +index 612fbd9..a395f4a 100644 +--- a/test/test_diff.py ++++ b/test/test_diff.py +@@ -14,6 +14,7 @@ import pytest + + from git import NULL_TREE, Diff, DiffIndex, Diffable, GitCommandError, Repo, Submodule + from git.cmd import Git ++from git.exc import UnsafeOptionError + + from test.lib import StringProcessAdapter, TestBase, fixture, with_rw_directory + +@@ -352,6 +353,25 @@ class TestDiff(TestBase): + self.assertIsInstance(diff.a_blob.size, int) + self.assertIsInstance(diff.b_blob.size, int) + ++ def test_diff_rejects_unsafe_output_options(self): ++ commit = self.rorepo.head.commit ++ ++ calls = ( ++ lambda target: commit.diff(output=target), ++ lambda target: commit.diff(other=f"--output={target}"), ++ lambda target: self.rorepo.index.diff(NULL_TREE, output=target), ++ lambda target: self.rorepo.index.diff(f"--output={target}"), ++ ) ++ for index, call in enumerate(calls): ++ target = osp.join(self.repo_dir, f"diff-output-{index}") ++ with self.assertRaises(UnsafeOptionError): ++ call(target) ++ self.assertFalse(osp.exists(target)) ++ ++ allowed_target = osp.join(self.repo_dir, "allowed-diff-output") ++ commit.diff(output=allowed_target, allow_unsafe_options=True) ++ self.assertTrue(osp.isfile(allowed_target)) ++ + def test_diff_interface(self): + """Test a few variations of the main diff routine.""" + assertion_map = {} diff -Nru python-git-3.1.44/debian/patches/CVE-2026-73625.patch python-git-3.1.44/debian/patches/CVE-2026-73625.patch --- python-git-3.1.44/debian/patches/CVE-2026-73625.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-73625.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,79 @@ +From: "GPT 5.6" +Date: Wed, 22 Jul 2026 04:46:13 +0200 +Subject: fix: validate split short-option values + +Single-character keyword arguments are transformed into an option token and +a separate value token. The unsafe-option candidate builder only checked the +keyword name, allowing an option-like value to bypass guards shared by clone, +remote, revision, blame, and archive operations. + +Include dash-prefixed values only when short options are actually split, +including sequence values, while preserving bare values and the non-splitting +compatibility path. + +Git baseline a23bace9 defines clone -n and --upload-pack as distinct options, +matching the argv boundary this validation now preserves. + +Refs GHSA-r9mr-m37c-5fr3. + +Co-authored-by: Sebastian Thiel + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/e8d0fbf774d1f6baa3b481adfe48bd262e43b453 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-73625 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-73625 +--- + git/cmd.py | 7 +++++++ + test/test_git.py | 17 +++++++++++++++++ + 2 files changed, 24 insertions(+) + +diff --git a/git/cmd.py b/git/cmd.py +index 870509a..0eface0 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -806,11 +806,18 @@ class Git(metaclass=_GitMeta): + option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-") + ] + if kwargs: ++ split_single_char_options = kwargs.get("split_single_char_options", True) + for key, value in kwargs.items(): + values = value if isinstance(value, (list, tuple)) else (value,) + if any(value is True or (value is not False and value is not None) for value in values): + key = str(key) + options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}") ++ if len(key) == 1 and split_single_char_options: ++ options.extend( ++ str(value) ++ for value in values ++ if value is not True and value not in (False, None) and str(value).startswith("-") ++ ) + return options + + +diff --git a/test/test_git.py b/test/test_git.py +index 846e3a4..100d211 100644 +--- a/test/test_git.py ++++ b/test/test_git.py +@@ -184,6 +184,23 @@ class TestGit(TestBase): + + self.assertEqual(options, ["--max-count"]) + ++ def test_option_candidates_include_split_single_char_option_values(self): ++ cases = [ ++ ({"n": "--upload-pack=helper"}, ["-n", "--upload-pack=helper"], ["--upload-pack"]), ++ ({"g": ("safe", "--out=target")}, ["-g", "--out=target"], ["--output"]), ++ ] ++ ++ for kwargs, candidates, unsafe_options in cases: ++ self.assertEqual(Git._option_candidates(kwargs=kwargs), candidates) ++ with self.assertRaises(UnsafeOptionError): ++ Git.check_unsafe_options(options=candidates, unsafe_options=unsafe_options) ++ ++ self.assertEqual(self.git.transform_kwargs(n="--upload-pack=helper"), ["-n", "--upload-pack=helper"]) ++ ++ unsplit_kwargs = {"n": "--upload-pack=helper", "split_single_char_options": False} ++ self.assertEqual(self.git.transform_kwargs(**unsplit_kwargs), ["-n--upload-pack=helper"]) ++ self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n"]) ++ + def test_check_unsafe_options_does_not_treat_short_options_as_abbreviations(self): + unsafe_options = ["--upload-pack"] + diff -Nru python-git-3.1.44/debian/patches/CVE-2026-76217.patch python-git-3.1.44/debian/patches/CVE-2026-76217.patch --- python-git-3.1.44/debian/patches/CVE-2026-76217.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-76217.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,289 @@ +From: Byron +Date: Tue, 4 Aug 2026 03:18:54 +0000 +Subject: Guard pathspec file inputs in high-level commands + + +GHSA-hh9p-6wh2-4mfc reports that high-level rm and checkout wrappers +forwarded pathspec file options without GitPython's unsafe-option policy. +A regression showed that both commands surfaced multi-line pathspec data in +Git errors, while reset consumed the same caller-selected file without a +validation error. The audit also found that reset's positional commit could +carry the option before its argument separator. + +Define one shared unsafe pathspec-file option list and apply it to +IndexFile.remove, Head.checkout, and HEAD.reset before invoking Git. Check +reset's positional commit as well as keyword options, retain the standard +allow_unsafe_options escape hatch for trusted callers, and cover abbreviated +long-option spellings. + +An audit against Git cf5497b14c5a24f10c13f7e0ee85cb95af13ea6a +(v2.55.0.windows.3-16-gcf5497b14c) found pathspec-file support in add, +checkout/restore, commit, reset, rm, and stash. GitPython has no arbitrary +high-level option forwarding to the other commands, and git mv does not +support this option. + +Validated with focused rejection and opt-in tests, 214 affected-module +regressions, Ruff, basedpyright, and git diff --check. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/f2550b65bf60ca087190981e2c7b6865e201f40c +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-76217 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-76217 +--- + git/cmd.py | 6 ++++++ + git/index/base.py | 10 ++++++++++ + git/refs/head.py | 27 ++++++++++++++++++++++++++- + test/test_git.py | 12 ++++++++++++ + test/test_index.py | 26 ++++++++++++++++++++++++++ + test/test_refs.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ + 6 files changed, 125 insertions(+), 1 deletion(-) + +diff --git a/git/cmd.py b/git/cmd.py +index 0eface0..e1dfac4 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -421,6 +421,12 @@ class Git(metaclass=_GitMeta): + "--upload-pack", + ] + ++ unsafe_git_pathspec_from_file_options = [ ++ # Reads pathspecs from a caller-controlled file. Some commands include an ++ # unmatched pathspec in their error output, which can disclose the file. ++ "--pathspec-from-file", ++ ] ++ + def __getstate__(self) -> Dict[str, Any]: + return slots_to_dict(self, exclude=self._excluded_) + +diff --git a/git/index/base.py b/git/index/base.py +index 4b58ad5..4a00f60 100644 +--- a/git/index/base.py ++++ b/git/index/base.py +@@ -981,6 +981,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + self, + items: Union[PathLike, Sequence[Union[PathLike, Blob, BaseIndexEntry, "Submodule"]]], + working_tree: bool = False, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> List[str]: + R"""Remove the given items from the index and optionally from the working tree +@@ -1011,6 +1012,10 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + physically removing the respective file. This may fail if there are + uncommitted changes in it. + ++ :param allow_unsafe_options: ++ Allow unsafe options such as ``--pathspec-from-file`` to be passed to ++ :manpage:`git-rm(1)`. ++ + :param kwargs: + Additional keyword arguments to be passed to :manpage:`git-rm(1)`, such as + ``r`` to allow recursive removal. +@@ -1022,6 +1027,11 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + This is interesting to know in case you have provided a directory or globs. + Paths are relative to the repository. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=Git.unsafe_git_pathspec_from_file_options, ++ ) + args = [] + if not working_tree: + args.append("--cached") +diff --git a/git/refs/head.py b/git/refs/head.py +index 6836344..624028f 100644 +--- a/git/refs/head.py ++++ b/git/refs/head.py +@@ -19,6 +19,7 @@ from .symbolic import SymbolicReference + + from typing import Any, Sequence, TYPE_CHECKING, Union + ++from git.cmd import Git + from git.types import Commit_ish, PathLike + + if TYPE_CHECKING: +@@ -66,6 +67,7 @@ class HEAD(SymbolicReference): + index: bool = True, + working_tree: bool = False, + paths: Union[PathLike, Sequence[PathLike], None] = None, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> "HEAD": + """Reset our HEAD to the given commit optionally synchronizing the index and +@@ -88,12 +90,21 @@ class HEAD(SymbolicReference): + Single path or list of paths relative to the git root directory + that are to be reset. This allows to partially reset individual files. + ++ :param allow_unsafe_options: ++ Allow unsafe options such as ``--pathspec-from-file`` to be passed to ++ :manpage:`git-reset(1)`. ++ + :param kwargs: + Additional arguments passed to :manpage:`git-reset(1)`. + + :return: + self + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([commit], kwargs), ++ unsafe_options=Git.unsafe_git_pathspec_from_file_options, ++ ) + mode: Union[str, None] + mode = "--soft" + if index: +@@ -238,7 +249,12 @@ class Head(Reference): + self.path = "%s/%s" % (self._common_path_default, new_path) + return self + +- def checkout(self, force: bool = False, **kwargs: Any) -> Union["HEAD", "Head"]: ++ def checkout( ++ self, ++ force: bool = False, ++ allow_unsafe_options: bool = False, ++ **kwargs: Any, ++ ) -> Union["HEAD", "Head"]: + """Check out this head by setting the HEAD to this reference, by updating the + index to reflect the tree we point to and by updating the working tree to + reflect the latest index. +@@ -250,6 +266,10 @@ class Head(Reference): + If ``False``, :exc:`~git.exc.GitCommandError` will be raised in that + situation. + ++ :param allow_unsafe_options: ++ Allow unsafe options such as ``--pathspec-from-file`` to be passed to ++ :manpage:`git-checkout(1)`. ++ + :param kwargs: + Additional keyword arguments to be passed to git checkout, e.g. + ``b="new_branch"`` to create a new branch at the given spot. +@@ -265,6 +285,11 @@ class Head(Reference): + the HEAD detached which is allowed and possible, but remains a special state + that some tools might not be able to handle. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=Git.unsafe_git_pathspec_from_file_options, ++ ) + kwargs["f"] = force + if kwargs["f"] is False: + kwargs.pop("f") +diff --git a/test/test_git.py b/test/test_git.py +index 100d211..f675b89 100644 +--- a/test/test_git.py ++++ b/test/test_git.py +@@ -184,6 +184,18 @@ class TestGit(TestBase): + + self.assertEqual(options, ["--max-count"]) + ++ def test_option_candidates_include_falsey_non_boolean_values(self): ++ kwargs = {"pathspec_from_file": 0} ++ candidates = Git._option_candidates(kwargs=kwargs) ++ ++ self.assertEqual(candidates, ["--pathspec-from-file"]) ++ self.assertEqual(self.git.transform_kwargs(**kwargs), ["--pathspec-from-file=0"]) ++ with self.assertRaises(UnsafeOptionError): ++ Git.check_unsafe_options( ++ options=candidates, ++ unsafe_options=Git.unsafe_git_pathspec_from_file_options, ++ ) ++ + def test_option_candidates_include_split_single_char_option_values(self): + cases = [ + ({"n": "--upload-pack=helper"}, ["-n", "--upload-pack=helper"], ["--upload-pack"]), +diff --git a/test/test_index.py b/test/test_index.py +index 75353d7..d7810e3 100644 +--- a/test/test_index.py ++++ b/test/test_index.py +@@ -203,6 +203,32 @@ class TestIndex(TestBase): + rw_repo.index.checkout(prefix=f"{target}/", allow_unsafe_options=True) + self.assertTrue(osp.isfile(osp.join(target, "CHANGES"))) + ++ @with_rw_repo("HEAD") ++ def test_remove_rejects_pathspec_from_file(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ pathspecs = Path(tdir) / "pathspecs" ++ pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") ++ for option_name in ("pathspec_from_file", "pathspec_from"): ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.index.remove( ++ [], ++ pathspec_file_nul=True, ++ **{option_name: str(pathspecs)}, ++ ) ++ ++ @with_rw_repo("HEAD") ++ def test_remove_allows_explicit_pathspec_from_file(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ pathspecs = Path(tdir) / "pathspecs" ++ pathspecs.write_bytes(b"CHANGES\0") ++ removed = rw_repo.index.remove( ++ [], ++ pathspec_from_file=str(pathspecs), ++ pathspec_file_nul=True, ++ allow_unsafe_options=True, ++ ) ++ assert "CHANGES" in removed ++ + def __init__(self, *args): + super().__init__(*args) + self._reset_progress() +diff --git a/test/test_refs.py b/test/test_refs.py +index 2c91886..f0f2e59 100644 +--- a/test/test_refs.py ++++ b/test/test_refs.py +@@ -239,6 +239,51 @@ class TestRefs(TestBase): + assert isinstance(res, SymbolicReference) + assert res.name == "HEAD" + ++ @with_rw_repo("HEAD") ++ def test_head_checkout_rejects_pathspec_from_file(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ pathspecs = Path(tdir) / "pathspecs" ++ pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") ++ for option_name in ("pathspec_from_file", "pathspec_from"): ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.active_branch.checkout( ++ pathspec_file_nul=True, ++ **{option_name: str(pathspecs)}, ++ ) ++ ++ @with_rw_repo("HEAD") ++ def test_head_reset_rejects_pathspec_from_file(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ pathspecs = Path(tdir) / "pathspecs" ++ pathspecs.write_bytes(b"unmatched-path-one\nunmatched-path-two") ++ for option_name in ("pathspec_from_file", "pathspec_from"): ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.head.reset( ++ pathspec_file_nul=True, ++ **{option_name: str(pathspecs)}, ++ ) ++ for option_name in ("--pathspec-from-file", "--pathspec-from"): ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.head.reset( ++ f"{option_name}={pathspecs}", ++ pathspec_file_nul=True, ++ ) ++ ++ @with_rw_repo("HEAD") ++ def test_head_commands_allow_explicit_pathspec_from_file(self, rw_repo): ++ with tempfile.TemporaryDirectory() as tdir: ++ pathspecs = Path(tdir) / "pathspecs" ++ pathspecs.write_bytes(b"CHANGES\0") ++ options = { ++ "pathspec_from_file": str(pathspecs), ++ "pathspec_file_nul": True, ++ "allow_unsafe_options": True, ++ } ++ head = rw_repo.head ++ branch = rw_repo.active_branch ++ assert head.reset(**options) is head ++ assert branch.checkout(**options) == branch ++ + @with_rw_repo("0.1.6") + def test_head_reset(self, rw_repo): + cur_head = rw_repo.head diff -Nru python-git-3.1.44/debian/patches/CVE-2026-76218.patch python-git-3.1.44/debian/patches/CVE-2026-76218.patch --- python-git-3.1.44/debian/patches/CVE-2026-76218.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-76218.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,124 @@ +From: Byron +Date: Tue, 4 Aug 2026 02:48:01 +0000 +Subject: Guard unsafe git init options + + +GHSA-9rj7-rf2p-w77r reports that Repo.init forwarded git-init +options without applying GitPython's unsafe-option policy. A regression +showed template and abbreviated option spellings reached Git without an +UnsafeOptionError and could create the destination before validation. + +Add a git-init denylist for template installation and separate Git +directory redirection, check keyword options before any path or directory +mutation, and provide the standard explicit allow_unsafe_options escape +hatch. This preserves trusted uses while rejecting untrusted forwarding by +default. + +An audit against Git cf5497b14c5a24f10c13f7e0ee85cb95af13ea6a +(v2.55.0.windows.3-16-gcf5497b14c) confirmed that init and clone are the +built-in commands that consume repository template directories. Clone, +clone_from, and submodule cloning already share the guarded clone helper; +the similarly named commit option only reads a commit-message template. + +Validated with the focused init regression, the clone/init unsafe-option +suite, 185 config/Git/index/clone tests, Ruff, and basedpyright. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/d9ddb55bdc66ffe8c9932fe460e6b8c8211e47c7 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-76218 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-76218 +--- + git/repo/base.py | 18 ++++++++++++++++++ + test/test_repo.py | 27 +++++++++++++++++++++++++++ + 2 files changed, 45 insertions(+) + +diff --git a/git/repo/base.py b/git/repo/base.py +index 5765437..eaf48f0 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -141,6 +141,14 @@ class Repo: + re_author_committer_start = re.compile(r"^(author|committer)") + re_tab_full_line = re.compile(r"^\t(.*)$") + ++ unsafe_git_init_options = [ ++ # Can install hooks that execute during later Git commands: ++ "--template", ++ # Redirects the repository metadata to a caller-controlled path: ++ "--separate-git-dir", ++ ] ++ """Options to :manpage:`git-init(1)` that permit unsafe code execution or I/O.""" ++ + unsafe_git_clone_options = [ + # Executes arbitrary commands: + "--upload-pack", +@@ -1366,6 +1374,7 @@ class Repo: + mkdir: bool = True, + odbt: Type[GitCmdObjectDB] = GitCmdObjectDB, + expand_vars: bool = True, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> "Repo": + """Initialize a git repository at the given path if specified. +@@ -1390,6 +1399,10 @@ class Repo: + information disclosure, allowing attackers to access the contents of + environment variables. + ++ :param allow_unsafe_options: ++ Allow unsafe options to be used, such as ``--template`` and ++ ``--separate-git-dir``. ++ + :param kwargs: + Keyword arguments serving as additional options to the + :manpage:`git-init(1)` command. +@@ -1397,6 +1410,11 @@ class Repo: + :return: + :class:`Repo` (the newly created repo) + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([], kwargs), ++ unsafe_options=cls.unsafe_git_init_options, ++ ) + if path: + path = expand_path(path, expand_vars) + if mkdir and path and not osp.exists(path): +diff --git a/test/test_repo.py b/test/test_repo.py +index b418559..0f14c76 100644 +--- a/test/test_repo.py ++++ b/test/test_repo.py +@@ -79,6 +79,33 @@ class TestRepo(TestBase): + with tempfile.TemporaryDirectory() as tdir: + self.assertRaises(InvalidGitRepositoryError, Repo, tdir) + ++ def test_init_rejects_unsafe_options(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ template_dir = osp.join(tdir, "template") ++ os.mkdir(template_dir) ++ unsafe_options = [ ++ {"template": template_dir}, ++ {"templa": template_dir}, ++ {"separate_git_dir": osp.join(tdir, "git-dir")}, ++ {"separate_git_di": osp.join(tdir, "git-dir")}, ++ ] ++ for index, kwargs in enumerate(unsafe_options): ++ repo_dir = osp.join(tdir, f"repo-{index}") ++ with self.assertRaises(UnsafeOptionError): ++ Repo.init(repo_dir, **kwargs) ++ assert not osp.exists(repo_dir) ++ ++ def test_init_allows_explicitly_unsafe_options(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ template_dir = osp.join(tdir, "template") ++ os.mkdir(template_dir) ++ repo = Repo.init( ++ osp.join(tdir, "repo"), ++ template=template_dir, ++ allow_unsafe_options=True, ++ ) ++ assert repo.git_dir ++ + @with_rw_directory + def test_new_should_raise_on_invalid_repo_location_within_repo(self, rw_dir): + repo_dir = osp.join(rw_dir, "repo") diff -Nru python-git-3.1.44/debian/patches/CVE-2026-76219.patch python-git-3.1.44/debian/patches/CVE-2026-76219.patch --- python-git-3.1.44/debian/patches/CVE-2026-76219.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-76219.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,174 @@ +From: Byron +Date: Sun, 2 Aug 2026 09:06:40 +0000 +Subject: Guard read-tree index output paths + + +GHSA-4gmw-gg2m-w46p reports that caller-controlled treeish arguments could +be parsed by git read-tree as --index-output and select an arbitrary output +path. A regression test showed that from_tree reached Git instead of raising +UnsafeOptionError; the same unchecked path was reachable through reset and +both merge_tree treeish positions. + +Add the project-standard unsafe-option guard and explicit opt-out to +from_tree, merge_tree, and reset. Check positional and keyword candidates so +abbreviations and alternate forwarding forms are covered before read-tree +runs. + +A broader audit found only two read-tree sinks in the codebase; both are now +guarded, and reset delegates to the guarded from_tree path. The only remaining +index-output use is GitPython's controlled temporary index. Git cf5497b14 +confirms read-tree parses this path-taking option before tree arguments. +Focused index tests and Ruff checks pass. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/9b5dcaf85da5946dbf69dcd53f9edba08f760b32 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-76219 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-76219 +--- + git/index/base.py | 40 +++++++++++++++++++++++++++++++++++++--- + test/test_index.py | 16 ++++++++++++++++ + 2 files changed, 53 insertions(+), 3 deletions(-) + +diff --git a/git/index/base.py b/git/index/base.py +index 4a00f60..240ef04 100644 +--- a/git/index/base.py ++++ b/git/index/base.py +@@ -131,6 +131,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + """ + + unsafe_git_checkout_index_options = ["--prefix"] ++ unsafe_git_read_tree_options = ["--index-output"] + + __slots__ = ("repo", "version", "entries", "_extension_data", "_file_path") + +@@ -256,7 +257,12 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + + @post_clear_cache + @default_index +- def merge_tree(self, rhs: Treeish, base: Union[None, Treeish] = None) -> "IndexFile": ++ def merge_tree( ++ self, ++ rhs: Treeish, ++ base: Union[None, Treeish] = None, ++ allow_unsafe_options: bool = False, ++ ) -> "IndexFile": + """Merge the given `rhs` treeish into the current index, possibly taking + a common base treeish into account. + +@@ -270,6 +276,9 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + Optional treeish reference pointing to the common base of `rhs` and this + index which equals lhs. + ++ :param allow_unsafe_options: ++ Allow options that may write to arbitrary paths. ++ + :return: + self (containing the merge and possibly unmerged entries in case of + conflicts) +@@ -280,6 +289,12 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + yourself, you have to commit the changed index (or make a valid tree from + it) and retry with a three-way :meth:`index.from_tree ` call. + """ ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates([base, rhs]), ++ unsafe_options=self.unsafe_git_read_tree_options, ++ ) ++ + # -i : ignore working tree status + # --aggressive : handle more merge cases + # -m : do an actual merge +@@ -324,7 +339,13 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + return inst + + @classmethod +- def from_tree(cls, repo: "Repo", *treeish: Treeish, **kwargs: Any) -> "IndexFile": ++ def from_tree( ++ cls, ++ repo: "Repo", ++ *treeish: Treeish, ++ allow_unsafe_options: bool = False, ++ **kwargs: Any, ++ ) -> "IndexFile": + R"""Merge the given treeish revisions into a new index which is returned. + The original index will remain unaltered. + +@@ -348,6 +369,9 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + :param kwargs: + Additional arguments passed to :manpage:`git-read-tree(1)`. + ++ :param allow_unsafe_options: ++ Allow options that may write to arbitrary paths. ++ + :return: + New :class:`IndexFile` instance. It will point to a temporary index location + which does not exist anymore. If you intend to write such a merged Index, +@@ -365,6 +389,12 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + if len(treeish) == 0 or len(treeish) > 3: + raise ValueError("Please specify between 1 and 3 treeish, got %i" % len(treeish)) + ++ if not allow_unsafe_options: ++ Git.check_unsafe_options( ++ options=Git._option_candidates(treeish, kwargs), ++ unsafe_options=cls.unsafe_git_read_tree_options, ++ ) ++ + arg_list: List[Union[Treeish, str]] = [] + # Ignore that the working tree and index possibly are out of date. + if len(treeish) > 1: +@@ -1408,6 +1438,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + working_tree: bool = False, + paths: Union[None, Iterable[PathLike]] = None, + head: bool = False, ++ allow_unsafe_options: bool = False, + **kwargs: Any, + ) -> "IndexFile": + """Reset the index to reflect the tree at the given commit. This will not adjust +@@ -1439,6 +1470,9 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + The paths need to exist at the commit, otherwise an exception will be + raised. + ++ :param allow_unsafe_options: ++ Allow options that may write to arbitrary paths. ++ + :param kwargs: + Additional keyword arguments passed to :manpage:`git-reset(1)`. + +@@ -1455,7 +1489,7 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + """ + # What we actually want to do is to merge the tree into our existing index, + # which is what git-read-tree does. +- new_inst = type(self).from_tree(self.repo, commit) ++ new_inst = type(self).from_tree(self.repo, commit, allow_unsafe_options=allow_unsafe_options) + if not paths: + self.entries = new_inst.entries + else: +diff --git a/test/test_index.py b/test/test_index.py +index d7810e3..ddd5d46 100644 +--- a/test/test_index.py ++++ b/test/test_index.py +@@ -344,6 +344,22 @@ class TestIndex(TestBase): + except Exception as ex: + assert "index.lock' could not be obtained" not in str(ex) + ++ @with_rw_repo("0.1.6") ++ def test_read_tree_methods_reject_index_output(self, rw_repo): ++ output_path = (Path(rw_repo.working_tree_dir) / "alternate-index").as_posix() ++ unsafe_option = f"--index-output={output_path}" ++ ++ with pytest.raises(UnsafeOptionError): ++ IndexFile.from_tree(rw_repo, unsafe_option) ++ with pytest.raises(UnsafeOptionError): ++ IndexFile.from_tree(rw_repo, "HEAD", index_output=output_path) ++ with pytest.raises(UnsafeOptionError): ++ rw_repo.index.reset(unsafe_option) ++ with pytest.raises(UnsafeOptionError): ++ rw_repo.index.merge_tree(unsafe_option) ++ with pytest.raises(UnsafeOptionError): ++ rw_repo.index.merge_tree("HEAD", base=unsafe_option) ++ + @with_rw_repo("0.1.6") + def test_index_file_from_tree(self, rw_repo): + common_ancestor_sha = "5117c9c8a4d3af19a9958677e45cda9269de1541" diff -Nru python-git-3.1.44/debian/patches/CVE-2026-76220.patch python-git-3.1.44/debian/patches/CVE-2026-76220.patch --- python-git-3.1.44/debian/patches/CVE-2026-76220.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-76220.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,88 @@ +From: Byron +Date: Sun, 2 Aug 2026 08:59:57 +0000 +Subject: Check joined short-option values before Git execution + + +GHSA-wvpp-8hx9-p66j reports that unsafe-option checks omitted the value +joined to a one-character option when split_single_char_options was false. A +regression test reproduced the mismatch: GitPython checked only -n even though +it emitted a joined -nVALUE token that Git parses as clustered short options. + +Collect the exact joined token for unsplit one-character keyword arguments so +the existing clustered-short-option validation sees every option character. +The split form and long-option behavior remain unchanged. + +A broader audit confirmed that all guarded keyword-forwarding APIs use +_option_candidates, including clone, ls-remote, fetch, pull, push, archive, +revision, diff, checkout-index, and tag paths. Git cf5497b14 confirms repeated +short-option parsing within a joined token. Focused candidate and unsafe-option +tests pass. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/96a888f4d782cb2f80452148e48e60ce4af6d541 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-76220 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-76220 +--- + git/cmd.py | 12 ++++++++++-- + test/test_git.py | 11 ++++++++++- + 2 files changed, 20 insertions(+), 3 deletions(-) + +diff --git a/git/cmd.py b/git/cmd.py +index e1dfac4..077dd36 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -817,13 +817,21 @@ class Git(metaclass=_GitMeta): + values = value if isinstance(value, (list, tuple)) else (value,) + if any(value is True or (value is not False and value is not None) for value in values): + key = str(key) +- options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}") +- if len(key) == 1 and split_single_char_options: ++ if len(key) != 1: ++ options.append(f"--{dashify(key)}") ++ elif split_single_char_options: ++ options.append(f"-{key}") + options.extend( + str(value) + for value in values + if value is not True and value not in (False, None) and str(value).startswith("-") + ) ++ else: ++ options.extend( ++ f"-{key}" if value is True else f"-{key}{value}" ++ for value in values ++ if value is True or (value is not False and value is not None) ++ ) + return options + + +diff --git a/test/test_git.py b/test/test_git.py +index f675b89..8927bba 100644 +--- a/test/test_git.py ++++ b/test/test_git.py +@@ -211,7 +211,7 @@ class TestGit(TestBase): + + unsplit_kwargs = {"n": "--upload-pack=helper", "split_single_char_options": False} + self.assertEqual(self.git.transform_kwargs(**unsplit_kwargs), ["-n--upload-pack=helper"]) +- self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n"]) ++ self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n--upload-pack=helper"]) + + def test_check_unsafe_options_does_not_treat_short_options_as_abbreviations(self): + unsafe_options = ["--upload-pack"] +@@ -241,6 +241,15 @@ class TestGit(TestBase): + + Git.check_unsafe_options(options=["-oupstream", "-bcurrent"], unsafe_options=unsafe_options) + ++ def test_option_candidates_include_joined_single_char_option_values(self): ++ kwargs = {"n": "uhelper", "split_single_char_options": False} ++ candidates = Git._option_candidates(kwargs=kwargs) ++ ++ self.assertEqual(candidates, ["-nuhelper"]) ++ with self.assertRaises(UnsafeOptionError): ++ Git.check_unsafe_options(options=candidates, unsafe_options=["-u"]) ++ ++ + _shell_cases = ( + # value_in_call, value_from_class, expected_popen_arg + (None, False, False), diff -Nru python-git-3.1.44/debian/patches/CVE-2026-76221.patch python-git-3.1.44/debian/patches/CVE-2026-76221.patch --- python-git-3.1.44/debian/patches/CVE-2026-76221.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-76221.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,102 @@ +From: Byron +Date: Sun, 2 Aug 2026 08:57:12 +0000 +Subject: Reject syntax-bearing git config option names + + +GHSA-jm78-9fvv-mhgr reports that config option names containing Git +syntax can be serialized as unintended directives. A regression test showed +that set, set_value, and add_value accepted delimiter, comment, bracket, and +whitespace characters in option names. + +Restrict written option names to GitPython's established safe character set of +letters, digits, hyphens, underscores, and dots. This blocks characters that +can change config syntax while preserving option names historically supported +by the writer and SectionConstraint. + +A broader audit confirmed that every public option-creating config API and +SectionConstraint delegate reaches this validator; no separate config writer +sink was found. The behavior was checked against Git cf5497b14, and the full +config test module plus dotted-option regression pass. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/a495ccd3b547ccd60b2187215823b72a9c0188bf +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-76221 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-76221 +--- + git/config.py | 5 +++++ + test/test_config.py | 37 +++++++++++++++++++++++++++++++++++++ + 2 files changed, 42 insertions(+) + +diff --git a/git/config.py b/git/config.py +index e94ef55..0a85563 100644 +--- a/git/config.py ++++ b/git/config.py +@@ -75,6 +75,9 @@ See: https://git-scm.com/docs/git-config#_conditional_includes + UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]") + """Characters that cannot be safely written in config names or values.""" + ++VALID_CONFIG_OPTION_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") ++"""Pattern for option names that can be written without changing config syntax.""" ++ + + class MetaParserBuilder(abc.ABCMeta): # noqa: B024 + """Utility class wrapping base-class methods into decorators that assure read-only +@@ -876,6 +879,8 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None: + if isinstance(name, str) and UNSAFE_CONFIG_CHARS_RE.search(name): + raise ValueError("Git config %s names must not contain CR, LF, or NUL" % label) ++ if label == "option" and isinstance(name, str) and not VALID_CONFIG_OPTION_NAME_RE.fullmatch(name): ++ raise ValueError("Git config option names may contain only letters, digits, '-', '_', or '.'") + if label == "section" and isinstance(name, str): + in_quotes = False + escaped = False +diff --git a/test/test_config.py b/test/test_config.py +index f7e9aef..be20508 100644 +--- a/test/test_config.py ++++ b/test/test_config.py +@@ -194,6 +194,43 @@ class TestBase(TestCase): + self.assertEqual(git_config.get_value("user", "name"), "safe") + self.assertFalse(git_config.has_section("core")) + ++ @with_rw_directory ++ def test_writer_rejects_invalid_option_names(self, rw_dir): ++ config_path = osp.join(rw_dir, "config") ++ bad_options = ( ++ "name=value", ++ "name#comment", ++ "name;comment", ++ "name with space", ++ "name\twith-tab", ++ "name[section", ++ "name]section", ++ "name:colon", ++ 'name"quote', ++ "name\\escape", ++ ) ++ ++ with GitConfigParser(config_path, read_only=False) as git_config: ++ git_config.add_section("user") ++ for bad_option in bad_options: ++ with pytest.raises(ValueError, match="option name"): ++ git_config.set("user", bad_option, "unsafe") ++ with pytest.raises(ValueError, match="option name"): ++ git_config.set_value("user", bad_option, "unsafe") ++ with pytest.raises(ValueError, match="option name"): ++ git_config.add_value("user", bad_option, "unsafe") ++ ++ git_config.set_value("user", "safe-option1", "safe") ++ git_config.set_value("user", "safe_option2", "safe") ++ git_config.set_value("user", "3safe_option", "safe") ++ git_config.set_value("user", "safe.option3", "safe") ++ ++ with GitConfigParser(config_path, read_only=True) as git_config: ++ self.assertEqual(git_config.get_value("user", "safe-option1"), "safe") ++ self.assertEqual(git_config.get_value("user", "safe_option2"), "safe") ++ self.assertEqual(git_config.get_value("user", "3safe_option"), "safe") ++ self.assertEqual(git_config.get_value("user", "safe.option3"), "safe") ++ + @with_rw_directory + def test_writer_rejects_unquoted_section_terminators(self, rw_dir): + config_path = osp.join(rw_dir, "config") diff -Nru python-git-3.1.44/debian/patches/CVE-2026-76222.patch python-git-3.1.44/debian/patches/CVE-2026-76222.patch --- python-git-3.1.44/debian/patches/CVE-2026-76222.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-76222.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,193 @@ +From: Byron +Date: Sat, 1 Aug 2026 04:47:40 +0200 +Subject: Validate submodule names before filesystem operations + + +Submodule names read from .gitmodules can influence the separate Git directory +path. Reject empty names, absolute or drive-qualified names, and parent +components with either path separator. + +Validate before constructing module paths and before opening or mutating +existing checkouts, so a repository initialized by an older vulnerable version +cannot bypass the guard. Validate programmatic add and rename inputs before +making changes as well. + +Advisory: GHSA-hmq2-w58f-27jc + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/e4b8e7d026ca6abb4cf604f8e77093432ce23c06 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-76222 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-76222 +--- + git/objects/submodule/base.py | 23 ++++++++++++++- + test/test_submodule.py | 69 +++++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 91 insertions(+), 1 deletion(-) + +diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py +index 43e0ec9..a4e0077 100644 +--- a/git/objects/submodule/base.py ++++ b/git/objects/submodule/base.py +@@ -6,6 +6,7 @@ __all__ = ["Submodule", "UpdateProgress"] + import gc + from io import BytesIO + import logging ++import ntpath + import os + import os.path as osp + import stat +@@ -300,10 +301,21 @@ class Submodule(IndexObject, TraversableIterableObj): + parser.set_submodule(self) + return SectionConstraint(parser, sm_section(self.name)) + ++ @classmethod ++ def _validated_name(cls, name: str) -> str: ++ if ( ++ not name ++ or name.startswith(("/", "\\")) ++ or ntpath.splitdrive(name)[0] ++ or ".." in name.replace("\\", "/").split("/") ++ ): ++ raise ValueError("Invalid submodule name %r" % name) ++ return name ++ + @classmethod + def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> PathLike: + if cls._need_gitfile_submodules(parent_repo.git): +- return osp.join(parent_repo.git_dir, "modules", name) ++ return osp.join(parent_repo.git_dir, "modules", cls._validated_name(name)) + if parent_repo.working_tree_dir: + return osp.join(parent_repo.working_tree_dir, path) + raise NotADirectoryError() +@@ -516,6 +528,7 @@ class Submodule(IndexObject, TraversableIterableObj): + raise InvalidGitRepositoryError("Cannot add submodules to bare repositories") + # END handle bare repos + ++ cls._validated_name(name) + path = cls._to_relative_path(repo, path) + + # Ensure we never put backslashes into the URL, as might happen on Windows. +@@ -733,6 +746,8 @@ class Submodule(IndexObject, TraversableIterableObj): + # END init mrepo + + try: ++ self._validated_name(self.name) ++ + # ENSURE REPO IS PRESENT AND UP-TO-DATE + ####################################### + try: +@@ -999,6 +1014,7 @@ class Submodule(IndexObject, TraversableIterableObj): + raise ValueError("You must specify to move at least the module or the configuration of the submodule") + # END handle input + ++ self._validated_name(self.name) + module_checkout_path = self._to_relative_path(self.repo, module_path) + + # VERIFY DESTINATION +@@ -1139,6 +1155,7 @@ class Submodule(IndexObject, TraversableIterableObj): + raise ValueError("Need to specify to delete at least the module, or the configuration") + # END handle parameters + ++ self._validated_name(self.name) + # Recursively remove children of this submodule. + nc = 0 + for csm in self.children(): +@@ -1365,6 +1382,9 @@ class Submodule(IndexObject, TraversableIterableObj): + if self.name == new_name: + return self + ++ self._validated_name(self.name) ++ self._validated_name(new_name) ++ + # .git/config + with self.repo.config_writer() as pw: + # As we ourselves didn't write anything about submodules into the parent +@@ -1412,6 +1432,7 @@ class Submodule(IndexObject, TraversableIterableObj): + If a repository was not available. + This could also mean that it was not yet initialized. + """ ++ self._validated_name(self.name) + module_checkout_abspath = self.abspath + try: + repo = git.Repo(module_checkout_abspath) +diff --git a/test/test_submodule.py b/test/test_submodule.py +index 94925a5..374fb37 100644 +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -753,6 +753,75 @@ class TestSubmodule(TestBase): + ) + # END for each checkout mode + ++ @with_rw_directory ++ @_patch_git_config("protocol.file.allow", "always") ++ def test_update_rejects_parent_component_in_name(self, rwdir): ++ source = git.Repo.init(osp.join(rwdir, "source")) ++ source.git.commit(m="initial commit", allow_empty=True) ++ ++ parent = git.Repo.init(osp.join(rwdir, "parent")) ++ parent.git.submodule("add", source.working_tree_dir, "module") ++ parent.index.commit("add submodule") ++ modules_file = Path(parent.working_tree_dir) / ".gitmodules" ++ modules_file.write_text( ++ modules_file.read_text().replace('submodule "module"', 'submodule "../../../escaped/module"') ++ ) ++ parent.index.add([".gitmodules"]) ++ parent.index.commit("change submodule name") ++ ++ clone = git.Repo.clone_from(parent.working_tree_dir, osp.join(rwdir, "clone")) ++ with pytest.raises(ValueError, match="submodule name"): ++ clone.submodules[0].update(init=True) ++ assert not osp.exists(osp.join(rwdir, "escaped")) ++ ++ Path(rwdir, "escaped").mkdir() ++ git.Repo.clone_from( ++ source.working_tree_dir, ++ osp.join(clone.working_tree_dir, "module"), ++ separate_git_dir=osp.join(rwdir, "escaped", "module"), ++ ) ++ with pytest.raises(ValueError, match="submodule name"): ++ clone.submodules[0].update(init=True) ++ ++ invalid_names = ( ++ "", ++ "..", ++ "../module", ++ R"..\module", ++ "nested/../module", ++ R"nested\..\module", ++ "/module", ++ R"\module", ++ "C:module", ++ R"C:\module", ++ ) ++ for name in invalid_names: ++ with pytest.raises(ValueError, match="submodule name"): ++ Submodule._module_abspath(clone, "module", name) ++ ++ @with_rw_directory ++ @_patch_git_config("protocol.file.allow", "always") ++ def test_root_update_keeps_going_after_invalid_submodule_name(self, rwdir): ++ source = git.Repo.init(osp.join(rwdir, "source")) ++ source.git.commit(m="initial commit", allow_empty=True) ++ ++ parent = git.Repo.init(osp.join(rwdir, "parent")) ++ parent.git.submodule("add", source.working_tree_dir, "invalid") ++ parent.git.submodule("add", source.working_tree_dir, "valid") ++ modules_file = Path(parent.working_tree_dir) / ".gitmodules" ++ modules_file.write_text(modules_file.read_text().replace('submodule "invalid"', 'submodule "../invalid"')) ++ parent.index.add([".gitmodules"]) ++ parent.index.commit("add submodules") ++ ++ clone = git.Repo.clone_from(parent.working_tree_dir, osp.join(rwdir, "clone")) ++ assert [sm.name for sm in clone.submodules] == ["../invalid", "valid"] ++ ++ clone.submodule_update(keep_going=True) ++ ++ assert os.listdir(osp.join(clone.working_tree_dir, "invalid")) == [] ++ assert not clone.submodule("../invalid").module_exists() ++ assert clone.submodule("valid").module_exists() ++ + @with_rw_directory + @_patch_git_config("protocol.file.allow", "always") + def test_list_only_valid_submodules(self, rwdir): diff -Nru python-git-3.1.44/debian/patches/CVE-2026-78675.patch python-git-3.1.44/debian/patches/CVE-2026-78675.patch --- python-git-3.1.44/debian/patches/CVE-2026-78675.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-78675.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,69 @@ +From: Byron +Date: Mon, 10 Aug 2026 12:32:24 +0200 +Subject: fix: ignore includes in submodule configuration + + +Submodule configuration is read from .gitmodules, whose contents may come +from an untrusted repository. Its parser inherited merge_includes=True and +could therefore open files named by include directives during ordinary +submodule enumeration. + +Disable include merging at the SubmoduleConfigParser construction site. This +matches Repo.config_writer() hardening from 41ecc6a4 and addresses +GHSA-7833-fr7j-v32q without changing include behavior for trusted config +parsers. + +The regression points .gitmodules at a non-config file and verifies the +submodule entry remains readable without opening the included path. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-78675 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-78675 +--- + git/objects/submodule/base.py | 2 +- + test/test_submodule.py | 16 ++++++++++++++++ + 2 files changed, 17 insertions(+), 1 deletion(-) + +diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py +index a4e0077..568bbb2 100644 +--- a/git/objects/submodule/base.py ++++ b/git/objects/submodule/base.py +@@ -267,7 +267,7 @@ class Submodule(IndexObject, TraversableIterableObj): + raise ValueError("Cannot write blobs of 'historical' submodule configurations") + # END handle writes of historical submodules + +- return SubmoduleConfigParser(fp_module, read_only=read_only) ++ return SubmoduleConfigParser(fp_module, read_only=read_only, merge_includes=False) + + def _clear_cache(self) -> None: + """Clear the possibly changed values.""" +diff --git a/test/test_submodule.py b/test/test_submodule.py +index 374fb37..6da5453 100644 +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -1026,6 +1026,22 @@ class TestSubmodule(TestBase): + + assert len(parent.submodules) == 0 + ++ @with_rw_directory ++ def test_gitmodules_does_not_merge_includes(self, rwdir): ++ parent = git.Repo.init(rwdir) ++ secret_path = osp.join(rwdir, "secret") ++ with open(secret_path, "w", encoding="utf-8") as secret: ++ secret.write("not git config\n") ++ with open(osp.join(rwdir, ".gitmodules"), "w", encoding="utf-8") as modules: ++ modules.write('[submodule "module"]\n') ++ modules.write("\tpath = module\n") ++ modules.write("\turl = https://example.com/module.git\n") ++ modules.write("[include]\n") ++ modules.write("\tpath = %s\n" % secret_path) ++ ++ parser = Submodule._config_parser(parent, None, read_only=True) ++ self.assertEqual(parser.get_value('submodule "module"', "path"), "module") ++ + @with_rw_directory + def test_remove_norefs(self, rwdir): + parent = git.Repo.init(osp.join(rwdir, "parent")) diff -Nru python-git-3.1.44/debian/patches/CVE-2026-78676.patch python-git-3.1.44/debian/patches/CVE-2026-78676.patch --- python-git-3.1.44/debian/patches/CVE-2026-78676.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-78676.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,107 @@ +From: Byron +Date: Mon, 10 Aug 2026 12:22:31 +0200 +Subject: fix: preserve multiline config values when writing + + +GitConfigParser decoded valid multiline values into embedded newlines, but +_write() serialized those newlines as indented physical lines. Rewriting an +otherwise unchanged config could therefore change its meaning to Git. + +Serialize resident multiline values with Git-compatible escapes inside a +quoted continuation, preserving GitPython read compatibility while keeping +each option structurally intact. This addresses GHSA-284h-m62q-gf8w. + +The regression starts with an inert multiline value, performs an unrelated +write, and verifies with both GitPython and git config that it remains one +value and does not create another option. + +Git baseline: config.c parse_value() and write_pair() at cf5497b14c5a escape +embedded LF as \\n rather than emitting it as a physical config line. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/4b4e47fc1224e23b0c8ee7220a7192818f2e4abb +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-78676 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-78676 +--- + git/config.py | 6 +++++- + test/test_config.py | 41 +++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 46 insertions(+), 1 deletion(-) + +diff --git a/git/config.py b/git/config.py +index 0a85563..790e0c3 100644 +--- a/git/config.py ++++ b/git/config.py +@@ -681,7 +681,11 @@ class GitConfigParser(cp.RawConfigParser, metaclass=MetaParserBuilder): + continue + + for v in values: +- fp.write(("\t%s = %s\n" % (key, self._value_to_string(v).replace("\n", "\n\t"))).encode(defenc)) ++ value = self._value_to_string(v) ++ if any(char in value for char in '\n\t\b\\"'): ++ value = value.replace("\\", "\\\\").replace('"', '\\"') ++ value = '"%s\\\n"' % value.replace("\n", "\\n").replace("\t", "\\t").replace("\b", "\\b") ++ fp.write(("\t%s = %s\n" % (key, value)).encode(defenc)) + # END if key is not __name__ + + # END section writing +diff --git a/test/test_config.py b/test/test_config.py +index be20508..d2c5f24 100644 +--- a/test/test_config.py ++++ b/test/test_config.py +@@ -7,6 +7,7 @@ import glob + import io + import os + import os.path as osp ++import subprocess + import sys + from unittest import mock + +@@ -150,6 +151,46 @@ class TestBase(TestCase): + git_config = GitConfigParser(config_file) + git_config.read() # This should not throw an exception + ++ @with_rw_directory ++ def test_rewriting_multiline_value_does_not_create_option(self, rw_dir): ++ config_path = osp.join(rw_dir, "config") ++ with open(config_path, "wb") as config_file: ++ config_file.write(b'[core]\n\tzzz = "A\\nhooksPath = ../evil-hooks\\\n"\n') ++ ++ with GitConfigParser(config_path, read_only=False) as git_config: ++ self.assertEqual(git_config.get_value("core", "zzz"), "A\nhooksPath = ../evil-hooks") ++ git_config.set_value("user", "name", "Test User") ++ ++ with GitConfigParser(config_path, read_only=True) as git_config: ++ self.assertEqual(git_config.get_value("core", "zzz"), "A\nhooksPath = ../evil-hooks") ++ self.assertFalse(git_config.has_option("core", "hooksPath")) ++ self.assertEqual( ++ subprocess.run(["git", "config", "--file", config_path, "--get", "core.hooksPath"]).returncode, 1 ++ ) ++ ++ @with_rw_directory ++ def test_writer_escapes_special_characters_without_newline(self, rw_dir): ++ config_path = osp.join(rw_dir, "config") ++ values = {"tab": "\tvalue\t", "backspace": "a\bb", "quote": 'a"b', "backslash": "a\\qb"} ++ ++ with GitConfigParser(config_path, read_only=False) as git_config: ++ for key, value in values.items(): ++ git_config.set_value("section", key, value) ++ ++ with GitConfigParser(config_path, read_only=True) as git_config: ++ for key, value in values.items(): ++ self.assertEqual(git_config.get_value("section", key), value) ++ self.assertEqual( ++ subprocess.run( ++ ["git", "config", "--file", config_path, "--get", "section.%s" % key], ++ stdout=subprocess.PIPE, ++ check=True, ++ ).stdout, ++ value.encode() + b"\n", ++ ) ++ with open(config_path, "rb") as config_file: ++ self.assertNotIn(b"\x08", config_file.read()) ++ + @with_rw_directory + def test_set_value_rejects_config_injection(self, rw_dir): + config_path = osp.join(rw_dir, "config") diff -Nru python-git-3.1.44/debian/patches/CVE-2026-78677.patch python-git-3.1.44/debian/patches/CVE-2026-78677.patch --- python-git-3.1.44/debian/patches/CVE-2026-78677.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-78677.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,121 @@ +From: Byron +Date: Mon, 10 Aug 2026 11:20:03 +0200 +Subject: Block separate git directories during clone + + +Repo.clone() and Repo.clone_from() did not reject the clone option that +redirects repository metadata to a caller-controlled path (GHSA-8mcc-hrx5-hvxc). +Regression coverage exercises both keyword and multi-option input through both +public clone APIs. + +Add the option to the existing clone denylist, matching Repo.init() +and the documented allow_unsafe_options contract. Git itself registers + +The Python package and Alpine test workflows failed across the submodule suite +because GitPython internally supplies --separate-git-dir when creating modern +submodule layouts. The new public clone guard correctly rejected that option, +but could not distinguish the library-generated path from caller input. + +Validate caller-provided keyword and multi-options before adding the +library-controlled metadata path, then explicitly allow the resulting trusted +clone invocation. This preserves rejection of unsafe clone_multi_options while +restoring normal submodule creation. Update the one test that intentionally +invokes Repo.clone_from() with its own separate git directory to opt in +explicitly. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: backport, https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-78677 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-78677 +--- + git/objects/submodule/base.py | 10 ++++++++++ + git/repo/base.py | 2 ++ + test/test_clone.py | 3 +++ + test/test_submodule.py | 1 + + 4 files changed, 16 insertions(+) + +diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py +index 568bbb2..04bb5c1 100644 +--- a/git/objects/submodule/base.py ++++ b/git/objects/submodule/base.py +@@ -9,6 +9,7 @@ import logging + import ntpath + import os + import os.path as osp ++import shlex + import stat + import sys + import uuid +@@ -359,6 +360,15 @@ class Submodule(IndexObject, TraversableIterableObj): + module_abspath = cls._module_abspath(repo, path, name) + module_checkout_path = module_abspath + if cls._need_gitfile_submodules(repo.git): ++ if not allow_unsafe_options: ++ Git.check_unsafe_options(Git._option_candidates([], kwargs), repo.unsafe_git_clone_options) ++ multi_options = kwargs.get("multi_options") ++ if multi_options: ++ Git.check_unsafe_options( ++ shlex.split(" ".join(cast("Sequence[str]", multi_options))), ++ repo.unsafe_git_clone_options, ++ ) ++ allow_unsafe_options = True + kwargs["separate_git_dir"] = module_abspath + module_abspath_dir = osp.dirname(module_abspath) + if not osp.isdir(module_abspath_dir): +diff --git a/git/repo/base.py b/git/repo/base.py +index eaf48f0..135fb36 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -158,6 +158,8 @@ class Repo: + "-c", + # Can install hooks that execute during clone: + "--template", ++ # Redirects the repository metadata to a caller-controlled path: ++ "--separate-git-dir", + # Fetches from an additional caller-controlled URI: + "--bundle-uri", + ] +diff --git a/test/test_clone.py b/test/test_clone.py +index 6109681..e0a7352 100644 +--- a/test/test_clone.py ++++ b/test/test_clone.py +@@ -33,6 +33,7 @@ class TestClone(TestBase): + "-cprotocol.ext.allow=always", + "-vcprotocol.ext.allow=always", + f"--bundle-uri=file://{tmp_dir}", ++ f"--separate-git-dir={tmp_dir / 'git-dir'}", + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +@@ -48,6 +49,7 @@ class TestClone(TestBase): + {"conf": "protocol.ext.allow=always"}, + {"c": "protocol.ext.allow=always"}, + {"bundle_uri": f"file://{tmp_dir}"}, ++ {"separate_git_dir": tmp_dir / "git-dir"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +@@ -73,6 +75,7 @@ class TestClone(TestBase): + {"upl": f"touch {tmp_file}"}, + {"upload_pac": f"touch {tmp_file}"}, + {"conf": "protocol.ext.allow=always"}, ++ {"separate_git_dir": tmp_dir / "git-dir"}, + ] + for unsafe_option in unsafe_kwargs: + with self.assertRaises(UnsafeOptionError): +diff --git a/test/test_submodule.py b/test/test_submodule.py +index 6da5453..ee9aae0 100644 +--- a/test/test_submodule.py ++++ b/test/test_submodule.py +@@ -779,6 +779,7 @@ class TestSubmodule(TestBase): + source.working_tree_dir, + osp.join(clone.working_tree_dir, "module"), + separate_git_dir=osp.join(rwdir, "escaped", "module"), ++ allow_unsafe_options=True, + ) + with pytest.raises(ValueError, match="submodule name"): + clone.submodules[0].update(init=True) diff -Nru python-git-3.1.44/debian/patches/CVE-2026-78678_CVE-2026-78679.patch python-git-3.1.44/debian/patches/CVE-2026-78678_CVE-2026-78679.patch --- python-git-3.1.44/debian/patches/CVE-2026-78678_CVE-2026-78679.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-78678_CVE-2026-78679.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,310 @@ +From: Byron +Date: Wed, 5 Aug 2026 04:48:36 +0200 +Subject: Block file-reading Git options + + +Reject file-reading options passed to blame, diff, and tag APIs. Inspect +positional values after resolving aliases, recognize unsafe options +behind command-specific short-flag clusters, and retain the explicit +allow_unsafe_options escape hatch. This closes GHSA-5xxx-qhh7-9287 and +GHSA-3wxw-xv34-2frg and covers the adjacent diff order-file sink. + +Regression tests cover long, short, and clustered options, incremental blame, +tag path/reference positionals, the ref keyword alias, both diff entry points, +and preservation of diff pickaxe behavior. + +Git baseline: cf5497b14c; git-blame, git-diff, and git-tag document the relevant file-input options. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/1b0d2d9b91575f7db44ef4ff58ac37fc9335e5f6 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-78678 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-78679 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-78678 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-78679 +--- + git/cmd.py | 11 +++++++---- + git/diff.py | 7 ++++--- + git/index/base.py | 3 ++- + git/refs/tag.py | 10 ++++++---- + git/repo/base.py | 24 ++++++++++++++++++++---- + test/test_diff.py | 14 ++++++++++++++ + test/test_refs.py | 15 +++++++++++++++ + test/test_remote.py | 3 +++ + test/test_repo.py | 7 +++++-- + 9 files changed, 76 insertions(+), 18 deletions(-) + +diff --git a/git/cmd.py b/git/cmd.py +index 077dd36..673eba2 100644 +--- a/git/cmd.py ++++ b/git/cmd.py +@@ -419,6 +419,7 @@ class Git(metaclass=_GitMeta): + unsafe_git_ls_remote_options = [ + # This option allows arbitrary command execution in git-ls-remote. + "--upload-pack", ++ "--exec", + ] + + unsafe_git_pathspec_from_file_options = [ +@@ -743,7 +744,9 @@ class Git(metaclass=_GitMeta): + return dashify(option_tokens[0]) + + @classmethod +- def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None: ++ def check_unsafe_options( ++ cls, options: List[str], unsafe_options: List[str], clusterable_short_options: str = "46flnqsv" ++ ) -> None: + """Raise :class:`~git.exc.UnsafeOptionError` for blocked option spellings. + + In addition to exact matches, this rejects abbreviated long options accepted +@@ -778,7 +781,7 @@ class Git(metaclass=_GitMeta): + # These value-less Git flags can be clustered before another short option + # (for example, ``-fuVALUE``). Stop at any other character because it may + # begin an attached value, as ``o`` does in the safe option ``-oupstream``. +- clusterable_short_options = frozenset("46flnqsv") ++ clusterable_short_options_set = frozenset(clusterable_short_options) + options_are_kwargs = all(not option.startswith("-") for option in options) + for option in options: + candidate = cls._canonicalize_option_name(option) +@@ -795,7 +798,7 @@ class Git(metaclass=_GitMeta): + raise UnsafeOptionError( + f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it." + ) +- if option_char not in clusterable_short_options: ++ if option_char not in clusterable_short_options_set: + break + if not (option.startswith("--") or (options_are_kwargs and len(candidate) > 1)): + continue +@@ -1113,7 +1116,7 @@ class Git(metaclass=_GitMeta): + """List references in a remote repository. + + :param allow_unsafe_options: +- Allow unsafe options, like ``--upload-pack``. ++ Allow unsafe options, like ``--upload-pack`` or ``--exec``. + """ + if not allow_unsafe_options: + candidate_options = self._option_candidates(args, kwargs) +diff --git a/git/diff.py b/git/diff.py +index 499f884..9a85a1b 100644 +--- a/git/diff.py ++++ b/git/diff.py +@@ -216,8 +216,8 @@ class Diffable: + to be read and diffed. + + :param allow_unsafe_options: +- If ``True``, allow options such as ``--output`` that can write to arbitrary +- filesystem paths. ++ If ``True``, allow options such as ``--output`` and ``-O`` that can write to ++ or read from arbitrary filesystem paths. + + :param kwargs: + Additional arguments passed to :manpage:`git-diff(1)`, such as ``R=True`` to +@@ -234,7 +234,8 @@ class Diffable: + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([other], kwargs), +- unsafe_options=self.repo.unsafe_git_revision_options, ++ unsafe_options=self.repo.unsafe_git_diff_options, ++ clusterable_short_options="46abceflmnpqrstuvwzBCDMNRW", + ) + + args: List[Union[PathLike, Diffable]] = [] +diff --git a/git/index/base.py b/git/index/base.py +index 240ef04..45ba1cb 100644 +--- a/git/index/base.py ++++ b/git/index/base.py +@@ -1550,7 +1550,8 @@ class IndexFile(LazyMixin, git_diff.Diffable, Serializable): + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([other], kwargs), +- unsafe_options=self.repo.unsafe_git_revision_options, ++ unsafe_options=self.repo.unsafe_git_diff_options, ++ clusterable_short_options="46abceflmnpqrstuvwzBCDMNRW", + ) + + # Only run if we are the default repository index. +diff --git a/git/refs/tag.py b/git/refs/tag.py +index 487eaa1..7b002f2 100644 +--- a/git/refs/tag.py ++++ b/git/refs/tag.py +@@ -134,15 +134,17 @@ class TagReference(Reference): + :return: + A new :class:`TagReference`. + """ ++ legacy_ref = kwargs.pop("ref", None) ++ if legacy_ref: ++ reference = legacy_ref ++ + if not allow_unsafe_options: + Git.check_unsafe_options( +- options=Git._option_candidates([], kwargs), ++ options=Git._option_candidates([path, reference], kwargs), + unsafe_options=cls.unsafe_git_tag_options, ++ clusterable_short_options="46adefilnqsv", + ) + +- if "ref" in kwargs and kwargs["ref"]: +- reference = kwargs["ref"] +- + if "message" in kwargs and kwargs["message"]: + kwargs["m"] = kwargs["message"] + del kwargs["message"] +diff --git a/git/repo/base.py b/git/repo/base.py +index 135fb36..ad301d7 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -200,6 +200,19 @@ class Repo: + "-o", + ] + ++ unsafe_git_blame_options = unsafe_git_revision_options + [ ++ # These options read from arbitrary files and expose their contents through blame output. ++ "--contents", ++ "-S", ++ "--ignore-revs-file", ++ ] ++ ++ unsafe_git_diff_options = unsafe_git_revision_options + [ ++ # Reads caller-controlled order patterns from an arbitrary file. ++ "-O", ++ "--orderfile", ++ ] ++ + # Invariants + config_level: ConfigLevels_Tup = ("system", "user", "global", "repository") + """Represents the configuration level of a configuration file.""" +@@ -1120,7 +1133,7 @@ class Repo: + :manpage:`git-rev-parse(1)` is a valid option. + + :param allow_unsafe_options: +- Allow unsafe options in revision argument, like ``--output``. ++ Allow unsafe options in revision argument, like ``--output`` or ``--contents``. + + :return: + Lazy iterator of :class:`BlameEntry` tuples, where the commit indicates the +@@ -1132,7 +1145,9 @@ class Repo: + """ + if not allow_unsafe_options: + Git.check_unsafe_options( +- options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options ++ options=Git._option_candidates([rev], kwargs), ++ unsafe_options=self.unsafe_git_blame_options, ++ clusterable_short_options="46bceflnpqstvw", + ) + + data: bytes = self.git.blame(rev, "--", file, p=True, incremental=True, stdout_as_string=False, **kwargs) +@@ -1224,7 +1239,7 @@ class Repo: + :manpage:`git-rev-parse(1)` is a valid option. + + :param allow_unsafe_options: +- Allow unsafe options in revision argument, like ``--output``. ++ Allow unsafe options in revision argument, like ``--output`` or ``--contents``. + + :return: + list: [git.Commit, list: []] +@@ -1240,7 +1255,8 @@ class Repo: + if not allow_unsafe_options: + Git.check_unsafe_options( + options=Git._option_candidates([rev, rev_opts_list], kwargs), +- unsafe_options=self.unsafe_git_revision_options, ++ unsafe_options=self.unsafe_git_blame_options, ++ clusterable_short_options="46bceflnpqstvw", + ) + data: bytes = self.git.blame(rev, *rev_opts_list, "--", file, p=True, stdout_as_string=False, **kwargs) + commits: Dict[str, Commit] = {} +diff --git a/test/test_diff.py b/test/test_diff.py +index a395f4a..aa101e7 100644 +--- a/test/test_diff.py ++++ b/test/test_diff.py +@@ -356,11 +356,25 @@ class TestDiff(TestBase): + def test_diff_rejects_unsafe_output_options(self): + commit = self.rorepo.head.commit + ++ commit.diff(S="needle") ++ + calls = ( + lambda target: commit.diff(output=target), + lambda target: commit.diff(other=f"--output={target}"), ++ lambda target: commit.diff(O=target), ++ lambda target: commit.diff(orderfile=target), ++ lambda target: commit.diff(other=f"--orderfile={target}"), ++ lambda target: commit.diff(other=f"-pO{target}"), ++ lambda target: commit.diff(other=f"-uO{target}"), ++ lambda target: commit.diff(other=f"-DO{target}"), + lambda target: self.rorepo.index.diff(NULL_TREE, output=target), + lambda target: self.rorepo.index.diff(f"--output={target}"), ++ lambda target: self.rorepo.index.diff(NULL_TREE, O=target), ++ lambda target: self.rorepo.index.diff(NULL_TREE, orderfile=target), ++ lambda target: self.rorepo.index.diff(f"--orderfile={target}"), ++ lambda target: self.rorepo.index.diff(f"-pO{target}"), ++ lambda target: self.rorepo.index.diff(f"-uO{target}"), ++ lambda target: self.rorepo.index.diff(f"-DO{target}"), + ) + for index, call in enumerate(calls): + target = osp.join(self.repo_dir, f"diff-output-{index}") +diff --git a/test/test_refs.py b/test/test_refs.py +index f0f2e59..127422b 100644 +--- a/test/test_refs.py ++++ b/test/test_refs.py +@@ -64,6 +64,21 @@ class TestRefs(TestBase): + with self.assertRaises(UnsafeOptionError): + TagReference.create(rw_repo, f"unsafe-{index}", **option) + ++ for args in ( ++ ("unsafe-reference", f"--file={message.name}"), ++ (f"--file={message.name}", "HEAD"), ++ (f"-eF{message.name}", "HEAD"), ++ (f"-iF{message.name}", "HEAD"), ++ ): ++ with self.assertRaises(UnsafeOptionError): ++ TagReference.create(rw_repo, *args) ++ ++ with self.assertRaises(UnsafeOptionError): ++ TagReference.create(rw_repo, "unsafe-ref-kwarg", ref=f"--file={message.name}") ++ ++ tag = TagReference.create(rw_repo, "legacy-ref", ref="HEAD", allow_unsafe_options=True) ++ self.assertEqual(tag.commit, rw_repo.head.commit) ++ + tag = TagReference.create(rw_repo, "allowed-file", F=message.name, allow_unsafe_options=True) + self.assertEqual(tag.tag.message, "private tag message") + +diff --git a/test/test_remote.py b/test/test_remote.py +index 8ff89ef..bd8cbcc 100644 +--- a/test/test_remote.py ++++ b/test/test_remote.py +@@ -1027,6 +1027,7 @@ class TestRemote(TestBase): + {"upload-pack": f"touch {tmp_file}"}, + {"upload_pack": f"touch {tmp_file}"}, + {"upl": f"touch {tmp_file}"}, ++ {"exec": f"touch {tmp_file}"}, + ] + for unsafe_option in unsafe_options: + with self.assertRaises(UnsafeOptionError): +@@ -1039,6 +1040,8 @@ class TestRemote(TestBase): + rw_repo.git.ls_remote(f"--upload-pack={tmp_file}", ".") + with self.assertRaises(UnsafeOptionError): + rw_repo.git.ls_remote(f"--upl={tmp_file}", ".") ++ with self.assertRaises(UnsafeOptionError): ++ rw_repo.git.ls_remote(f"--exec={tmp_file}", ".") + with self.assertRaises(UnsafeOptionError): + rw_repo.git.ls_remote("--upload-pack", "touch", ".") + with self.assertRaises(UnsafeOptionError): +diff --git a/test/test_repo.py b/test/test_repo.py +index 0f14c76..8e300a0 100644 +--- a/test/test_repo.py ++++ b/test/test_repo.py +@@ -800,8 +800,11 @@ class TestRepo(TestBase): + def test_blame_rejects_unsafe_revision(self): + with tempfile.TemporaryDirectory() as tdir: + output_marker = osp.join(tdir, "pwn") +- with self.assertRaises(UnsafeOptionError): +- self.rorepo.blame(f"--output={output_marker}", "README.md") ++ for option in ("--output", "--contents", "-S", "-wS", "--ignore-revs-file"): ++ with self.assertRaises(UnsafeOptionError): ++ self.rorepo.blame(f"{option}={output_marker}", "README.md") ++ with self.assertRaises(UnsafeOptionError): ++ list(self.rorepo.blame_incremental(f"{option}={output_marker}", "README.md")) + assert not osp.exists(output_marker) + + def test_blame_rejects_unsafe_options(self): diff -Nru python-git-3.1.44/debian/patches/CVE-2026-87817.patch python-git-3.1.44/debian/patches/CVE-2026-87817.patch --- python-git-3.1.44/debian/patches/CVE-2026-87817.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-87817.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,636 @@ +From: Byron +Date: Tue, 25 Aug 2026 04:57:29 +0200 +Subject: fix: prefer `.git` during repository discovery + + +GitPython considered worktree administration and bare-repository signatures +before a worktree's real .git entry. Align discovery with Git so .git files +and directories win, malformed .git files stop discovery, and candidate git +directories validate HEAD plus commondir-backed object and ref storage. This +addresses GHSA-239g-whfq-7xj9. + +Regression coverage compares ambiguous layouts with git rev-parse and rejects +invalid HEAD/.git metadata. + +Git baseline: 15c6308cf7ad276b306aa5b3ababfbdebfb1a917; setup.c +setup_git_directory_gently_1(), is_git_directory(), and validate_headref(). + +Repository validation can use GIT_COMMON_DIR for refs and objects. Preserve the +same value on Repo so later config, ref, and object access uses the directory +that made discovery succeed. + +Capture GIT_COMMON_DIR before the first repository config read so bare-state +detection uses the same metadata location as discovery. Resolve relative +environment values immediately so later working-directory changes cannot +retarget the Repo. + +Review feedback: relative GIT_COMMON_DIR left Git subprocesses resolving GIT_DIR +and GIT_COMMON_DIR from a different working directory; malformed commondir data, +empty GIT_OBJECT_DIRECTORY, and the linked-worktree signature were also handled +inconsistently. + +Pin the repository environment to resolved paths, reject invalid +metadata without consulting the process working directory, and +restore HEAD-based linked-worktree detection. Keep the loose HEAD +and dangling .git behavior because both match Git setup.c at baseline +15c6308cf7ad276b306aa5b3ababfbdebfb1a917. + +Review feedback identified that chained or self-referential .git pointers +recurse, filesystem-encoded metadata can fail text decoding, and a relative +GIT_DIR is not retained for later Git commands. + +Parse one regular, size-bounded Gitfile exactly once, decode Gitfile and +commondir paths with the filesystem codec, and retain the resolved GIT_DIR +for subprocesses. This rejects cycles like Git instead of recursing and keeps +commands stable after working-directory changes. + +Review feedback noted that a dangling commondir symlink was treated as absent, +allowing local objects and refs to validate the repository. + +Distinguish a truly missing commondir from a dangling symlink. This follows +Git's get_common_dir_noenv(), whose file_exists check uses lstat before +attempting to read the entry. + +Python 3.9 on Windows raised UnicodeDecodeError while the repository-discovery +regression test parsed invalid commondir bytes, causing the Python package test +(windows, 3.9) check to fail. + +Treat UnicodeError like an unreadable metadata file in both commondir and +gitfile parsing. Invalid bytes now make discovery reject the candidate +repository, matching Git's behavior. + +Review feedback noted that GIT_OBJECT_DIRECTORY made discovery succeed without +becoming the Repo ODB root, while a relative value could later be resolved from +the Git wrapper's different working directory. + +Resolve the environment value against the construction directory, use it as the +ODB root, and preserve the absolute value for later Git commands. The regression +moves the only object store outside the git directory and verifies access +through both GitDB and git cat-file after the original environment and current +directory are restored. + +Review feedback identified four setup mismatches: non-missing .git stat failures +could fall through to another repository, explicit GIT_DIR could be redirected +through a nested .git entry, Gitfile reads were not bounded to the stat-reported +size, and alternates ignored GIT_OBJECT_DIRECTORY. + +Match Git setup.c by bypassing discovery for an environment-selected GIT_DIR, +stopping discovery when Gitfile stat fails for reasons other than a missing +path, and reading exactly the previously observed Gitfile size. Resolve +alternates below the active ODB root so custom object stores remain internally +consistent. + +The commit review noted that treating every present GIT_DIR as explicit broke +two documented cases: an empty GIT_DIR must fall back to current-directory +discovery, while an empty Repo path must still use a nonempty GIT_DIR. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-87817 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-87817 +--- + git/repo/base.py | 90 +++++++++++++++++-------- + git/repo/fun.py | 111 +++++++++++++++++++----------- + test/test_repo.py | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++- + 3 files changed, 331 insertions(+), 67 deletions(-) + +diff --git a/git/repo/base.py b/git/repo/base.py +index ad301d7..d5a9a67 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -45,7 +45,6 @@ from git.util import ( + + from .fun import ( + find_submodule_git_dir, +- find_worktree_git_dir, + is_git_dir, + rev_parse, + touch, +@@ -231,6 +230,10 @@ class Repo: + ) -> None: + R"""Create a new :class:`Repo` instance. + ++ .. note:: ++ Repositories using reftable may be opened, but GitPython's direct reference ++ access does not support reftable. ++ + :param path: + The path to either the worktree directory or the .git directory itself:: + +@@ -265,7 +268,11 @@ class Repo: + :class:`Repo` + """ + +- epath = path or os.getenv("GIT_DIR") ++ git_dir_env = os.getenv("GIT_DIR") ++ object_dir_env = os.getenv("GIT_OBJECT_DIRECTORY") ++ if object_dir_env is not None: ++ object_dir_env = osp.abspath(object_dir_env) ++ epath = path or git_dir_env + if not epath: + epath = os.getcwd() + if Git.is_cygwin(): +@@ -289,15 +296,48 @@ class Repo: + raise NoSuchPathError(epath) + + # Walk up the path to find the `.git` dir. +- curpath = epath +- git_dir = None ++ curpath = os.fspath(epath) if epath is not None else "" ++ git_dir: Optional[str] = None ++ explicit_git_dir = not path and bool(git_dir_env) + while curpath: + # ABOUT osp.NORMPATH + # It's important to normalize the paths, as submodules will otherwise + # initialize their repo instances with paths that depend on path-portions + # that will not exist after being removed. It's just cleaner. ++ if not explicit_git_dir: ++ dotgit = osp.join(curpath, ".git") ++ try: ++ sm_gitpath = find_submodule_git_dir(dotgit) ++ except OSError: ++ break ++ if sm_gitpath is not None: ++ # Worktrees can use relative paths as of Git 2.48, so join to curpath. ++ git_dir = osp.normpath(osp.join(curpath, os.fspath(sm_gitpath))) ++ self._working_tree_dir = curpath ++ break ++ ++ # Like Git, do not fall back to a bare repository or parent directory when ++ # a non-directory .git entry exists but is not a valid gitfile. ++ if osp.exists(dotgit) and not osp.isdir(dotgit): ++ break ++ + if is_git_dir(curpath): + git_dir = curpath ++ if osp.isfile(osp.join(curpath, "gitdir")) and osp.isfile(osp.join(curpath, "commondir")): ++ if "GIT_WORK_TREE" in os.environ: ++ self._working_tree_dir = os.getenv("GIT_WORK_TREE") ++ else: ++ # Linked worktree administrative directories store the path to ++ # the worktree's .git file in gitdir (without a "gitdir: " prefix). ++ with open(osp.join(git_dir, "gitdir")) as fp: ++ worktree_gitfile = fp.read().strip() ++ ++ if not osp.isabs(worktree_gitfile): ++ worktree_gitfile = osp.normpath(osp.join(git_dir, worktree_gitfile)) ++ ++ self._working_tree_dir = osp.dirname(worktree_gitfile) ++ break ++ + # from man git-config : core.worktree + # Set the path to the root of the working tree. If GIT_COMMON_DIR + # environment variable is set, core.worktree is ignored and not used for +@@ -317,21 +357,7 @@ class Repo: + self._working_tree_dir = os.getenv("GIT_WORK_TREE") + break + +- dotgit = osp.join(curpath, ".git") +- sm_gitpath = find_submodule_git_dir(dotgit) +- if sm_gitpath is not None: +- git_dir = osp.normpath(sm_gitpath) +- +- sm_gitpath = find_submodule_git_dir(dotgit) +- if sm_gitpath is None: +- sm_gitpath = find_worktree_git_dir(dotgit) +- +- if sm_gitpath is not None: +- git_dir = expand_path(sm_gitpath, expand_vars) +- self._working_tree_dir = curpath +- break +- +- if not search_parent_directories: ++ if explicit_git_dir or not search_parent_directories: + break + curpath, tail = osp.split(curpath) + if not tail: +@@ -342,6 +368,16 @@ class Repo: + raise InvalidGitRepositoryError(epath) + self.git_dir = git_dir + ++ common_dir_env = os.getenv("GIT_COMMON_DIR") ++ if common_dir_env is not None: ++ self._common_dir = osp.abspath(common_dir_env) ++ else: ++ try: ++ common_dir = os.fsdecode((Path(self.git_dir) / "commondir").read_bytes()).rstrip("\r\n") ++ self._common_dir = osp.join(self.git_dir, common_dir) ++ except OSError: ++ self._common_dir = "" ++ + self._bare = False + try: + self._bare = self.config_reader("repository").getboolean("core", "bare") +@@ -349,12 +385,6 @@ class Repo: + # Let's not assume the option exists, although it should. + pass + +- try: +- common_dir = (Path(self.git_dir) / "commondir").read_text().splitlines()[0].strip() +- self._common_dir = osp.join(self.git_dir, common_dir) +- except OSError: +- self._common_dir = "" +- + # Adjust the working directory in case we are actually bare - we didn't know + # that in the first place. + if self._bare: +@@ -363,9 +393,15 @@ class Repo: + + self.working_dir: PathLike = self._working_tree_dir or self.common_dir + self.git = self.GitCommandWrapperType(self.working_dir) ++ if common_dir_env is not None: ++ self.git.update_environment(GIT_DIR=os.fspath(self.git_dir), GIT_COMMON_DIR=os.fspath(self.common_dir)) ++ elif git_dir_env is not None: ++ self.git.update_environment(GIT_DIR=os.fspath(self.git_dir)) ++ if object_dir_env is not None: ++ self.git.update_environment(GIT_OBJECT_DIRECTORY=object_dir_env) + + # Special handling, in special times. +- rootpath = osp.join(self.common_dir, "objects") ++ rootpath = object_dir_env if object_dir_env is not None else osp.join(self.common_dir, "objects") + if issubclass(odbt, GitCmdObjectDB): + self.odb = odbt(rootpath, self.git) + else: +@@ -989,7 +1025,7 @@ class Repo: + The method does not check for the existence of the paths in `alts`, as the + caller is responsible. + """ +- alternates_path = osp.join(self.common_dir, "objects", "info", "alternates") ++ alternates_path = osp.join(self.odb.root_path(), "info", "alternates") + if not alts: + if osp.isfile(alternates_path): + os.remove(alternates_path) +diff --git a/git/repo/fun.py b/git/repo/fun.py +index 182cf82..bc18eb7 100644 +--- a/git/repo/fun.py ++++ b/git/repo/fun.py +@@ -18,6 +18,7 @@ __all__ = [ + ] + + import os ++import re + import os.path as osp + from pathlib import Path + import stat +@@ -57,22 +58,61 @@ def touch(filename: str) -> str: + def is_git_dir(d: PathLike) -> bool: + """This is taken from the git setup.c:is_git_directory function. + ++ .. note:: ++ This function recognizes repositories using reftable through their ++ compatibility files, but GitPython's direct reference access does not support ++ reftable. ++ + :raise git.exc.WorkTreeRepositoryUnsupported: + If it sees a worktree directory. It's quite hacky to do that here, but at least + clearly indicates that we don't support it. There is the unlikely danger to + throw if we see directories which just look like a worktree dir, but are none. + """ + if osp.isdir(d): +- if (osp.isdir(osp.join(d, "objects")) or "GIT_OBJECT_DIRECTORY" in os.environ) and osp.isdir( +- osp.join(d, "refs") +- ): +- headref = osp.join(d, "HEAD") +- return osp.isfile(headref) or (osp.islink(headref) and os.readlink(headref).startswith("refs")) +- elif ( +- osp.isfile(osp.join(d, "gitdir")) +- and osp.isfile(osp.join(d, "commondir")) +- and osp.isfile(osp.join(d, "gitfile")) +- ): ++ headref = osp.join(d, "HEAD") ++ if osp.islink(headref): ++ try: ++ valid_head = os.readlink(headref).startswith("refs/") ++ except OSError: ++ valid_head = False ++ else: ++ try: ++ with open(headref, "rb") as fp: ++ head = fp.read(256) ++ except OSError: ++ valid_head = False ++ else: ++ valid_head = (head.startswith(b"ref:") and head[4:].lstrip().startswith(b"refs/")) or bool( ++ re.match(rb"(?:[0-9A-Fa-f]{64}|[0-9A-Fa-f]{40})", head) ++ ) ++ ++ common_dir = os.getenv("GIT_COMMON_DIR") ++ if common_dir == "": ++ return False ++ if common_dir is None: ++ common_dir_file = Path(d) / "commondir" ++ try: ++ common_dir = os.fsdecode(common_dir_file.read_bytes()).rstrip("\r\n") ++ except FileNotFoundError: ++ if osp.lexists(common_dir_file): ++ return False ++ common_dir = os.fspath(d) ++ except (OSError, UnicodeError): ++ return False ++ else: ++ if not common_dir: ++ return False ++ try: ++ common_dir = osp.realpath(osp.join(d, common_dir)) ++ except (OSError, ValueError): ++ return False ++ ++ object_dir = os.getenv("GIT_OBJECT_DIRECTORY") ++ if object_dir is None: ++ object_dir = osp.join(common_dir, "objects") ++ if valid_head and osp.isdir(object_dir) and osp.isdir(osp.join(common_dir, "refs")): ++ return True ++ if osp.isfile(osp.join(d, "gitdir")) and osp.isfile(osp.join(d, "commondir")) and osp.isfile(headref): + raise WorkTreeRepositoryUnsupported(d) + return False + +@@ -81,19 +121,20 @@ def find_worktree_git_dir(dotgit: PathLike) -> Optional[str]: + """Search for a gitdir for this worktree.""" + try: + statbuf = os.stat(dotgit) +- except OSError: ++ except (FileNotFoundError, NotADirectoryError): + return None +- if not stat.S_ISREG(statbuf.st_mode): ++ if not stat.S_ISREG(statbuf.st_mode) or statbuf.st_size > (1 << 20): + return None + + try: +- lines = Path(dotgit).read_text().splitlines() +- for key, value in [line.strip().split(": ") for line in lines]: +- if key == "gitdir": +- return value +- except ValueError: +- pass +- return None ++ with open(dotgit, "rb") as fp: ++ content_bytes = fp.read(statbuf.st_size) ++ if len(content_bytes) != statbuf.st_size: ++ return None ++ content = os.fsdecode(content_bytes).rstrip("\r\n") ++ except (OSError, UnicodeError): ++ return None ++ return content[8:] if len(content) >= 9 and content.startswith("gitdir: ") else None + + + def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]: +@@ -101,26 +142,18 @@ def find_submodule_git_dir(d: PathLike) -> Optional[PathLike]: + if is_git_dir(d): + return d + +- try: +- with open(d) as fp: +- content = fp.read().rstrip() +- except IOError: +- # It's probably not a file. +- pass +- else: +- if content.startswith("gitdir: "): +- path = content[8:] +- +- if Git.is_cygwin(): +- # Cygwin creates submodules prefixed with `/cygdrive/...`. +- # Cygwin git understands Cygwin paths much better than Windows ones. +- # Also the Cygwin tests are assuming Cygwin paths. +- path = cygpath(path) +- if not osp.isabs(path): +- path = osp.normpath(osp.join(osp.dirname(d), path)) +- return find_submodule_git_dir(path) +- # END handle exception +- return None ++ path = find_worktree_git_dir(d) ++ if path is None: ++ return None ++ ++ if Git.is_cygwin(): ++ # Cygwin creates submodules prefixed with `/cygdrive/...`. ++ # Cygwin git understands Cygwin paths much better than Windows ones. ++ # Also the Cygwin tests are assuming Cygwin paths. ++ path = cygpath(path) ++ if not osp.isabs(path): ++ path = osp.normpath(osp.join(osp.dirname(d), path)) ++ return path if is_git_dir(path) else None + + + def short_to_long(odb: "GitCmdObjectDB", hexsha: str) -> Optional[bytes]: +diff --git a/test/test_repo.py b/test/test_repo.py +index 8e300a0..475c79a 100644 +--- a/test/test_repo.py ++++ b/test/test_repo.py +@@ -37,7 +37,8 @@ from git import ( + Tree, + ) + from git.exc import BadObject, UnsafeOptionError, UnsafeProtocolError +-from git.repo.fun import touch ++from git.exc import WorkTreeRepositoryUnsupported ++from git.repo.fun import find_worktree_git_dir, touch + from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree + + from test.lib import TestBase, fixture, with_rw_directory, with_rw_repo +@@ -119,6 +120,176 @@ class TestRepo(TestBase): + nonexistent = osp.join(tdir, "foobar") + self.assertRaises(NoSuchPathError, Repo, nonexistent) + ++ def test_repo_discovery_prefers_dotgit(self): ++ layouts = { ++ "linked-worktree": { ++ "gitdir": ".git\n", ++ "commondir": ".git\n", ++ "HEAD": "ref: refs/heads/main\n", ++ }, ++ "bare": {"objects": None, "refs": None, "HEAD": "ref: refs/heads/main\n"}, ++ } ++ ++ with tempfile.TemporaryDirectory() as tdir: ++ for name, entries in layouts.items(): ++ path = pathlib.Path(tdir) / name ++ Repo.init(path).close() ++ for entry, contents in entries.items(): ++ item = path / entry ++ if contents is None: ++ item.mkdir() ++ else: ++ item.write_text(contents) ++ ++ with self.subTest(layout=name): ++ expected_git_dir = Git(path).rev_parse("--absolute-git-dir") ++ assert osp.samefile(Repo(path).git_dir, expected_git_dir) ++ ++ def test_repo_discovery_honors_explicit_git_dir(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ git_dir = pathlib.Path(tdir) / "repo.git" ++ Repo.init(git_dir, bare=True).close() ++ Repo.init(git_dir / ".git", bare=True).close() ++ ++ with mock.patch.dict(os.environ, {"GIT_DIR": os.fspath(git_dir)}): ++ for path in (None, ""): ++ with Repo(path) as repo, self.subTest(path=path): ++ assert osp.samefile(repo.git_dir, git_dir) ++ ++ worktree = pathlib.Path(tdir) / "worktree" ++ Repo.init(worktree).close() ++ with cwd(worktree), mock.patch.dict(os.environ, {"GIT_DIR": ""}): ++ with Repo() as repo: ++ assert osp.samefile(repo.git_dir, worktree / ".git") ++ ++ def test_repo_discovery_rejects_invalid_metadata(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ path = pathlib.Path(tdir) ++ (path / "objects").mkdir() ++ (path / "refs").mkdir() ++ (path / "HEAD").write_text("not a ref") ++ ++ with self.subTest(metadata="HEAD"): ++ self.assertRaises(InvalidGitRepositoryError, Repo, path) ++ ++ (path / "HEAD").write_text("ref: refs/heads/main\n") ++ ++ for contents in (b"", b"\xff"): ++ (path / "commondir").write_bytes(contents) ++ with cwd(path), self.subTest(metadata="commondir", contents=contents): ++ self.assertRaises(InvalidGitRepositoryError, Repo, path) ++ ++ (path / "gitdir").write_text("../worktree/.git\n") ++ (path / "commondir").write_text("missing\n") ++ with self.subTest(metadata="linked-worktree"): ++ self.assertRaises(WorkTreeRepositoryUnsupported, Repo, path) ++ ++ (path / "gitdir").unlink() ++ (path / "commondir").unlink() ++ for variable in ("GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"): ++ with mock.patch.dict(os.environ, {variable: ""}), self.subTest(metadata=variable): ++ self.assertRaises(InvalidGitRepositoryError, Repo, path) ++ ++ for contents in (b"not a gitfile", b"gitdir: \n", b"gitdir: .git\n", b"\xff"): ++ (path / ".git").write_bytes(contents) ++ with self.subTest(metadata=".git", contents=contents): ++ self.assertRaises(InvalidGitRepositoryError, Repo, path) ++ ++ def test_repo_discovery_rejects_dangling_commondir(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ path = pathlib.Path(tdir) ++ (path / "objects").mkdir() ++ (path / "refs").mkdir() ++ (path / "HEAD").write_text("ref: refs/heads/main\n") ++ (path / "commondir").symlink_to("missing") ++ ++ self.assertRaises(InvalidGitRepositoryError, Repo, path) ++ ++ def test_repo_discovery_rejects_dotgit_stat_errors(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ path = pathlib.Path(tdir) ++ Repo.init(path).close() ++ child = path / "child" ++ child.mkdir() ++ (child / ".git").symlink_to(".git") ++ ++ self.assertRaises(InvalidGitRepositoryError, Repo, child, search_parent_directories=True) ++ ++ def test_gitfile_read_is_bounded(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ dotgit = pathlib.Path(tdir) / ".git" ++ content = b"gitdir: target\n" ++ dotgit.write_bytes(content) ++ reader = mock.mock_open(read_data=b"") ++ ++ with mock.patch("builtins.open", reader): ++ assert find_worktree_git_dir(dotgit) is None ++ ++ reader().read.assert_called_once_with(len(content)) ++ ++ def test_repo_discovery_uses_storage_environment(self): ++ with tempfile.TemporaryDirectory() as tdir: ++ git_dir = pathlib.Path(tdir) / "git" ++ common_dir = pathlib.Path(tdir) / "common" ++ git_dir.mkdir() ++ common_dir.mkdir() ++ (git_dir / "HEAD").write_text("ref: refs/heads/main\n") ++ (common_dir / "objects").mkdir() ++ (common_dir / "refs").mkdir() ++ (common_dir / "config").write_text("[core]\n\tbare = true\n") ++ ++ with cwd(tdir): ++ with mock.patch.dict(os.environ, {"GIT_DIR": "git", "GIT_COMMON_DIR": "common"}): ++ repo = Repo() ++ ++ assert osp.samefile(repo.common_dir, common_dir) ++ assert osp.samefile(repo.odb.root_path(), common_dir / "objects") ++ assert repo.bare ++ assert osp.samefile(repo.git.rev_parse("--absolute-git-dir"), git_dir) ++ assert osp.samefile(repo.git.rev_parse("--git-common-dir"), common_dir) ++ ++ (git_dir / "commondir").write_text("../common\n") ++ environment = dict(os.environ) ++ environment["GIT_DIR"] = "git" ++ environment.pop("GIT_COMMON_DIR", None) ++ with cwd(tdir), mock.patch.dict(os.environ, environment, clear=True): ++ repo = Repo() ++ ++ assert osp.samefile(repo.git.rev_parse("--absolute-git-dir"), git_dir) ++ ++ if sys.platform.startswith("linux"): ++ byte_common_dir = pathlib.Path(tdir) / os.fsdecode(b"common-\xff") ++ byte_common_dir.mkdir() ++ (byte_common_dir / "objects").mkdir() ++ (byte_common_dir / "refs").mkdir() ++ (git_dir / "commondir").write_bytes(b"../common-\xff\n") ++ ++ assert osp.samefile(Repo(git_dir).common_dir, byte_common_dir) ++ ++ @with_rw_directory ++ def test_repo_discovery_preserves_object_directory(self, tdir): ++ git_dir = pathlib.Path(tdir) / "git" ++ payload = b"custom object database" ++ payload_file = pathlib.Path(tdir) / "payload" ++ payload_file.write_bytes(payload) ++ ++ source_repo = Repo.init(git_dir, bare=True) ++ blob_hexsha = source_repo.git.hash_object("-w", payload_file) ++ source_repo.close() ++ object_dir = pathlib.Path(tdir) / "objects" ++ (git_dir / "objects").rename(object_dir) ++ ++ with cwd(tdir), mock.patch.dict(os.environ, {"GIT_DIR": "git", "GIT_OBJECT_DIRECTORY": "objects"}): ++ repo = Repo(odbt=GitDB) ++ ++ with repo: ++ assert osp.samefile(repo.odb.root_path(), object_dir) ++ assert repo.odb.has_object(bytes.fromhex(blob_hexsha)) ++ assert repo.git.cat_file("blob", blob_hexsha) == payload.decode() ++ repo.alternates = ["other/location"] ++ assert repo.alternates == ["other/location"] ++ assert (object_dir / "info" / "alternates").is_file() ++ + @with_rw_repo("0.3.2.1") + def test_repo_creation_from_different_paths(self, rw_repo): + r_from_gitdir = Repo(rw_repo.git_dir) +@@ -1549,3 +1720,27 @@ class TestRepo(TestBase): + + with pytest.raises(GitCommandError): + temp_repo.ignored(tmp_dir / "symlink/file.txt") ++ ++ @with_rw_directory ++ def test_reftable_repo_opens_but_direct_refs_are_unsupported(self, rw_dir): ++ git = Git(rw_dir) ++ try: ++ git.init(ref_format="reftable") ++ except GitCommandError as err: ++ if err.status == 129: ++ pytest.skip("git init --ref-format is not supported by this git version") ++ raise ++ ++ git.update_environment( ++ GIT_AUTHOR_NAME="Test Author", ++ GIT_AUTHOR_EMAIL="author@example.com", ++ GIT_COMMITTER_NAME="Test Committer", ++ GIT_COMMITTER_EMAIL="committer@example.com", ++ ) ++ git.commit(allow_empty=True, message="initial commit") ++ expected_head = git.rev_parse("HEAD") ++ ++ repo = Repo(rw_dir) ++ assert repo.git.rev_parse("HEAD") == expected_head ++ assert repo.head.reference.name == ".invalid" ++ assert not repo.heads diff -Nru python-git-3.1.44/debian/patches/CVE-2026-87818.patch python-git-3.1.44/debian/patches/CVE-2026-87818.patch --- python-git-3.1.44/debian/patches/CVE-2026-87818.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-87818.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,71 @@ +From: Byron +Date: Mon, 17 Aug 2026 06:15:12 +0200 +Subject: fix: require opt-in for no-index diffs + + +Treat --no-index as an unsafe diff option because it changes path operands +from repository pathspecs to arbitrary filesystem paths. This addresses +GHSA-whh4-5q6c-9v3x without exposing advisory reproduction details. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Origin: https://github.com/gitpython-developers/GitPython/commit/09f2cf383a992d1af697364569e9f384748c1bd8 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-87818 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-87818 +--- + git/diff.py | 4 ++-- + git/repo/base.py | 2 ++ + test/test_diff.py | 11 +++++++++++ + 3 files changed, 15 insertions(+), 2 deletions(-) + +diff --git a/git/diff.py b/git/diff.py +index 9a85a1b..aa048f4 100644 +--- a/git/diff.py ++++ b/git/diff.py +@@ -216,8 +216,8 @@ class Diffable: + to be read and diffed. + + :param allow_unsafe_options: +- If ``True``, allow options such as ``--output`` and ``-O`` that can write to +- or read from arbitrary filesystem paths. ++ If ``True``, allow options such as ``--output``, ``--no-index``, and ``-O`` ++ that can write to or read from arbitrary filesystem paths. + + :param kwargs: + Additional arguments passed to :manpage:`git-diff(1)`, such as ``R=True`` to +diff --git a/git/repo/base.py b/git/repo/base.py +index d5a9a67..daaee8b 100644 +--- a/git/repo/base.py ++++ b/git/repo/base.py +@@ -207,6 +207,8 @@ class Repo: + ] + + unsafe_git_diff_options = unsafe_git_revision_options + [ ++ # Treats path operands as arbitrary filesystem paths. ++ "--no-index", + # Reads caller-controlled order patterns from an arbitrary file. + "-O", + "--orderfile", +diff --git a/test/test_diff.py b/test/test_diff.py +index aa101e7..a982393 100644 +--- a/test/test_diff.py ++++ b/test/test_diff.py +@@ -386,6 +386,17 @@ class TestDiff(TestBase): + commit.diff(output=allowed_target, allow_unsafe_options=True) + self.assertTrue(osp.isfile(allowed_target)) + ++ def test_diff_rejects_no_index(self): ++ calls = ( ++ lambda: self.rorepo.head.commit.diff(no_index=True), ++ lambda: self.rorepo.head.commit.diff(other="--no-index"), ++ lambda: self.rorepo.index.diff(None, no_index=True), ++ lambda: self.rorepo.index.diff("--no-index"), ++ ) ++ for call in calls: ++ with self.assertRaises(UnsafeOptionError): ++ call() ++ + def test_diff_interface(self): + """Test a few variations of the main diff routine.""" + assertion_map = {} diff -Nru python-git-3.1.44/debian/patches/CVE-2026-87819_1.patch python-git-3.1.44/debian/patches/CVE-2026-87819_1.patch --- python-git-3.1.44/debian/patches/CVE-2026-87819_1.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-87819_1.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,102 @@ +From: Byron +Date: Tue, 11 Aug 2026 14:18:25 +0200 +Subject: fix: parse actor identities without regular expressions + + +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 + +Origin: https://github.com/gitpython-developers/GitPython/commit/751473a5f3221d6f989291cbebcc404353fd3ba8 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-87819 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-87819 +--- + git/util.py | 24 ++++++++---------------- + test/test_actor.py | 20 ++++++++++++++++++++ + 2 files changed, 28 insertions(+), 16 deletions(-) + +diff --git a/git/util.py b/git/util.py +index b02e994..2c56dcf 100644 +--- a/git/util.py ++++ b/git/util.py +@@ -772,10 +772,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" +@@ -820,18 +816,14 @@ class 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 5e66357..baf6545 100644 +--- a/test/test_actor.py ++++ b/test/test_actor.py +@@ -27,6 +27,26 @@ class TestActor(TestBase): + 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 + " \n y "), Actor("x", "a")) ++ ++ def test_from_string_uses_git_delimiters(self): ++ for value, expected in ( ++ ("Name ", Actor("Name", "e>", Actor("Name", "email")), ++ ("Name", Actor("Name", "email")), ++ (" <>", Actor("", "")), ++ ("Name ", Actor("Name email>", None)), ++ ): ++ self.assertEqual(Actor._from_string(value), expected) ++ + def test_should_display_representation(self): + a = Actor._from_string("Michael Trier ") + self.assertEqual('">', repr(a)) diff -Nru python-git-3.1.44/debian/patches/CVE-2026-87819_2.patch python-git-3.1.44/debian/patches/CVE-2026-87819_2.patch --- python-git-3.1.44/debian/patches/CVE-2026-87819_2.patch 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/CVE-2026-87819_2.patch 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,210 @@ +From: Byron +Date: Fri, 28 Aug 2026 08:37:50 +0200 +Subject: fix: restore Actor.name_email_regex (#2220) + + +GitPython 3.1.60 removed the Actor.name_email_regex class attribute while +replacing internal actor parsing, which broke downstream consumers such as +python-semantic-release. + +Bring back the historical compiled pattern for downstream compatibility, +but emit DeprecationWarning whenever the class member is accessed. The warning +explains its quadratic behavior on long malformed strings and points callers to +the public Actor(name, email) constructor when fields are separate, or direct +string parsing when they are not. + +Assisted-by: GPT 5.6 +Co-authored-by: GPT 5.6 + +Add public Actor.from_string constructor (#2220) + +Promote the existing actor identity parser to Actor.from_string while retaining _from_string as a compatibility alias. Point the name_email_regex deprecation warning to the new public constructor or Actor(name, email). + +Validation: +- actor-related test/test_actor.py and test/test_util.py cases: 12 passed +- ruff check and format checks passed +- basedpyright git/util.py: no errors + +Co-authored-by: Codex GPT-5 + +Address review feedback about Actor subclasses + +Review feedback: Actor.from_string() should preserve the class on which the public classmethod is called instead of always returning a base Actor. + +Construct through cls on both parsing paths and cover subclass construction directly. + +Validation: +- actor-related test/test_actor.py and test/test_util.py cases: 13 passed +- ruff check and format checks passed +- basedpyright git/util.py: no errors + +Address review feedback about constructor typing + +Review feedback: Actor.from_string() constructs subclasses at runtime but its Actor return annotation prevents type checkers from preserving subclass-specific members. + +Use the project existing Python 3.7-compatible bound TypeVar pattern so the declared return type follows cls. + +Validation: +- actor-related test/test_actor.py and test/test_util.py cases: 13 passed +- ruff check and format checks passed +- basedpyright git/util.py: no errors + +Co-authored-by: Codex GPT-5 + +Origin: https://github.com/gitpython-developers/GitPython/commit/bc63b027e2fc737e2ec0222bfa764e1aa76a427f +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2026-87819 +Bug-Debian-Freexian: https://deb.freexian.com/extended-lts/tracker/CVE-2026-87819 +--- + git/util.py | 26 +++++++++++++++++++++++--- + test/test_actor.py | 35 ++++++++++++++++++++++++++--------- + 2 files changed, 49 insertions(+), 12 deletions(-) + +diff --git a/git/util.py b/git/util.py +index 2c56dcf..4ca1573 100644 +--- a/git/util.py ++++ b/git/util.py +@@ -80,6 +80,7 @@ from typing import ( + Sequence, + Tuple, + TYPE_CHECKING, ++ Type, + TypeVar, + Union, + cast, +@@ -108,6 +109,7 @@ from git.types import ( + + T_IterableObj = TypeVar("T_IterableObj", bound=Union["IterableObj", "Has_id_attribute"], covariant=True) + # So IterableList[Head] is subtype of IterableList[IterableObj]. ++T_Actor = TypeVar("T_Actor", bound="Actor") + + _logger = logging.getLogger(__name__) + +@@ -767,11 +769,27 @@ class CallableRemoteProgress(RemoteProgress): + self._callable(*args, **kwargs) + + ++class _DeprecatedActorNameEmailRegex: ++ _pattern = re.compile(r"(.*) <(.*?)>") ++ ++ def __get__(self, _instance: Any, _owner: Any) -> Pattern[str]: ++ warnings.warn( ++ "Actor.name_email_regex is deprecated and will be removed in GitPython 4.0.0 because searching long " ++ "malformed strings with it can take quadratic time. Use Actor.from_string() to parse actor identities, " ++ "or Actor(name, email) when the fields are already separate.", ++ DeprecationWarning, ++ stacklevel=2, ++ ) ++ return self._pattern ++ ++ + class Actor: + """Actors hold information about a person acting on the repository. They can be + committers and authors or anything with a name and an email as mentioned in the git + log entries.""" + ++ name_email_regex = _DeprecatedActorNameEmailRegex() ++ + # ENVIRONMENT VARIABLES + # These are read when creating new commits. + env_author_name = "GIT_AUTHOR_NAME" +@@ -805,7 +823,7 @@ class Actor: + return '">' % (self.name, self.email) + + @classmethod +- def _from_string(cls, string: str) -> "Actor": ++ def from_string(cls: Type[T_Actor], string: str) -> T_Actor: + """Create an :class:`Actor` from a string. + + :param string: +@@ -820,10 +838,12 @@ class Actor: + 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]) ++ return cls(line[:left_bracket].rstrip(), line[left_bracket + 1 : right_bracket]) + + # Assume the best and use the whole string as name. +- return Actor(string, None) ++ return cls(string, None) ++ ++ _from_string = from_string + + @classmethod + def _main_actor( +diff --git a/test/test_actor.py b/test/test_actor.py +index baf6545..68afb80 100644 +--- a/test/test_actor.py ++++ b/test/test_actor.py +@@ -3,14 +3,15 @@ + # This module is part of GitPython and is released under the + # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ + +-from git import Actor ++from unittest import mock + ++from git import Actor + from test.lib import TestBase + + + class TestActor(TestBase): + def test_from_string_should_separate_name_and_email(self): +- a = Actor._from_string("Michael Trier ") ++ a = Actor.from_string("Michael Trier ") + self.assertEqual("Michael Trier", a.name) + self.assertEqual("mtrier@example.com", a.email) + +@@ -23,18 +24,34 @@ class TestActor(TestBase): + assert len(m) == 1 + + def test_from_string_should_handle_just_name(self): +- a = Actor._from_string("Michael Trier") ++ a = Actor.from_string("Michael Trier") + self.assertEqual("Michael Trier", a.name) + self.assertEqual(None, a.email) + ++ def test_from_string_constructs_subclass(self): ++ class DerivedActor(Actor): ++ pass ++ ++ self.assertIsInstance(DerivedActor.from_string("name "), DerivedActor) ++ + def test_from_string_handles_unterminated_email_without_regex_backtracking(self): + value = "A" * 20_000 + " ") ++ ++ message = str(context.warning) ++ self.assertIn("Actor.from_string()", message) ++ self.assertIn("Actor(name, email)", message) ++ assert match is not None ++ self.assertEqual(match.groups(), ("Michael Trier", "mtrier@example.com")) ++ + def test_from_string_does_not_parse_across_lines(self): +- self.assertEqual(Actor._from_string("x \n y "), Actor("x", "a")) ++ self.assertEqual(Actor.from_string("x \n y "), Actor("x", "a")) + + def test_from_string_uses_git_delimiters(self): + for value, expected in ( +@@ -45,12 +62,12 @@ class TestActor(TestBase): + ("Name ", Actor("Name email>", None)), + ): +- self.assertEqual(Actor._from_string(value), expected) ++ self.assertEqual(Actor.from_string(value), expected) + + def test_should_display_representation(self): +- a = Actor._from_string("Michael Trier ") ++ a = Actor.from_string("Michael Trier ") + self.assertEqual('">', repr(a)) + + def test_str_should_alias_name(self): +- a = Actor._from_string("Michael Trier ") ++ a = Actor.from_string("Michael Trier ") + self.assertEqual(a.name, str(a)) diff -Nru python-git-3.1.44/debian/patches/series python-git-3.1.44/debian/patches/series --- python-git-3.1.44/debian/patches/series 1970-01-01 00:00:00.000000000 +0000 +++ python-git-3.1.44/debian/patches/series 2026-10-28 11:39:59.000000000 +0000 @@ -0,0 +1,42 @@ +CVE-2026-42215.patch +CVE-2026-42284.patch +CVE-2026-44243_1.patch +CVE-2026-44243_2.patch +CVE-2026-44244_1.patch +CVE-2026-44244_2.patch +CVE-2026-67322.patch +CVE-2026-67323.patch +CVE-2026-67324_CVE-2026-67325_1.patch +CVE-2026-67324_CVE-2026-67325_2.patch +CVE-2026-67324_CVE-2026-67325_3.patch +CVE-2026-67326.patch +CVE-2026-69097.patch +CVE-2026-73619.patch +CVE-2026-73620.patch +CVE-2026-73621.patch +CVE-2026-73622.patch +CVE-2026-73623.patch +CVE-2026-73624.patch +CVE-2026-73625.patch +CVE-2026-76217.patch +CVE-2026-76218.patch +CVE-2026-76219.patch +CVE-2026-76220.patch +CVE-2026-76221.patch +CVE-2026-76222.patch +CVE-2026-78675.patch +CVE-2026-78676.patch +CVE-2026-78677.patch +CVE-2026-78678_CVE-2026-78679.patch +CVE-2026-87817.patch +CVE-2026-87818.patch +CVE-2026-87819_1.patch +CVE-2026-87819_2.patch +CVE-2026-100689.patch +CVE-2026-110855.patch +CVE-2026-110856.patch +CVE-2026-110857.patch +CVE-2026-110858.patch +CVE-2026-110859.patch +CVE-2026-110860.patch +CVE-2026-114145.patch