Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-dulwich for openSUSE:Factory 
checked in at 2026-05-29 18:12:50
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-dulwich (Old)
 and      /work/SRC/openSUSE:Factory/.python-dulwich.new.1937 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-dulwich"

Fri May 29 18:12:50 2026 rev:72 rq:1355886 version:1.2.5

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-dulwich/python-dulwich.changes    
2026-05-23 23:23:44.219795470 +0200
+++ /work/SRC/openSUSE:Factory/.python-dulwich.new.1937/python-dulwich.changes  
2026-05-29 18:14:33.433000731 +0200
@@ -1,0 +2,12 @@
+Thu May 28 23:04:19 UTC 2026 - Lukas Müller <[email protected]>
+
+- Update to version 1.2.5.
+  Changelog: https://github.com/jelmer/dulwich/releases/tag/dulwich-1.2.5
+  Security release fixing the following issues:
+  * GHSA-gfhv-vqv2-4544.
+  * CVE-2026-42305
+  * CVE-2026-42563
+  * CVE-2026-47712
+  * receive.maxInputSize
+
+-------------------------------------------------------------------

Old:
----
  dulwich-1.2.4.tar.gz

New:
----
  dulwich-1.2.5.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-dulwich.spec ++++++
--- /var/tmp/diff_new_pack.jcwFBB/_old  2026-05-29 18:14:35.513085320 +0200
+++ /var/tmp/diff_new_pack.jcwFBB/_new  2026-05-29 18:14:35.533086133 +0200
@@ -25,7 +25,7 @@
 %{?sle15_python_module_pythons}
 %define oldpython python
 Name:           python-dulwich
-Version:        1.2.4
+Version:        1.2.5
 Release:        0
 Summary:        Pure-Python Git Library
 License:        Apache-2.0 OR GPL-2.0-or-later

++++++ dulwich-1.2.4.tar.gz -> dulwich-1.2.5.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/Cargo.lock 
new/dulwich-dulwich-1.2.5/Cargo.lock
--- old/dulwich-dulwich-1.2.4/Cargo.lock        2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/Cargo.lock        2026-05-28 23:55:09.000000000 
+0200
@@ -14,7 +14,7 @@
 
 [[package]]
 name = "diff-tree-py"
-version = "1.2.4"
+version = "1.2.5"
 dependencies = [
  "pyo3",
 ]
@@ -39,7 +39,7 @@
 
 [[package]]
 name = "objects-py"
