diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index b8150e93d..e14856e7e 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -45,7 +45,7 @@ jobs: - name: Install Cygwin uses: cygwin/cygwin-install-action@v6 with: - packages: git python39 python-pip-wheel python-setuptools-wheel python-wheel-wheel + packages: curl git python39 python-setuptools-wheel add-to-path: false # No need to change $PATH outside the Cygwin environment. - name: Arrange for verbose output @@ -73,12 +73,15 @@ jobs: - name: Set up virtual environment run: | + pip_wheel=/usr/share/python-wheels/pip-26.0.1-py3-none-any.whl + curl -fsSLo "$pip_wheel" https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl + python3.9 -c 'import hashlib, pathlib, sys; assert hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest() == sys.argv[2]' "$pip_wheel" bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b python3.9 -m venv .venv echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV" - name: Update PyPA packages run: | - python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel + python -m pip install -U 'setuptools; python_version<"3.12"' wheel - name: Install project and test dependencies run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75a29d6db..dfa5fc91e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: exclude: ^test/fixtures/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.0 + rev: v0.16.5 hooks: - id: ruff-check args: ["--fix"] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ee4ef9d95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# Commit messages + +Every commit must have a descriptive title and a substantive body. Title-only +commit messages are not acceptable. + +The body must explain the problem or motivation, what changed, and why the +chosen approach addresses it. Include relevant behavior before and after the +change, design decisions, limitations, and validation results. Scale the detail +to the change; do not add boilerplate or claim checks that were not run. + +Commit messages must stand on their own. Put the information needed to understand +and review the change in the commit body, even when it also appears in a pull +request description. PR and issue links may provide additional context, but must +not substitute for that explanation. diff --git a/VERSION b/VERSION index c29b32b56..4eb2ee669 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.61 +3.1.62 diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 6b06dd5bf..20bfaeae6 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,19 @@ Changelog ========= +3.1.62 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62 + 3.1.61 ====== diff --git a/git/config.py b/git/config.py index e7f64f7b5..aef881d2e 100644 --- a/git/config.py +++ b/git/config.py @@ -467,6 +467,45 @@ def string_decode(v: str) -> str: # END string_decode + def is_line_continuation(value: str) -> bool: + quoted = escaped = False + for char in value: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + quoted = not quoted + elif char in "#;" and not quoted: + return False + return escaped + + def parse_value(value: str) -> str: + parsed: List[str] = [] + whitespace: List[str] = [] + quoted = escaped = False + escapes = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"} + for char in value: + if escaped: + parsed.append(escapes.get(char, "\\" + char)) + escaped = False + continue + if char.isspace() and not quoted: + if parsed: + whitespace.append(char) + continue + if char in "#;" and not quoted: + break + parsed.extend(whitespace) + whitespace.clear() + if char == "\\": + escaped = True + elif char == '"': + quoted = not quoted + else: + parsed.append(char) + return "".join(parsed) + while True: # We assume to read binary! line = fp.readline().decode(defenc) @@ -513,7 +552,29 @@ def string_decode(v: str) -> str: if len(optval) < 2 or optval[0] != '"': # Does not open quoting. - pass + # A value ending in an odd number of backslashes + # continues on the next line, exactly as git does: the + # final backslash and the newline are removed and the + # next line is appended before the complete value is + # parsed. An even number means the last backslash is + # escaped and the value ends there. + continued = False + while True: + if not is_line_continuation(optval): + break + continuation = fp.readline() + if not continuation: + # Backslash at end of file: git drops it. + optval = optval[:-1] + break + lineno = lineno + 1 + joined = continuation.decode(defenc) + while joined.endswith("\n") or joined.endswith("\r"): + joined = joined[:-1] + optval = optval[:-1] + joined + continued = True + if continued: + optval = parse_value(optval) elif optval[-1] != '"': # Opens quoting and does not close: appears to start multi-line quoting. is_multi_line = True diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 39e912321..563b20a18 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -414,6 +414,18 @@ def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike: return path + @property + def abspath(self) -> PathLike: + root = self.repo.working_tree_dir + if root is None: + return super().abspath + path = root + for component in os.fspath(self._to_relative_path(self.repo, self.path)).split("/"): + path = join_path_native(path, component) + if osp.islink(path): + raise ValueError("Submodule checkout path %r contains a symbolic link" % self.path) + return path + @classmethod def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_abspath: PathLike) -> None: """Write a ``.git`` file containing a (preferably) relative path to the actual diff --git a/git/repo/base.py b/git/repo/base.py index 4ae48e17b..7039d0320 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -386,6 +386,10 @@ def __init__( # Let's not assume the option exists, although it should. pass + # A linked worktree is not bare even when its main repository is. + if self._bare and self._working_tree_dir and osp.isfile(osp.join(self.git_dir, "commondir")): + self._bare = False + # Adjust the working directory in case we are actually bare - we didn't know # that in the first place. if self._bare: diff --git a/test/test_config.py b/test/test_config.py index 28bb12043..3107d8074 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -143,6 +143,44 @@ def test_multi_line_config(self): ) self.assertEqual(len(config.sections()), 23) + def test_backslash_line_continuation(self): + """An unquoted value ending in a backslash continues on the next line, + exactly as git config parses it: the final backslash and the newline + are removed before the complete logical value is parsed.""" + cases = [ + (b"[a]\n\tk = line1\\\n line2\n", "line1 line2"), + (b"[a]\n\tk = one\\\n two\\\n three\n", "one two three"), + (b"[a]\n\tk = one\\\n two \n", "one two"), + (b"[a]\n\tk = one\\\n two ; ignored\n", "one two"), + (b'[a]\n\tk = one\\\n "two"\n', "one two"), + (b"[a]\n\tk = one\\\n two\\tthree\n", "one two\tthree"), + (b"[a]\n\tk = val\\\\\n next\n", "val\\\\"), + (b"[a]\n\tk = end\\\n", "end"), + (b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"), + ] + for content, expected in cases: + config_file = io.BytesIO(content) + config_file.name = "backslash_continuation.config" + config = GitConfigParser(config_file) + config.read() + section = "alias" if b"[alias]" in content else "a" + key = "co" if section == "alias" else "k" + self.assertEqual(config.get_value(section, key), expected) + + @with_rw_directory + def test_comment_backslash_does_not_continue_value(self, rw_dir): + config_path = osp.join(rw_dir, "config") + with open(config_path, "wb") as config_file: + config_file.write(b"[a]\n\tk = one\\\n two ; ignored \\\n\tx = two\n") + + with GitConfigParser(config_path, read_only=False) as config: + self.assertEqual(config.get_value("a", "k"), "one two") + self.assertEqual(config.get_value("a", "x"), "two") + config.set_value("a", "added", "three") + + with GitConfigParser(config_path) as config: + self.assertEqual(config.get_value("a", "x"), "two") + def test_config_value_with_trailing_new_line(self): config_content = b'[section-header]\nkey:"value\n"' config_file = io.BytesIO(config_content) diff --git a/test/test_repo.py b/test/test_repo.py index 12e572f52..b7b5718ff 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -1459,6 +1459,34 @@ def test_git_work_tree_dotgit(self, rw_dir, use_relative_paths=False): self.assertIsInstance(repo.heads["aaaaaaaa"], Head) + @with_rw_directory + def test_git_work_tree_from_bare_repo(self, rw_dir): + if Git().version_info[:3] < (2, 5, 1): + pytest.skip("worktree feature unsupported, needs git 2.5.1 or later") + + bare_repo = self.rorepo.clone(join_path_native(rw_dir, "bare_repo"), bare=True) + worktree_path = join_path_native(rw_dir, "worktree_repo") + if Git.is_cygwin(): + worktree_path = cygpath(worktree_path) + bare_repo.git.worktree("add", "--detach", worktree_path) + + repo = Repo(worktree_path) + + assert Git(worktree_path).rev_parse("--is-bare-repository") == "false" + assert not repo.bare + assert osp.samefile(repo.working_tree_dir, worktree_path) + + # Discovering from a subdirectory (as tools such as mkdocs plugins do) and then + # running a command with an absolute pathspec must use the worktree, not the + # bare common dir, as the working directory; 3.1.61 failed here with + # "is outside repository at '/.'" (#2223). + subdir = osp.join(worktree_path, "git", "repo") + sub_repo = Repo(subdir, search_parent_directories=True) + assert not sub_repo.bare + assert osp.samefile(sub_repo.working_dir, worktree_path) + expected = repo.git.log("-n", "1", "--format=%H", "--", "git/repo") + assert sub_repo.git.log("-n", "1", "--format=%H", "--", subdir) == expected + def test_git_work_tree_dotgit_relative(self): """Check that we find .git as a worktree file containing a relative path and find the worktree based on it.""" diff --git a/test/test_submodule.py b/test/test_submodule.py index d545cc8d5..ca9078aac 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1369,6 +1369,46 @@ class Repo: osp.join(Repo.working_tree_dir + "-other", "module"), ) + @with_rw_directory + def test_update_rejects_checkout_path_outside_parent(self, rwdir): + parent = git.Repo.init(osp.join(rwdir, "parent")) + submodule = Submodule( + parent, + Submodule.NULL_BIN_SHA, + name="module", + path=osp.join("..", "outside"), + url="unused", + ) + + with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("clone attempted")): + with pytest.raises(ValueError, match="is not in repository"): + submodule.update(init=True) + + @with_rw_directory + def test_update_rejects_checkout_path_through_symlink(self, rwdir): + parent = git.Repo.init(osp.join(rwdir, "parent")) + os.mkdir(osp.join(parent.working_tree_dir, "target")) + os.symlink("target", osp.join(parent.working_tree_dir, "link")) + submodule = Submodule( + parent, + Submodule.NULL_BIN_SHA, + name="module", + path=osp.join("link", "module"), + url="unused", + ) + + with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("clone attempted")): + with pytest.raises(ValueError, match="contains a symbolic link"): + submodule.update(init=True) + + @with_rw_directory + def test_update_rejects_checkout_path_at_parent_root(self, rwdir): + parent = git.Repo.init(osp.join(rwdir, "parent")) + submodule = Submodule(parent, Submodule.NULL_BIN_SHA, name="module", path=".", url="unused") + + with pytest.raises(ValueError, match="must not be the repository root"): + submodule.update(init=True) + @skipUnless(sys.platform == "win32", "Specifically for Windows.") @with_rw_directory def test_to_relative_path_windows_path_kinds(self, rwdir):