diff --git a/src/scmrepo/git/credentials.py b/src/scmrepo/git/credentials.py index 0d775cd0..fa295852 100644 --- a/src/scmrepo/git/credentials.py +++ b/src/scmrepo/git/credentials.py @@ -173,11 +173,29 @@ def get(self, credential: "Credential", **kwargs) -> "Credential": if res.stderr: logger.debug(res.stderr) - credentials = {} + credentials: dict[str, Any] = {} for line in res.stdout.splitlines(): try: key, value = line.split("=", maxsplit=1) - credentials[key] = value + # Only include credential values that are used in the Credential + # constructor. + # Other values may be returned by the subprocess, but they must be + # ignored. + # e.g. osxkeychain credential helper >= 2.46.0 can return + # `capability[]` and `state`) + if key in [ + "protocol", + "host", + "path", + "username", + "password", + "password_expiry_utc", + "url", + ]: + # Garbage bytes were output from git-credential-osxkeychain from + # 2.45.0 to 2.47.0: + # https://github.com/git/git/commit/6c3c451fb6e1c3ca83f74e63079d4d0af01b2d69 + credentials[key] = _strip_garbage_bytes(value) except ValueError: continue if not credentials: @@ -265,6 +283,19 @@ def get_matching_commands( ) +def _strip_garbage_bytes(s: str) -> str: + """ + Garbage (random) bytes were output from git-credential-osxkeychain from + 2.45.0 to 2.47.0 so must be removed. + https://github.com/git/git/commit/6c3c451fb6e1c3ca83f74e63079d4d0af01b2d69 + :param s: string that might contain garbage/random bytes + :return str: The string with the garbage bytes removed + """ + # Assume that any garbage bytes begin with a 0-byte + zero = s.find(chr(0)) + return s[0:zero] if zero >= 0 else s + + class _CredentialKey(NamedTuple): protocol: str host: Optional[str] diff --git a/tests/test_credentials.py b/tests/test_credentials.py index c53a2aec..0c50396f 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -1,5 +1,6 @@ import io import os +import random import pytest @@ -45,6 +46,45 @@ def test_subprocess_get_use_http_path(git_helper, mocker): assert creds == Credential(username="foo", password="bar") +def test_subprocess_ignore_unexpected_credential_keys(git_helper, mocker): + git_helper.use_http_path = True + run = mocker.patch( + "subprocess.run", + # Simulate git-credential-osxkeychain (version >=2.45) + return_value=mocker.Mock( + stdout="username=foo\npassword=bar\ncapability[]=state\nstate[]=osxkeychain:seen=1" + ), + ) + creds = git_helper.get(Credential(protocol="https", host="foo.com", path="foo.git")) + assert run.call_args.args[0] == ["git-credential-foo", "get"] + assert ( + run.call_args.kwargs.get("input") + == "protocol=https\nhost=foo.com\npath=foo.git\n" + ) + assert creds == Credential(username="foo", password="bar") + + +def test_subprocess_strip_trailing_garbage_bytes(git_helper, mocker): + """Garbage bytes were output from git-credential-osxkeychain from 2.45.0 to 2.47.0 + so must be removed + https://github.com/git/git/commit/6c3c451fb6e1c3ca83f74e63079d4d0af01b2d69""" + git_helper.use_http_path = True + run = mocker.patch( + "subprocess.run", + # Simulate git-credential-osxkeychain (version 2.45), assuming initial 0-byte + return_value=mocker.Mock( + stdout=f"username=foo\npassword=bar{chr(0)}{random.randbytes(15)}" + ), + ) + creds = git_helper.get(Credential(protocol="https", host="foo.com", path="foo.git")) + assert run.call_args.args[0] == ["git-credential-foo", "get"] + assert ( + run.call_args.kwargs.get("input") + == "protocol=https\nhost=foo.com\npath=foo.git\n" + ) + assert creds == Credential(username="foo", password="bar") + + def test_subprocess_get_failed(git_helper, mocker): from subprocess import CalledProcessError