-version = "1.2.4"
+version = "1.2.5"
 dependencies = [
  "memchr",
  "pyo3",
@@ -53,7 +53,7 @@
 
 [[package]]
 name = "pack-py"
-version = "1.2.4"
+version = "1.2.5"
 dependencies = [
  "memchr",
  "pyo3",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/Cargo.toml 
new/dulwich-dulwich-1.2.5/Cargo.toml
--- old/dulwich-dulwich-1.2.4/Cargo.toml        2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/Cargo.toml        2026-05-28 23:55:09.000000000 
+0200
@@ -6,4 +6,4 @@
 pyo3 = ">=0.25, <0.29"
 
 [workspace.package]
-version = "1.2.4"
+version = "1.2.5"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/NEWS 
new/dulwich-dulwich-1.2.5/NEWS
--- old/dulwich-dulwich-1.2.4/NEWS      2026-05-21 21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/NEWS      2026-05-28 23:55:09.000000000 +0200
@@ -1,3 +1,67 @@
+1.2.5  2026-05-28
+
+ * SECURITY(GHSA-gfhv-vqv2-4544): Validate submodule paths in
+   ``porcelain.submodule_update`` (and thus
+   ``porcelain.clone(recurse_submodules=True)``). A crafted upstream
+   repository could carry a submodule whose path was ``.git/hooks`` (or
+   any other path inside ``.git`` or above the work tree), causing the
+   submodule's tree contents to be written there with their executable
+   bits intact -- dropping a hook that later commands would run. Submodule
+   paths are now rejected if they are absolute or carry a component that
+   the configured path validator refuses, and the submodule's own tree is
+   materialized with the same validator. This is the dulwich analogue of git's
+   CVE-2024-32002 / CVE-2024-32004.
+   (Jelmer Vernooij; reported by tonghuaroot)
+
+ * SECURITY(CVE-2026-42305): Harden tree path validation against entry
+   names that are harmless on POSIX but dangerous when checked out on
+   Windows. A crafted tree could previously carry such names through to
+   the work tree. ``validate_path_element_ntfs`` now also rejects:
+
+   - Windows path separators, so an entry named
+     ``.git\hooks\pre-commit.exe`` can no longer materialize a file
+     inside ``.git`` that Git for Windows would execute.
+   - The alternate data stream marker ``:`` (e.g.
+     ``.git::$INDEX_ALLOCATION``, which writes into ``.git`` directly).
+   - NTFS 8.3 short-name aliases of ``.git`` (``git~<digits>``); only
+     ``git~1`` was rejected before.
+   - Reserved Windows device names (``CON``, ``PRN``, ``AUX``, ``NUL``,
+     ``COM1``-``COM9``, ``LPT1``-``LPT9``), including with an extension or
+     trailing dots/spaces such as ``NUL.txt`` or ``COM1 .bar``.
+
+   In addition, ``core.protectNTFS`` now defaults to true on every
+   platform (matching git after CVE-2019-1353), so a POSIX clone no longer
+   accepts paths that would be unsafe on a later Windows clone, and both
+   ``core.protectNTFS`` and ``core.protectHFS`` are now read under their
+   correct option names, having previously been silently ignored. POSIX
+   users who need literal NTFS-unsafe filenames can opt out with
+   ``core.protectNTFS=false``.
+   (Jelmer Vernooij; reported by Christopher Toth)
+
+ * SECURITY (CVE-2026-42563): Shell-quote values substituted into
+   ``ProcessMergeDriver`` commands. ``%P`` is a path from the git
+   tree, so a malicious branch could inject shell commands when the
+   user had a merge driver configured that referenced ``%P``.
+   (Jelmer Vernooij; reported by Ravishanker Kusuma (hayageek))
+
+ * SECURITY(CVE-2026-47712): Sanitize commit subjects used in
+   ``porcelain.format_patch`` filenames so a malicious subject (e.g.
+   ``x/../../x``) cannot direct the generated patch outside ``outdir``.
+   ``get_summary`` now matches git's ``format_sanitized_subject``.
+   (Jelmer Vernooij; reported by Christopher Toth)
+
+ * SECURITY: Honour ``receive.maxInputSize`` in
+   ``ReceivePackHandler``. Previously a remote unauthenticated client
+   could send a tiny crafted pack (~174 bytes) that declared a huge
+   ``dest_size`` in its delta header and trigger hundreds of MB of
+   allocation in ``apply_delta`` / ``add_thin_pack``, exhausting
+   server memory over ``git-receive-pack``. ``add_thin_pack`` now
+   accepts a ``max_input_size`` keyword (in bytes, ``0`` / ``None`` =
+   unlimited, matching git's semantics) and ``ReceivePackHandler``
+   reads ``receive.maxInputSize`` from the repository config and
+   passes it through. Exceeding the cap raises ``PackInputTooLarge``.
+   (Jelmer Vernooij; Reported by Liyi, Ziyue, Strick, Maurice and Chenchen @ 
University of Sydney)
+
 1.2.4  2026-05-21
 
  * Tolerate ref names with empty path components (e.g. ``refs/tags//v1.0``)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/__init__.py 
new/dulwich-dulwich-1.2.5/dulwich/__init__.py
--- old/dulwich-dulwich-1.2.4/dulwich/__init__.py       2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/dulwich/__init__.py       2026-05-28 
23:55:09.000000000 +0200
@@ -25,7 +25,7 @@
 from collections.abc import Callable
 from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
 
-__version__ = (1, 2, 4)
+__version__ = (1, 2, 5)
 
 __all__ = ["__version__", "replace_me"]
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/cli.py 
new/dulwich-dulwich-1.2.5/dulwich/cli.py
--- old/dulwich-dulwich-1.2.4/dulwich/cli.py    2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/dulwich/cli.py    2026-05-28 23:55:09.000000000 
+0200
@@ -1675,7 +1675,7 @@
                         "Rich is required for colored output. Install with: 
pip install 'dulwich[colordiff]'"
                     )
                 else:
-                    logging.warning(
+                    logger.warning(
                         "Rich not available, disabling colored output. Install 
with: pip install 'dulwich[colordiff]'"
                     )
                     return outstream.buffer
@@ -2132,7 +2132,7 @@
                 ssh_command=_ssh_command_from_env(),
             )
         except GitProtocolError as e:
-            logging.exception(e)
+            logger.exception(e)
 
 
 def _get_commit_message_with_template(
@@ -2470,7 +2470,7 @@
                 ".", message=message, all=parsed_args.all, 
amend=parsed_args.amend
             )
         except CommitMessageError as e:
-            logging.exception(e)
+            logger.exception(e)
             return 1
         return None
 
@@ -2705,7 +2705,7 @@
                         "Rich is required for colored output. Install with: 
pip install 'dulwich[colordiff]'"
                     )
                 else:
-                    logging.warning(
+                    logger.warning(
                         "Rich not available, disabling colored output. Install 
with: pip install 'dulwich[colordiff]'"
                     )
                     return outstream
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/client.py 
new/dulwich-dulwich-1.2.5/dulwich/client.py
--- old/dulwich-dulwich-1.2.4/dulwich/client.py 2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/dulwich/client.py 2026-05-28 23:55:09.000000000 
+0200
@@ -521,7 +521,7 @@
             elif part.startswith(b"symref-target:"):
                 symrefs[ref] = Ref(part[14:])
             else:
-                logging.warning("unknown part in pkt-ref: %s", part)
+                logger.warning("unknown part in pkt-ref: %s", part)
         refs[ref] = sha
 
     return refs, symrefs, peeled
@@ -1943,7 +1943,7 @@
 
     @staticmethod
     def _warn_filter_objects() -> None:
-        logging.warning("object filtering not recognized by server, ignoring")
+        logger.warning("object filtering not recognized by server, ignoring")
 
 
 def check_wants(wants: Set[bytes], refs: Mapping[bytes, bytes]) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/filters.py 
new/dulwich-dulwich-1.2.5/dulwich/filters.py
--- old/dulwich-dulwich-1.2.4/dulwich/filters.py        2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/dulwich/filters.py        2026-05-28 
23:55:09.000000000 +0200
@@ -49,6 +49,9 @@
     from .repo import BaseRepo
 
 
+logger = logging.getLogger(__name__)
+
+
 class FilterError(Exception):
     """Exception raised when filter operations fail."""
 
@@ -328,7 +331,7 @@
             except FilterError as e:
                 if self.required:
                     raise
-                logging.warning("Process filter failed, falling back: %s", e)
+                logger.warning("Process filter failed, falling back: %s", e)
 
         # Fall back to clean command
         if not self.clean_cmd:
@@ -355,7 +358,7 @@
             if self.required:
                 raise FilterError(f"Required clean filter failed: {e}")
             # If not required, log warning and return original data on failure
-            logging.warning("Optional clean filter failed: %s", e)
+            logger.warning("Optional clean filter failed: %s", e)
             return data
 
     def smudge(self, data: bytes, path: bytes = b"") -> bytes:
@@ -371,7 +374,7 @@
             except FilterError as e:
                 if self.required:
                     raise
-                logging.warning("Process filter failed, falling back: %s", e)
+                logger.warning("Process filter failed, falling back: %s", e)
 
         # Fall back to smudge command
         if not self.smudge_cmd:
@@ -403,7 +406,7 @@
                     f"Required smudge filter failed: {e} {e.stderr} {e.stdout}"
                 )
             # If not required, log warning and return original data on failure
-            logging.warning("Optional smudge filter failed: %s", e)
+            logger.warning("Optional smudge filter failed: %s", e)
             return data
 
     def cleanup(self) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/gc.py 
new/dulwich-dulwich-1.2.5/dulwich/gc.py
--- old/dulwich-dulwich-1.2.4/dulwich/gc.py     2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/dulwich/gc.py     2026-05-28 23:55:09.000000000 
+0200
@@ -55,6 +55,8 @@
     from .repo import BaseRepo, Repo
 
 
+logger = logging.getLogger(__name__)
+
 DEFAULT_GC_AUTO = 6700
 DEFAULT_GC_AUTO_PACK_LIMIT = 50
 DEFAULT_GC_PRUNE_EXPIRE = 1209600  # 2 weeks in seconds
@@ -486,7 +488,7 @@
         if time.time() - stat_info.st_mtime < expiry_seconds:
             # gc.log exists and is not expired - skip GC
             with open(gc_log_path, "rb") as f:
-                logging.info(
+                logger.info(
                     "gc.log content: %s", f.read().decode("utf-8", 
errors="replace")
                 )
             return False
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/index.py 
new/dulwich-dulwich-1.2.5/dulwich/index.py
--- old/dulwich-dulwich-1.2.4/dulwich/index.py  2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/dulwich/index.py  2026-05-28 23:55:09.000000000 
+0200
@@ -60,6 +60,7 @@
     "commit_tree",
     "detect_case_only_renames",
     "get_path_element_normalizer",
+    "get_path_element_validator",
     "get_unstaged_changes",
     "index_entry_from_stat",
     "make_path_normalizer",
@@ -85,6 +86,7 @@
 ]
 
 import errno
+import logging
 import os
 import shutil
 import stat
@@ -131,6 +133,8 @@
 )
 from .pack import ObjectContainer, SHA1Reader, SHA1Writer
 
+logger = logging.getLogger(__name__)
+
 # Type alias for recursive tree structure used in commit_tree
 TreeDict = dict[bytes, "TreeDict | tuple[int, ObjectID]"]
 
@@ -1970,6 +1974,33 @@
     return _normalize_path_element_default(element) not in INVALID_DOTNAMES
 
 
+def _is_ntfs_dotgit_short_name(normalized: bytes) -> bool:
+    """Match NTFS 8.3 short-name forms of ``.git`` (``git~<digits>``)."""
+    if not normalized.startswith(b"git~"):
+        return False
+    tail = normalized[4:]
+    return len(tail) > 0 and tail.isdigit()
+
+
+# Reserved Windows device names. Opening any of these on Windows
+# resolves to a device rather than a file, regardless of any
+# extension or trailing dots/spaces (``NUL``, ``NUL.txt``,
+# ``aux.foo.bar`` all hit the device).
+RESERVED_WINDOWS_DEVICE_NAMES = frozenset(
+    [b"con", b"prn", b"aux", b"nul"]
+    + [b"com%d" % i for i in range(1, 10)]
+    + [b"lpt%d" % i for i in range(1, 10)]
+)
+
+
+def _is_reserved_windows_device_name(normalized: bytes) -> bool:
+    """Match Windows reserved device names regardless of extension."""
+    # The "stem" is the portion before the first ``.``; Windows
+    # also strips trailing spaces from that stem when resolving.
+    stem = normalized.split(b".", 1)[0].rstrip(b" ")
+    return stem in RESERVED_WINDOWS_DEVICE_NAMES
+
+
 def validate_path_element_ntfs(element: bytes) -> bool:
     """Validate a path element using NTFS filesystem rules.
 
@@ -1979,10 +2010,22 @@
     Returns:
       True if path element is valid for NTFS, False otherwise
     """
+    # A backslash is a path separator on Windows, so accepting it
+    # here would let a tree authored on POSIX escape the work tree
+    # or plant files under ``.git\`` when checked out on Windows.
+    if b"\\" in element:
+        return False
+    # NTFS alternate data streams are addressed as ``name:stream``;
+    # reject any element containing ``:`` so ``.git::$INDEX_ALLOCATION``
+    # and similar forms cannot bypass the ``.git`` check.
+    if b":" in element:
+        return False
     normalized = _normalize_path_element_ntfs(element)
     if normalized in INVALID_DOTNAMES:
         return False
-    if normalized == b"git~1":
+    if _is_ntfs_dotgit_short_name(normalized):
+        return False
+    if _is_reserved_windows_device_name(normalized):
         return False
     return True
 
@@ -2031,6 +2074,30 @@
     return True
 
 
+def get_path_element_validator(config: "Config") -> Callable[[bytes], bool]:
+    """Get the path-element validator to use when checking out a tree.
+
+    ``core.protectNTFS`` defaults to true on every platform (matching Git's
+    ``PROTECT_NTFS_DEFAULT=1``) because a repository authored on POSIX can
+    still be cloned on Windows later; ``core.protectHFS`` defaults to true on
+    macOS. With both disabled this falls back to the default validator, which
+    only refuses ``.git``, ``.`` and ``..``.
+
+    Args:
+        config: Repository configuration object
+
+    Returns:
+        Function that validates a single path element for the configured
+        filesystem protections.
+    """
+    if config.get_boolean(b"core", b"protectNTFS", True):
+        return validate_path_element_ntfs
+    elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
+        return validate_path_element_hfs
+    else:
+        return validate_path_element_default
+
+
 def validate_path(
     path: bytes,
     element_validator: Callable[[bytes], bool] = validate_path_element_default,
@@ -2590,9 +2657,7 @@
             try:
                 normalized = normalize_path(change.old.path)
             except UnicodeDecodeError:
-                import logging
-
-                logging.warning(
+                logger.warning(
                     "Skipping case-only rename detection for path with invalid 
UTF-8: %r",
                     change.old.path,
                 )
@@ -2605,9 +2670,7 @@
             try:
                 normalized = normalize_path(change.old.path)
             except UnicodeDecodeError:
-                import logging
-
-                logging.warning(
+                logger.warning(
                     "Skipping case-only rename detection for path with invalid 
UTF-8: %r",
                     change.old.path,
                 )
@@ -2623,9 +2686,7 @@
             try:
                 normalized = normalize_path(change.new.path)
             except UnicodeDecodeError:
-                import logging
-
-                logging.warning(
+                logger.warning(
                     "Skipping case-only rename detection for path with invalid 
UTF-8: %r",
                     change.new.path,
                 )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/merge_drivers.py 
new/dulwich-dulwich-1.2.5/dulwich/merge_drivers.py
--- old/dulwich-dulwich-1.2.4/dulwich/merge_drivers.py  2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/dulwich/merge_drivers.py  2026-05-28 
23:55:09.000000000 +0200
@@ -29,7 +29,9 @@
 ]
 
 import os
+import shlex
 import subprocess
+import sys
 import tempfile
 from collections.abc import Callable
 from typing import Protocol
@@ -64,6 +66,15 @@
         ...
 
 
+def _shell_quote(value: str) -> str:
+    """Shell-quote ``value`` for the platform's default shell."""
+    if sys.platform == "win32":
+        if any(c in value for c in "\r\n\x00"):
+            raise ValueError("value contains unescapable character for 
cmd.exe")
+        return '"' + value.replace('"', '""') + '"'
+    return shlex.quote(value)
+
+
 class ProcessMergeDriver:
     """Merge driver that runs an external process."""
 
@@ -110,14 +121,18 @@
             with open(theirs_path, "wb") as f:
                 f.write(theirs)
 
-            # Prepare command with placeholders
+            # %P is attacker-controllable; quote everything (CVE-2026-42563).
             cmd = self.command
-            cmd = cmd.replace("%O", ancestor_path)
-            cmd = cmd.replace("%A", ours_path)
-            cmd = cmd.replace("%B", theirs_path)
-            cmd = cmd.replace("%L", str(marker_size))
+            cmd = cmd.replace("%O", _shell_quote(ancestor_path))
+            cmd = cmd.replace("%A", _shell_quote(ours_path))
+            cmd = cmd.replace("%B", _shell_quote(theirs_path))
+            cmd = cmd.replace("%L", _shell_quote(str(marker_size)))
             if path:
-                cmd = cmd.replace("%P", path)
+                try:
+                    quoted_path = _shell_quote(path)
+                except ValueError:
+                    return ours, False
+                cmd = cmd.replace("%P", quoted_path)
 
             # Execute merge command
             try:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/object_store.py 
new/dulwich-dulwich-1.2.5/dulwich/object_store.py
--- old/dulwich-dulwich-1.2.4/dulwich/object_store.py   2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/dulwich/object_store.py   2026-05-28 
23:55:09.000000000 +0200
@@ -43,6 +43,7 @@
     "PackBasedObjectStore",
     "PackCapableObjectStore",
     "PackContainer",
+    "PackInputTooLarge",
     "commit_tree_changes",
     "find_shallow",
     "get_depth",
@@ -54,6 +55,7 @@
 ]
 
 import binascii
+import logging
 import os
 import stat
 import sys
@@ -125,6 +127,8 @@
     from .pack import FilePackIndex, Pack
 
 
+logger = logging.getLogger(__name__)
+
 # Maximum number of times to rescan the pack directory after a pack file
 # disappears between snapshot and lazy open (e.g. concurrent repack).
 # Mirrors git's bounded reprepare_packed_git() retry.
@@ -218,6 +222,51 @@
 DEFAULT_TEMPFILE_GRACE_PERIOD = 14 * 24 * 60 * 60  # 2 weeks
 
 
+class PackInputTooLarge(OSError):
+    """Raised when a received pack exceeds the configured input size cap.
+
+    Mirrors the failure mode of git's ``receive.maxInputSize`` /
+    ``git index-pack --max-input-size``.
+    """
+
+
+def _bound_read_callables(
+    read_all: Callable[[int], bytes],
+    read_some: Callable[[int], bytes] | None,
+    max_input_size: int,
+) -> tuple[Callable[[int], bytes], Callable[[int], bytes] | None]:
+    """Wrap pack-stream read callbacks so total bytes are capped.
+
+    When the cumulative number of bytes returned across ``read_all`` and
+    ``read_some`` exceeds ``max_input_size``, the next read raises
+    ``PackInputTooLarge``. This is the in-process analogue of
+    ``git index-pack --max-input-size``.
+    """
+    bytes_read = [0]
+
+    def _check(n: int) -> None:
+        bytes_read[0] += n
+        if bytes_read[0] > max_input_size:
+            raise PackInputTooLarge(
+                f"pack exceeds maximum input size of {max_input_size} bytes"
+            )
+
+    def wrapped_read_all(n: int) -> bytes:
+        data = read_all(n)
+        _check(len(data))
+        return data
+
+    if read_some is None:
+        return wrapped_read_all, None
+
+    def wrapped_read_some(n: int) -> bytes:
+        data = read_some(n)
+        _check(len(data))
+        return data
+
+    return wrapped_read_all, wrapped_read_some
+
+
 def find_shallow(
     store: ObjectContainer, heads: Iterable[ObjectID], depth: int
 ) -> tuple[set[ObjectID], set[ObjectID]]:
@@ -2130,6 +2179,8 @@
         read_all: Callable[[int], bytes],
         read_some: Callable[[int], bytes] | None,
         progress: Callable[..., None] | None = None,
+        *,
+        max_input_size: int | None = None,
     ) -> "Pack":
         """Add a new thin pack to this object store.
 
@@ -2143,11 +2194,21 @@
           read_some: Read function that returns at least one byte, but may
             not return the number of bytes requested.
           progress: Optional progress reporting function.
+          max_input_size: Maximum number of bytes that may be read from
+            the wire while ingesting this pack. Matches git's
+            ``receive.maxInputSize`` / ``index-pack --max-input-size``
+            semantics: ``None`` (the default) or ``0`` mean unlimited.
+            Exceeding the cap raises ``PackInputTooLarge``.
         Returns: A Pack object pointing at the now-completed thin pack in the
             objects/pack directory.
         """
         import tempfile
 
+        if max_input_size:
+            read_all, read_some = _bound_read_callables(
+                read_all, read_some, max_input_size
+            )
+
         fd, path = tempfile.mkstemp(dir=self.path, prefix="tmp_pack_")
         with os.fdopen(fd, "w+b") as f:
             os.chmod(path, PACK_MODE)
@@ -2898,8 +2959,6 @@
       unknown: How to handle unknown objects: "error", "warn", or "ignore"
     Returns: A tuple of (commits, tags, others) SHA1s
     """
-    import logging
-
     if unknown not in ("error", "warn", "ignore"):
         raise ValueError(
             f"unknown must be 'error', 'warn', or 'ignore', got {unknown!r}"
@@ -2915,9 +2974,7 @@
             if unknown == "error":
                 raise
             elif unknown == "warn":
-                logging.warning(
-                    "Object %s not found in object store", e.decode("ascii")
-                )
+                logger.warning("Object %s not found in object store", 
e.decode("ascii"))
             # else: ignore
         else:
             if isinstance(o, Commit):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/patch.py 
new/dulwich-dulwich-1.2.5/dulwich/patch.py
--- old/dulwich-dulwich-1.2.4/dulwich/patch.py  2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/dulwich/patch.py  2026-05-28 23:55:09.000000000 
+0200
@@ -172,16 +172,66 @@
         f.write(version.encode(encoding) + b"\n")
 
 
+def _sanitize_subject_for_filename(text: str, max_length: int = 52) -> str:
+    """Sanitize a string for safe use as part of a filename.
+
+    Matches git's ``format_sanitized_subject`` behavior:
+
+    - Only ``[A-Za-z0-9._]`` are kept; other characters become ``-``
+      (collapsed across runs).
+    - Consecutive ``.`` are collapsed to a single ``.``.
+    - The result is truncated to ``max_length`` characters.
+    - Trailing ``.`` and ``-`` are stripped.
+
+    Args:
+      text: Input string (typically a commit subject line).
+      max_length: Maximum length of the returned string.
+
+    Returns: Sanitized string safe to embed in a filename.
+    """
+    result: list[str] = []
+    # 2 = initial, 1 = saw a non-title char, 0 = saw a title char
+    space = 2
+    i = 0
+    text_len = len(text)
+    while i < text_len:
+        c = text[i]
+        if ("A" <= c <= "Z") or ("a" <= c <= "z") or ("0" <= c <= "9") or c in 
"._":
+            if space == 1:
+                result.append("-")
+            space = 0
+            result.append(c)
+            if c == ".":
+                while i + 1 < text_len and text[i + 1] == ".":
+                    i += 1
+        else:
+            space |= 1
+        i += 1
+        if len(result) >= max_length:
+            break
+
+    return "".join(result)[:max_length].rstrip(".-")
+
+
 def get_summary(commit: "Commit") -> str:
     """Determine the summary line for use in a filename.
 
+    Sanitizes the commit subject so it is safe to use as a filename
+    component, matching git's ``format_sanitized_subject`` behavior:
+    characters outside ``[A-Za-z0-9._]`` are replaced with ``-`` (with
+    runs collapsed) and consecutive ``.`` are collapsed. The result is
+    also length-limited to prevent overly long filenames.
+
     Args:
       commit: Commit
-    Returns: Summary string
+    Returns: Sanitized summary string suitable for use as a filename
+      component.
     """
     decoded = commit.message.decode(errors="replace")
     lines = decoded.splitlines()
-    return lines[0].replace(" ", "-") if lines else ""
+    if not lines:
+        return ""
+    return _sanitize_subject_for_filename(lines[0])
 
 
 #  Unified Diff
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/porcelain/__init__.py 
new/dulwich-dulwich-1.2.5/dulwich/porcelain/__init__.py
--- old/dulwich-dulwich-1.2.4/dulwich/porcelain/__init__.py     2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/dulwich/porcelain/__init__.py     2026-05-28 
23:55:09.000000000 +0200
@@ -474,6 +474,8 @@
     worktree_unlock,
 )
 
+logger = logging.getLogger(__name__)
+
 # Module level tuple definition for status output
 GitStatus = namedtuple("GitStatus", "staged unstaged untracked")
 
@@ -1551,10 +1553,10 @@
             submodule_update(repo, init=True, recursive=True)
         except FileNotFoundError as e:
             # .gitmodules file doesn't exist - no submodules to process
-            logging.debug("No .gitmodules file found: %s", e)
+            logger.debug("No .gitmodules file found: %s", e)
         except KeyError as e:
             # Submodule configuration missing
-            logging.warning("Submodule configuration error: %s", e)
+            logger.warning("Submodule configuration error: %s", e)
             if errstream:
                 errstream.write(
                     f"Warning: Submodule configuration error: {e}\n".encode()
@@ -5414,9 +5416,12 @@
     config = repo.get_config()
     honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
 
-    if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
+    # core.protectNTFS defaults to True on all platforms (matching
+    # Git's PROTECT_NTFS_DEFAULT=1) because a repo authored on
+    # POSIX can still be cloned on Windows later.
+    if config.get_boolean(b"core", b"protectNTFS", True):
         validate_path_element = validate_path_element_ntfs
-    elif config.get_boolean(b"core", b"core.protectHFS", sys.platform == 
"darwin"):
+    elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
         validate_path_element = validate_path_element_hfs
     else:
         validate_path_element = validate_path_element_default
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/porcelain/lfs.py 
new/dulwich-dulwich-1.2.5/dulwich/porcelain/lfs.py
--- old/dulwich-dulwich-1.2.4/dulwich/porcelain/lfs.py  2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/dulwich/porcelain/lfs.py  2026-05-28 
23:55:09.000000000 +0200
@@ -36,6 +36,8 @@
 from dulwich.refs import HEADREF, Ref
 from dulwich.repo import Repo
 
+logger = logging.getLogger(__name__)
+
 
 def lfs_track(
     repo: str | os.PathLike[str] | Repo = ".",
@@ -647,7 +649,7 @@
                     content = f.read()
             except KeyError:
                 # Object not in local store
-                logging.warn("LFS object %s not found locally", oid)
+                logger.warning("LFS object %s not found locally", oid)
             else:
                 client.upload(oid, size, content)
                 pushed += 1
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/porcelain/submodule.py 
new/dulwich-dulwich-1.2.5/dulwich/porcelain/submodule.py
--- old/dulwich-dulwich-1.2.4/dulwich/porcelain/submodule.py    2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/dulwich/porcelain/submodule.py    2026-05-28 
23:55:09.000000000 +0200
@@ -22,7 +22,7 @@
 """Porcelain functions for working with submodules."""
 
 import os
-from collections.abc import Iterator, Sequence
+from collections.abc import Callable, Iterator, Sequence
 from typing import TYPE_CHECKING, BinaryIO
 
 from ..config import ConfigFile, read_submodules
@@ -87,6 +87,29 @@
         config.write_to_path()
 
 
+def _check_submodule_path(path: bytes, validator: Callable[[bytes], bool]) -> 
None:
+    """Reject submodule paths that would escape the working tree.
+
+    Args:
+      path: Submodule path as it appears in the tree gitlink entry.
+      validator: Path-element validator selected for this repository.
+
+    Raises:
+      Error: If the path is absolute or carries a component (e.g. ``.git`` or
+        ``..``) that the validator rejects. This is the same bar git applies
+        to submodule paths, not a stricter one.
+    """
+    from ..index import validate_path
+    from . import Error
+
+    # Tree paths always use "/" as the separator; a leading "/" or "\\" would
+    # make os.path.join discard the repository root, so treat it as absolute.
+    if path.startswith((b"/", b"\\")):
+        raise Error(f"refusing submodule with absolute path: {path!r}")
+    if not validate_path(path, validator):
+        raise Error(f"refusing submodule with unsafe path: {path!r}")
+
+
 def submodule_list(repo: "RepoPath") -> Iterator[tuple[str, str]]:
     """List submodules.
 
@@ -122,7 +145,7 @@
       errstream: Error stream for error messages
     """
     from ..client import get_transport_and_path
-    from ..index import build_index_from_tree
+    from ..index import build_index_from_tree, get_path_element_validator
     from ..refs import HEADREF
     from ..submodule import iter_cached_submodules
     from . import (
@@ -138,12 +161,14 @@
 
         config = r.get_config()
         gitmodules_path = os.path.join(r.path, ".gitmodules")
+        path_validator = get_path_element_validator(config)
 
         # Get list of submodules to update
         submodules_to_update = []
         head_commit = r[r.head()]
         assert isinstance(head_commit, Commit)
         for path, sha in iter_cached_submodules(r.object_store, 
head_commit.tree):
+            _check_submodule_path(path, path_validator)
             path_str = (
                 path.decode(DEFAULT_ENCODING) if isinstance(path, bytes) else 
path
             )
@@ -231,6 +256,7 @@
                         sub_repo.index_path(),
                         sub_repo.object_store,
                         tree_id,
+                        validate_path_element=path_validator,
                     )
             else:
                 # Fetch and checkout in existing submodule
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/repo.py 
new/dulwich-dulwich-1.2.5/dulwich/repo.py
--- old/dulwich-dulwich-1.2.4/dulwich/repo.py   2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/dulwich/repo.py   2026-05-28 23:55:09.000000000 
+0200
@@ -58,6 +58,7 @@
     "serialize_graftpoints",
 ]
 
+import logging
 import os
 import stat
 import sys
@@ -157,6 +158,8 @@
     write_packed_refs,  # noqa: F401
 )
 
+logger = logging.getLogger(__name__)
+
 CONTROLDIR = ".git"
 OBJECTDIR = "objects"
 DEFAULT_OFS_DELTA = True
@@ -835,8 +838,6 @@
           depth: Shallow fetch depth
         Returns: iterator over objects, with __len__ implemented
         """
-        import logging
-
         # Filter out refs pointing to missing objects to avoid errors 
downstream.
         # This makes Dulwich more robust when dealing with broken refs on disk.
         # Previously serialize_refs() did this filtering as a side-effect.
@@ -846,7 +847,7 @@
             if sha in self.object_store:
                 refs[ref] = sha
             else:
-                logging.warning(
+                logger.warning(
                     "ref %s points at non-present sha %s",
                     ref.decode("utf-8", "replace"),
                     sha.decode("ascii"),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/server.py 
new/dulwich-dulwich-1.2.5/dulwich/server.py
--- old/dulwich-dulwich-1.2.4/dulwich/server.py 2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/dulwich/server.py 2026-05-28 23:55:09.000000000 
+0200
@@ -1433,6 +1433,25 @@
             capability_object_format(self.repo.object_format.name),
         ]
 
+    def _receive_max_input_size(self) -> int | None:
+        """Return the configured ``receive.maxInputSize`` for this repo.
+
+        Mirrors git: the value is in bytes, and ``0`` (the default) means
+        unlimited. Returned as ``None`` when unset or zero so it can be
+        passed verbatim as ``add_thin_pack(..., max_input_size=...)``.
+        """
+        config = self.repo.get_config_stack()  # type: ignore[attr-defined]
+        try:
+            raw = config.get((b"receive",), b"maxInputSize")
+        except KeyError:
+            return None
+        try:
+            value = int(raw.decode())
+        except ValueError:
+            logger.warning("Ignoring invalid receive.maxInputSize value %r", 
raw)
+            return None
+        return value if value > 0 else None
+
     def _apply_pack(
         self, refs: list[tuple[ObjectID, ObjectID, Ref]]
     ) -> Iterator[tuple[bytes, bytes]]:
@@ -1466,7 +1485,11 @@
             # string
             try:
                 recv = getattr(self.proto, "recv", None)
-                self.repo.object_store.add_thin_pack(self.proto.read, recv)  # 
type: ignore[attr-defined]
+                self.repo.object_store.add_thin_pack(  # type: 
ignore[attr-defined]
+                    self.proto.read,
+                    recv,
+                    max_input_size=self._receive_max_input_size(),
+                )
                 yield (b"unpack", b"ok")
             except all_exceptions as e:
                 yield (b"unpack", str(e).replace("\n", "").encode("utf-8"))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/stash.py 
new/dulwich-dulwich-1.2.5/dulwich/stash.py
--- old/dulwich-dulwich-1.2.4/dulwich/stash.py  2026-05-21 21:19:15.000000000 
+0200
+++ new/dulwich-dulwich-1.2.5/dulwich/stash.py  2026-05-28 23:55:09.000000000 
+0200
@@ -28,7 +28,6 @@
 ]
 
 import os
-import sys
 from typing import TYPE_CHECKING, TypedDict
 
 from .diff_tree import tree_changes
@@ -38,14 +37,12 @@
     _tree_to_fs_path,
     build_file_from_blob,
     commit_tree,
+    get_path_element_validator,
     index_entry_from_stat,
     iter_fresh_objects,
     symlink,
     update_working_tree,
     validate_path,
-    validate_path_element_default,
-    validate_path_element_hfs,
-    validate_path_element_ntfs,
 )
 from .object_store import iter_tree_contents
 from .objects import S_IFGITLINK, Blob, Commit, ObjectID, TreeEntry
@@ -163,13 +160,7 @@
         # Get config for working directory update
         config = self._repo.get_config()
         honor_filemode = config.get_boolean(b"core", b"filemode", os.name != 
"nt")
-
-        if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
-            validate_path_element = validate_path_element_ntfs
-        elif config.get_boolean(b"core", b"core.protectHFS", sys.platform == 
"darwin"):
-            validate_path_element = validate_path_element_hfs
-        else:
-            validate_path_element = validate_path_element_default
+        validate_path_element = get_path_element_validator(config)
 
         if config.get_boolean(b"core", b"symlinks", True):
             symlink_fn = symlink
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/dulwich/worktree.py 
new/dulwich-dulwich-1.2.5/dulwich/worktree.py
--- old/dulwich-dulwich-1.2.4/dulwich/worktree.py       2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/dulwich/worktree.py       2026-05-28 
23:55:09.000000000 +0200
@@ -43,7 +43,6 @@
 import os
 import shutil
 import stat
-import sys
 import tempfile
 import time
 import warnings
@@ -807,10 +806,8 @@
         stacked_config = config
         from .index import (
             build_index_from_tree,
+            get_path_element_validator,
             symlink,
-            validate_path_element_default,
-            validate_path_element_hfs,
-            validate_path_element_ntfs,
         )
 
         if tree is None:
@@ -824,12 +821,7 @@
             tree = head.tree
         config = self._repo.get_config()
         honor_filemode = config.get_boolean(b"core", b"filemode", os.name != 
"nt")
-        if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
-            validate_path_element = validate_path_element_ntfs
-        elif config.get_boolean(b"core", b"core.protectHFS", sys.platform == 
"darwin"):
-            validate_path_element = validate_path_element_hfs
-        else:
-            validate_path_element = validate_path_element_default
+        validate_path_element = get_path_element_validator(config)
         if config.get_boolean(b"core", b"symlinks", True):
             symlink_fn = symlink
         else:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/tests/porcelain/__init__.py 
new/dulwich-dulwich-1.2.5/tests/porcelain/__init__.py
--- old/dulwich-dulwich-1.2.4/tests/porcelain/__init__.py       2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/tests/porcelain/__init__.py       2026-05-28 
23:55:09.000000000 +0200
@@ -3155,6 +3155,76 @@
         )
         self.assertEqual(patches, [])
 
+    def test_format_patch_subject_cannot_escape_outdir(self) -> None:
+        # A malicious commit subject must not be able to direct the
+        # generated patch file outside the requested output directory.
+        tree1 = Tree()
+        c1 = make_commit(tree=tree1, message=b"Initial commit")
+        self.repo.object_store.add_objects([(tree1, None), (c1, None)])
+
+        blob = Blob.from_string(b"data")
+        tree2 = Tree()
+        tree2.add(b"f.txt", 0o100644, blob.id)
+        # Subjects that try to traverse out of outdir via path separators
+        # or parent-directory components.
+        for evil in (b"x/../../x", b"x\\..\\..\\x", b"a:b"):
+            c2 = make_commit(
+                tree=tree2,
+                parents=[c1.id],
+                message=evil,
+            )
+            self.repo.object_store.add_objects(
+                [(blob, None), (tree2, None), (c2, None)]
+            )
+            self.repo[b"HEAD"] = c2.id
+
+            with tempfile.TemporaryDirectory() as tmpdir:
+                patches = porcelain.format_patch(
+                    self.repo.path,
+                    committish=c2.id,
+                    outdir=tmpdir,
+                )
+                self.assertEqual(len(patches), 1)
+                real_outdir = os.path.realpath(tmpdir)
+                real_patch = os.path.realpath(patches[0])
+                self.assertEqual(
+                    os.path.dirname(real_patch),
+                    real_outdir,
+                    f"patch for subject {evil!r} escaped outdir: {patches[0]}",
+                )
+                # Filename must not contain path separators or parent refs.
+                base = os.path.basename(patches[0])
+                self.assertNotIn("/", base)
+                self.assertNotIn("\\", base)
+                self.assertNotIn("..", base)
+
+    def test_format_patch_long_subject_truncated(self) -> None:
+        tree1 = Tree()
+        c1 = make_commit(tree=tree1, message=b"Initial commit")
+        self.repo.object_store.add_objects([(tree1, None), (c1, None)])
+
+        blob = Blob.from_string(b"data")
+        tree2 = Tree()
+        tree2.add(b"f.txt", 0o100644, blob.id)
+        c2 = make_commit(
+            tree=tree2,
+            parents=[c1.id],
+            message=b"a" * 500,
+        )
+        self.repo.object_store.add_objects([(blob, None), (tree2, None), (c2, 
None)])
+        self.repo[b"HEAD"] = c2.id
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            patches = porcelain.format_patch(
+                self.repo.path,
+                committish=c2.id,
+                outdir=tmpdir,
+            )
+            self.assertEqual(len(patches), 1)
+            # Whatever the cap is, an extremely long subject must not
+            # produce a pathologically long filename.
+            self.assertLess(len(os.path.basename(patches[0])), 100)
+
 
 class SymbolicRefTests(PorcelainTestCase):
     def test_set_wrong_symbolic_ref(self) -> None:
@@ -5575,6 +5645,106 @@
         with open(nested_submodule_file) as f:
             self.assertEqual(f.read(), "nested submodule content")
 
+    def _build_malicious_submodule_repo(self, submodule_path):
+        """Build a parent repo whose gitlink path is attacker-controlled.
+
+        Returns the path to a bare attacker submodule repository and commits a
+        matching ``.gitmodules`` plus tree gitlink entry into ``self.repo``,
+        both pointing at ``submodule_path``.
+        """
+        attacker_path = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, attacker_path)
+        attacker = Repo.init_bare(attacker_path, mkdir=False)
+        self.addCleanup(attacker.close)
+
+        payload = Blob.from_string(b"#!/bin/sh\necho PWNED\n")
+        attacker.object_store.add_object(payload)
+        tree = Tree()
+        tree.add(b"post-checkout", 0o100755, payload.id)
+        attacker.object_store.add_object(tree)
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = commit.committer = b"a <a@a>"
+        commit.author_time = commit.commit_time = 0
+        commit.author_timezone = commit.commit_timezone = 0
+        commit.message = b"payload"
+        attacker.object_store.add_object(commit)
+        attacker.refs[b"refs/heads/master"] = commit.id
+        attacker.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
+
+        gitmodules = (
+            b'[submodule "evil"]\n'
+            b"\tpath = " + submodule_path + b"\n"
+            b"\turl = " + attacker_path.encode() + b"\n"
+        )
+        # A real clone checks .gitmodules out into the work tree; write it
+        # directly so submodule_update can read it without a full checkout.
+        with open(os.path.join(self.repo.path, ".gitmodules"), "wb") as f:
+            f.write(gitmodules)
+        gmb = Blob.from_string(gitmodules)
+        self.repo.object_store.add_object(gmb)
+        vt = Tree()
+        vt.add(b".gitmodules", 0o100644, gmb.id)
+        vt.add(submodule_path, 0o160000, commit.id)
+        self.repo.object_store.add_object(vt)
+        vc = Commit()
+        vc.tree = vt.id
+        vc.author = vc.committer = b"a <a@a>"
+        vc.author_time = vc.commit_time = 0
+        vc.author_timezone = vc.commit_timezone = 0
+        vc.message = b"parent"
+        self.repo.object_store.add_object(vc)
+        self.repo.refs[b"refs/heads/master"] = vc.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
+        return attacker_path
+
+    def test_update_rejects_dotgit_path(self) -> None:
+        # A submodule path of .git/hooks would drop the attacker's tree
+        # into the parent's .git/hooks directory (CVE-style RCE via hooks).
+        self._build_malicious_submodule_repo(b".git/hooks")
+        self.assertRaises(
+            porcelain.Error,
+            porcelain.submodule_update,
+            self.repo,
+            init=True,
+        )
+        hook = os.path.join(self.repo.path, ".git", "hooks", "post-checkout")
+        self.assertFalse(os.path.exists(hook))
+
+    def test_update_rejects_parent_traversal_path(self) -> None:
+        self._build_malicious_submodule_repo(b"../escape")
+        self.assertRaises(
+            porcelain.Error,
+            porcelain.submodule_update,
+            self.repo,
+            init=True,
+        )
+
+    def test_check_submodule_path(self) -> None:
+        from dulwich.index import (
+            validate_path_element_default,
+            validate_path_element_ntfs,
+        )
+        from dulwich.porcelain.submodule import _check_submodule_path
+
+        # .git and .. components are rejected on every platform.
+        for bad in (b".git/hooks", b"..", b"a/../b", b"/abs", b".git"):
+            self.assertRaises(
+                porcelain.Error, _check_submodule_path, bad, 
validate_path_element_ntfs
+            )
+
+        # A path that is only unsafe on NTFS (a reserved device name) is
+        # refused under the default protectNTFS validator but, like git with
+        # core.protectNTFS=false, accepted by the default validator so an
+        # existing POSIX repository can still be updated.
+        self.assertRaises(
+            porcelain.Error, _check_submodule_path, b"aux", 
validate_path_element_ntfs
+        )
+        _check_submodule_path(b"aux", validate_path_element_default)
+
+        # Ordinary nested paths pass under either validator.
+        _check_submodule_path(b"libs/foo", validate_path_element_ntfs)
+
 
 class PushTests(PorcelainTestCase):
     def test_simple(self) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/tests/test_index.py 
