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."""