new/dulwich-dulwich-1.2.5/tests/test_index.py
--- old/dulwich-dulwich-1.2.4/tests/test_index.py       2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/tests/test_index.py       2026-05-28 
23:55:09.000000000 +0200
@@ -622,6 +622,73 @@
             )
             self.assertFileContents(epath, b"d")
 
+    def test_ntfs_malicious_entries_dropped(self) -> None:
+        # A malicious tree authored on POSIX containing NTFS-hostile
+        # entries must not materialize any of them under the NTFS
+        # validator — the combination would let an attacker plant
+        # ``.git\hooks\pre-commit.exe`` or ``.git::$INDEX_ALLOCATION``
+        # on a Windows clone.
+        from dulwich.index import validate_path_element_ntfs
+
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        with Repo.init(repo_dir) as repo:
+            hook = Blob.from_string(b"malicious hook")
+            escape = Blob.from_string(b"outside payload")
+            shortname = Blob.from_string(b"masquerading as .git")
+            ads = Blob.from_string(b"alternate data stream payload")
+            benign = Blob.from_string(b"ok")
+
+            tree = Tree()
+            tree[b".git\\hooks\\pre-commit.exe"] = (
+                stat.S_IFREG | 0o755,
+                hook.id,
+            )
+            tree[b"..\\outside.txt"] = (stat.S_IFREG | 0o644, escape.id)
+            tree[b"git~1"] = (stat.S_IFREG | 0o644, shortname.id)
+            tree[b".git::$INDEX_ALLOCATION"] = (
+                stat.S_IFREG | 0o644,
+                ads.id,
+            )
+            tree[b"ok.txt"] = (stat.S_IFREG | 0o644, benign.id)
+
+            repo.object_store.add_objects(
+                [(o, None) for o in [hook, escape, shortname, ads, benign, 
tree]]
+            )
+
+            build_index_from_tree(
+                repo.path,
+                repo.index_path(),
+                repo.object_store,
+                tree.id,
+                validate_path_element=validate_path_element_ntfs,
+            )
+
+            index = repo.open_index()
+            self.assertEqual(list(index), [b"ok.txt"])
+
+            # Nothing written under the literal paths (the POSIX form)
+            # or under `.git/` (the Windows decomposition of `\`).
+            self.assertFalse(
+                os.path.exists(os.path.join(repo.path, 
".git\\hooks\\pre-commit.exe"))
+            )
+            self.assertFalse(
+                os.path.exists(
+                    os.path.join(repo.path, ".git", "hooks", "pre-commit.exe")
+                )
+            )
+            # ``git~1`` and ``.git::$INDEX_ALLOCATION`` would resolve
+            # against the existing ``.git`` directory on NTFS (8.3
+            # short-name and alternate-data-stream resolution), so
+            # ``os.path.exists`` can return true even when nothing was
+            # materialized as a literal entry. Check the directory
+            # listing instead.
+            work_tree_entries = os.listdir(repo.path)
+            self.assertNotIn("git~1", work_tree_entries)
+            self.assertNotIn(".git::$INDEX_ALLOCATION", work_tree_entries)
+            # Nothing escaped the work tree either.
+            self.assertNotIn("outside.txt", 
os.listdir(os.path.dirname(repo.path)))
+
     def test_nonempty(self) -> None:
         repo_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, repo_dir)
@@ -1466,6 +1533,97 @@
         self.assertTrue(validate_path_element_hfs(b".g\xc3\xaft"))  # .gït
         self.assertTrue(validate_path_element_hfs(b"git"))  # git without dot
 
+    def test_ntfs_rejects_backslash(self) -> None:
+        # A backslash is a path separator on Windows, so a tree entry
+        # containing one would materialize as nested directories and
+        # let an attacker plant ``.git\hooks\pre-commit`` or escape
+        # the work tree with ``..\outside``.
+        
self.assertFalse(validate_path_element_ntfs(b".git\\hooks\\pre-commit"))
+        self.assertFalse(validate_path_element_ntfs(b"..\\outside"))
+        self.assertFalse(validate_path_element_ntfs(b"a\\b"))
+        self.assertFalse(validate_path_element_ntfs(b"foo\\"))
+
+    def test_non_ntfs_validators_accept_backslash(self) -> None:
+        # On POSIX/HFS a backslash is a valid filename byte. The
+        # protection is gated on the NTFS validator (selected by
+        # core.protectNTFS), so the other validators still accept it.
+        self.assertTrue(validate_path_element_default(b"a\\b"))
+        self.assertTrue(validate_path_element_hfs(b"a\\b"))
+
+    def test_ntfs_rejects_all_short_name_variants(self) -> None:
+        # Git's is_ntfs_dotgit rejects any ``git~<digits>`` 8.3
+        # short-name form; previously only the literal ``git~1`` was
+        # checked.
+        for name in (b"git~1", b"git~2", b"git~10", b"GIT~1", b"gIt~3"):
+            self.assertFalse(
+                validate_path_element_ntfs(name),
+                f"{name!r} should be rejected on NTFS",
+            )
+        # Trailing ``.``/space is stripped by NTFS — same names.
+        self.assertFalse(validate_path_element_ntfs(b"git~1."))
+        self.assertFalse(validate_path_element_ntfs(b"git~1 "))
+        # Names that merely contain ``git~`` are still accepted.
+        self.assertTrue(validate_path_element_ntfs(b"git~foo"))
+        self.assertTrue(validate_path_element_ntfs(b"mygit~1"))
+
+    def test_ntfs_rejects_alternate_data_stream(self) -> None:
+        # NTFS alternate data streams are addressed as ``name:stream``;
+        # a ``:`` anywhere in an element can smuggle a write to
+        # ``.git::$INDEX_ALLOCATION`` etc.
+        
self.assertFalse(validate_path_element_ntfs(b".git::$INDEX_ALLOCATION"))
+        self.assertFalse(validate_path_element_ntfs(b".git:evil"))
+        self.assertFalse(validate_path_element_ntfs(b"foo:bar"))
+
+    def test_ntfs_rejects_reserved_device_names(self) -> None:
+        # CON, PRN, AUX, NUL and COM1..9 / LPT1..9 are reserved
+        # devices on Windows. Opening them resolves to the device
+        # rather than a disk file, with or without an extension and
+        # regardless of case.
+        for name in (
+            b"NUL",
+            b"nul",
+            b"NuL",
+            b"CON",
+            b"PRN",
+            b"AUX",
+            b"COM1",
+            b"COM9",
+            b"LPT1",
+            b"LPT9",
+        ):
+            self.assertFalse(
+                validate_path_element_ntfs(name),
+                f"{name!r} should be rejected on NTFS",
+            )
+
+    def test_ntfs_rejects_reserved_device_names_with_extension(self) -> None:
+        # Extensions do not make a reserved name safe on Windows —
+        # ``NUL.txt`` still opens the NUL device.
+        self.assertFalse(validate_path_element_ntfs(b"NUL.txt"))
+        self.assertFalse(validate_path_element_ntfs(b"aux.foo"))
+        self.assertFalse(validate_path_element_ntfs(b"COM1.bar"))
+        # Multiple extensions still match the stem.
+        self.assertFalse(validate_path_element_ntfs(b"nul.tar.gz"))
+        # Trailing dots/spaces are stripped by NTFS before resolution.
+        self.assertFalse(validate_path_element_ntfs(b"NUL."))
+        self.assertFalse(validate_path_element_ntfs(b"NUL "))
+        self.assertFalse(validate_path_element_ntfs(b"NUL ..."))
+        # A trailing space on the stem itself is also stripped, so
+        # ``NUL .txt`` still resolves to the NUL device.
+        self.assertFalse(validate_path_element_ntfs(b"NUL .txt"))
+
+    def test_ntfs_accepts_names_that_only_resemble_devices(self) -> None:
+        # Only the exact reserved names are devices; longer names
+        # that merely start with one of them are fine.
+        self.assertTrue(validate_path_element_ntfs(b"null"))
+        self.assertTrue(validate_path_element_ntfs(b"console"))
+        self.assertTrue(validate_path_element_ntfs(b"prnt"))
+        self.assertTrue(validate_path_element_ntfs(b"myaux"))
+        # COM0/LPT0 and COM10+ are not in the reserved range.
+        self.assertTrue(validate_path_element_ntfs(b"com0"))
+        self.assertTrue(validate_path_element_ntfs(b"com10"))
+        self.assertTrue(validate_path_element_ntfs(b"lpt0"))
+
 
 class TestDecodeUTF8WithFallback(TestCase):
     """Tests for the xutftowcsn-style lossy UTF-8 decoder."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/tests/test_merge_drivers.py 
new/dulwich-dulwich-1.2.5/tests/test_merge_drivers.py
--- old/dulwich-dulwich-1.2.4/tests/test_merge_drivers.py       2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/tests/test_merge_drivers.py       2026-05-28 
23:55:09.000000000 +0200
@@ -181,9 +181,10 @@
 
         result, success = driver.merge(b"a", b"b", b"c", marker_size=15)
 
-        # Expect different line endings on Windows vs Unix
+        # On Windows the value is wrapped in double quotes for cmd.exe and
+        # echo prints them literally; POSIX shells strip the shlex quoting.
         if sys.platform == "win32":
-            expected = b"marker size: 15 \r\n"
+            expected = b'marker size: "15" \r\n'
         else:
             expected = b"marker size: 15\n"
         self.assertEqual(result, expected)
@@ -197,14 +198,43 @@
 
         result, success = driver.merge(b"a", b"b", b"c", path="dir/file.xml")
 
-        # Expect different line endings on Windows vs Unix
+        # On Windows the value is wrapped in double quotes for cmd.exe and
+        # echo prints them literally; POSIX shells strip the shlex quoting.
         if sys.platform == "win32":
-            expected = b"path: dir/file.xml \r\n"
+            expected = b'path: "dir/file.xml" \r\n'
         else:
             expected = b"path: dir/file.xml\n"
         self.assertEqual(result, expected)
         self.assertTrue(success)
 
+    def test_merge_path_with_shell_metacharacters_is_not_injected(self):
+        """Malicious paths must not be able to inject extra shell commands.
+
+        Regression test for command injection via the %P placeholder
+        (path comes from a git tree and is therefore attacker-controllable
+        when merging an untrusted branch).
+        """
+        import os
+        import tempfile
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            sentinel = os.path.join(tmpdir, "pwned")
+            # Path that would, without proper quoting, terminate the echo
+            # and run a separate command creating the sentinel file.
+            malicious_path = f"x; touch {sentinel} #"
+
+            command = "echo %P > %A"
+            driver = ProcessMergeDriver(command, "injectable")
+
+            result, _ = driver.merge(b"a", b"b", b"c", path=malicious_path)
+
+            self.assertFalse(
+                os.path.exists(sentinel),
+                "Merge driver executed injected command - path was not 
shell-quoted",
+            )
+            # The literal path should appear in the output instead.
+            self.assertIn(b"x; touch", result)
+
 
 class MergeBlobsWithDriversTests(unittest.TestCase):
     """Tests for merge_blobs with merge drivers."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/tests/test_object_store.py 
new/dulwich-dulwich-1.2.5/tests/test_object_store.py
--- old/dulwich-dulwich-1.2.4/tests/test_object_store.py        2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/tests/test_object_store.py        2026-05-28 
23:55:09.000000000 +0200
@@ -29,7 +29,7 @@
 from contextlib import closing
 from io import BytesIO
 
-from dulwich.errors import NotTreeError
+from dulwich.errors import NotTreeError, ObjectFormatException
 from dulwich.index import commit_tree
 from dulwich.object_format import DEFAULT_OBJECT_FORMAT
 from dulwich.object_store import (
@@ -37,6 +37,7 @@
     MemoryObjectStore,
     ObjectStoreGraphWalker,
     OverlayObjectStore,
+    PackInputTooLarge,
     commit_tree_changes,
     read_packs_file,
     tree_lookup_path,
@@ -380,8 +381,6 @@
         # be ingested: MemoryObjectStore and ``git fsck`` already reject
         # such objects, so DiskObjectStore must too. Otherwise a malicious
         # remote can poison the repository.
-        from dulwich.errors import ObjectFormatException
-
         o = DiskObjectStore(self.store_dir)
         self.addCleanup(o.close)
         f, commit, abort = o.add_pack()
@@ -397,6 +396,27 @@
         # No pack/index files should have been left behind.
         self.assertEqual([], os.listdir(o.pack_dir))
 
+    def test_add_thin_pack_max_input_size(self) -> None:
+        """Bounding wire input rejects packs exceeding the cap.
+
+        Mirrors git's ``receive.maxInputSize`` semantics.
+        """
+        o = DiskObjectStore(self.store_dir)
+        self.addCleanup(o.close)
+
+        blob = make_object(Blob, data=b"yummy data")
+        o.add_object(blob)
+
+        f = BytesIO()
+        build_pack(
+            f,
+            [(REF_DELTA, (blob.id, b"more yummy data"))],
+            store=o,
+        )
+
+        with self.assertRaises(PackInputTooLarge):
+            o.add_thin_pack(f.read, None, max_input_size=8)
+
     def test_pack_index_version_config(self) -> None:
         # Test that pack.indexVersion configuration is respected
         from dulwich.config import ConfigDict
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/tests/test_patch.py 
new/dulwich-dulwich-1.2.5/tests/test_patch.py
--- old/dulwich-dulwich-1.2.4/tests/test_patch.py       2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/tests/test_patch.py       2026-05-28 
23:55:09.000000000 +0200
@@ -641,19 +641,64 @@
 
 
 class GetSummaryTests(TestCase):
-    def test_simple(self) -> None:
-        c = make_commit(
+    def _make_commit(self, message: bytes) -> Commit:
+        return make_commit(
             author=b"Jelmer <[email protected]>",
             committer=b"Jelmer <[email protected]>",
             author_time=1271350201,
             commit_time=1271350201,
             author_timezone=0,
             commit_timezone=0,
-            message=b"This is the first line\nAnd this is the second line.\n",
+            message=message,
             tree=Tree().id,
         )
+
+    def test_simple(self) -> None:
+        c = self._make_commit(
+            b"This is the first line\nAnd this is the second line.\n",
+        )
         self.assertEqual("This-is-the-first-line", get_summary(c))
 
+    def test_empty_message(self) -> None:
+        c = self._make_commit(b"")
+        self.assertEqual("", get_summary(c))
+
+    def test_path_traversal_unix(self) -> None:
+        c = self._make_commit(b"x/../../x\n")
+        # Path separators and consecutive dots must be sanitized so the
+        # result cannot escape the requested output directory.
+        summary = get_summary(c)
+        self.assertNotIn("/", summary)
+        self.assertNotIn("..", summary)
+        self.assertEqual("x-.-.-x", summary)
+
+    def test_path_traversal_windows(self) -> None:
+        c = self._make_commit(b"x\\..\\..\\x\n")
+        summary = get_summary(c)
+        self.assertNotIn("\\", summary)
+        self.assertNotIn("..", summary)
+        self.assertEqual("x-.-.-x", summary)
+
+    def test_colon_sanitized(self) -> None:
+        c = self._make_commit(b"feature: do something\n")
+        summary = get_summary(c)
+        self.assertNotIn(":", summary)
+        self.assertEqual("feature-do-something", summary)
+
+    def test_long_subject_truncated(self) -> None:
+        c = self._make_commit(b"a" * 500 + b"\n")
+        summary = get_summary(c)
+        self.assertLessEqual(len(summary), 64)
+
+    def test_trailing_dots_and_dashes_stripped(self) -> None:
+        c = self._make_commit(b"hello...\n")
+        self.assertEqual("hello", get_summary(c))
+
+    def test_only_problematic_chars(self) -> None:
+        c = self._make_commit(b"/\\:..\n")
+        # After sanitization there is nothing usable left.
+        self.assertEqual("", get_summary(c))
+
 
 class DiffAlgorithmTests(TestCase):
     """Tests for diff algorithm selection."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/dulwich-dulwich-1.2.4/tests/test_worktree.py 
new/dulwich-dulwich-1.2.5/tests/test_worktree.py
--- old/dulwich-dulwich-1.2.4/tests/test_worktree.py    2026-05-21 
21:19:15.000000000 +0200
+++ new/dulwich-dulwich-1.2.5/tests/test_worktree.py    2026-05-28 
23:55:09.000000000 +0200
@@ -30,6 +30,7 @@
 from dulwich.errors import CommitError
 from dulwich.index import get_unstaged_changes as _get_unstaged_changes
 from dulwich.object_store import tree_lookup_path
+from dulwich.objects import Blob, Tree
 from dulwich.repo import Repo
 from dulwich.worktree import (
     WorkTree,
@@ -319,6 +320,64 @@
             contents = f.read()
         self.assertEqual(b"contents of file a", contents)
 
+    def test_reset_index_honors_protectNTFS_config(self):
+        """core.protectNTFS=true must select the NTFS path-element validator.
+
+        The option name read from config must be ``protectNTFS`` (as
+        documented by git-config); the earlier ``core.protectNTFS``
+        form never matched a real config key and silently fell back to
+        the platform default.
+        """
+        # Set protectNTFS on (overriding the POSIX default of False).
+        config = self.repo.get_config()
+        config.set((b"core",), b"protectNTFS", b"true")
+        config.write_to_path()
+
+        # Craft a tree that contains a name the NTFS validator
+        # rejects (git~1, an 8.3 short-name alias for .git).
+        evil = Blob.from_string(b"evil")
+        good = Blob.from_string(b"ok")
+        tree = Tree()
+        tree[b"git~1"] = (stat.S_IFREG | 0o644, evil.id)
+        tree[b"ok.txt"] = (stat.S_IFREG | 0o644, good.id)
+        self.repo.object_store.add_objects([(evil, None), (good, None), (tree, 
None)])
+
+        self.worktree.reset_index(tree.id)
+
+        # git~1 was dropped by the NTFS validator; ok.txt survived. On NTFS
+        # the path "git~1" is the 8.3 short-name alias of the real .git
+        # directory, so a bare os.path.exists() check is always true there;
+        # assert instead that no regular file carrying the evil blob was
+        # materialized.
+        evil_path = os.path.join(self.repo.path, "git~1")
+        if os.path.isfile(evil_path):
+            with open(evil_path, "rb") as f:
+                self.assertNotEqual(b"evil", f.read())
+        self.assertTrue(os.path.exists(os.path.join(self.repo.path, "ok.txt")))
+
+    def test_reset_index_defaults_to_protectNTFS(self):
+        """core.protectNTFS defaults to True on every platform.
+
+        A tree authored on POSIX can still be cloned on Windows
+        later, so the NTFS validator must be on by default
+        regardless of os.name (matching Git's PROTECT_NTFS_DEFAULT=1).
+        """
+        # No core.protectNTFS set — rely on the built-in default.
+        evil = Blob.from_string(b"evil")
+        good = Blob.from_string(b"ok")
+        tree = Tree()
+        tree[b"git~1"] = (stat.S_IFREG | 0o644, evil.id)
+        tree[b"ok.txt"] = (stat.S_IFREG | 0o644, good.id)
+        self.repo.object_store.add_objects([(evil, None), (good, None), (tree, 
None)])
+
+        self.worktree.reset_index(tree.id)
+
+        evil_path = os.path.join(self.repo.path, "git~1")
+        if os.path.isfile(evil_path):
+            with open(evil_path, "rb") as f:
+                self.assertNotEqual(b"evil", f.read())
+        self.assertTrue(os.path.exists(os.path.join(self.repo.path, "ok.txt")))
+
 
 class WorkTreeSparseCheckoutTests(WorkTreeTestCase):
     """Tests for WorkTree sparse checkout operations."""

Reply via email to