Skip to content

Commit 7df1eb4

Browse files
raywcmnaorpeled
andauthored
fix(gitea): tolerate binary file payloads (#2380) (#2440)
* fix(gitea): tolerate binary file payloads * fix(gitea): tolerate binary file payloads * fix(gitea): preserve non-UTF-8 text via decode_if_bytes fallback Addresses Qodo review on #2440: get_file_content returned '' on any UnicodeDecodeError, so legitimate non-UTF-8 text (e.g. UTF-16) was indistinguishable from an empty file and lost downstream. Decode via the shared decode_if_bytes fallback chain instead, which preserves such text and still tolerates binary payloads (filtered downstream by extension). Update tests to assert text preservation and non-crashing binary handling. --------- Co-authored-by: Wang ChengMeng <79295644+cmw-creator@users.noreply.github.com> Co-authored-by: naorpeled <me@naor.dev>
1 parent 94f3e63 commit 7df1eb4

2 files changed

Lines changed: 107 additions & 4 deletions

File tree

pr_agent/git_providers/gitea_provider.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from giteapy.rest import ApiException
77

88
from pr_agent.algo.file_filter import filter_ignored
9+
from pr_agent.algo.git_patch_processing import decode_if_bytes
910
from pr_agent.algo.language_handler import is_valid_file
1011
from pr_agent.algo.types import EDIT_TYPE
1112
from pr_agent.algo.utils import (clip_tokens,
@@ -819,10 +820,10 @@ def get_pull_request_diff(self, owner: str, repo: str, pr_number: int) -> str:
819820

820821
if hasattr(response, 'data'):
821822
raw_data = response.data.read()
822-
return raw_data.decode('utf-8')
823+
return raw_data.decode('utf-8', errors='replace')
823824
elif isinstance(response, tuple):
824825
raw_data = response[0].read()
825-
return raw_data.decode('utf-8')
826+
return raw_data.decode('utf-8', errors='replace')
826827
else:
827828
error_msg = f"Unexpected response format received from API: {type(response)}"
828829
self.logger.error(error_msg)
@@ -940,12 +941,17 @@ def get_file_content(self, owner: str, repo: str, commit_sha: str, filepath: str
940941
auth_settings=['AuthorizationHeaderToken']
941942
)
942943

944+
# Decode via the shared fallback chain (utf-8, then iso-8859-1/latin-1/
945+
# ascii/utf-16) so legitimate non-UTF-8 *text* (e.g. UTF-16) is preserved
946+
# rather than dropped, while binary payloads no longer crash the provider.
947+
# decode_if_bytes returns "" only if every encoding fails; binary files are
948+
# filtered downstream by extension (should_skip_patch).
943949
if hasattr(response, 'data'):
944950
raw_data = response.data.read()
945-
return raw_data.decode('utf-8')
951+
return decode_if_bytes(raw_data)
946952
elif isinstance(response, tuple):
947953
raw_data = response[0].read()
948-
return raw_data.decode('utf-8')
954+
return decode_if_bytes(raw_data)
949955

950956
return ""
951957

tests/unittest/test_gitea_provider.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,103 @@ def call_api_side_effect(path, method, **kwargs):
104104
assert args[0] == '/repos/owner/repo/pulls/123/commits'
105105
assert kwargs.get('auth_settings') == ['AuthorizationHeaderToken']
106106

107+
108+
@patch('pr_agent.git_providers.gitea_provider.get_settings')
109+
@patch('pr_agent.git_providers.gitea_provider.giteapy.ApiClient')
110+
def test_gitea_provider_preserves_non_utf8_text_file_content(self, mock_api_client_cls, mock_get_settings):
111+
# Regression for the Qodo review on #2440: non-UTF-8 *text* (e.g. UTF-16)
112+
# must not be dropped to "" (which is indistinguishable from an empty file
113+
# and loses real content downstream). It is decoded via the shared
114+
# decode_if_bytes fallback chain instead of crashing or returning "".
115+
settings = MagicMock()
116+
settings.get.side_effect = lambda k, d=None: {
117+
'GITEA.URL': 'https://gitea.example.com',
118+
'GITEA.PERSONAL_ACCESS_TOKEN': 'test-token',
119+
'GITEA.REPO_SETTING': None,
120+
'GITEA.SKIP_SSL_VERIFICATION': False,
121+
'GITEA.SSL_CA_CERT': None
122+
}.get(k, d)
123+
mock_get_settings.return_value = settings
124+
125+
mock_api_client = mock_api_client_cls.return_value
126+
mock_api_client.configuration.api_key = {'Authorization': 'token test-token'}
127+
mock_resp = MagicMock()
128+
# UTF-16-LE encoded text — not valid UTF-8, but legitimate text content.
129+
mock_resp.data = BytesIO("hello world".encode("utf-16"))
130+
mock_api_client.call_api.return_value = mock_resp
131+
132+
from pr_agent.git_providers.gitea_provider import RepoApi
133+
134+
repo_api = RepoApi(mock_api_client)
135+
136+
content = repo_api.get_file_content('owner', 'repo', 'sha1', 'notes.txt')
137+
assert content != '', "non-UTF-8 text must not be dropped to an empty string"
138+
assert all(ch in content for ch in "hello world"), "the underlying text should survive the fallback decode"
139+
args, kwargs = mock_api_client.call_api.call_args
140+
assert args[0] == '/repos/owner/repo/raw/notes.txt'
141+
assert kwargs.get('query_params') == [('ref', 'sha1')]
142+
assert kwargs.get('auth_settings') == ['AuthorizationHeaderToken']
143+
144+
@patch('pr_agent.git_providers.gitea_provider.get_settings')
145+
@patch('pr_agent.git_providers.gitea_provider.giteapy.ApiClient')
146+
def test_gitea_provider_does_not_crash_on_binary_file_content(self, mock_api_client_cls, mock_get_settings):
147+
# The original #2380 crash path: raw binary bytes must not raise
148+
# UnicodeDecodeError. decode_if_bytes yields a best-effort string; binary
149+
# files are filtered downstream by extension, so this only needs to not crash.
150+
settings = MagicMock()
151+
settings.get.side_effect = lambda k, d=None: {
152+
'GITEA.URL': 'https://gitea.example.com',
153+
'GITEA.PERSONAL_ACCESS_TOKEN': 'test-token',
154+
'GITEA.REPO_SETTING': None,
155+
'GITEA.SKIP_SSL_VERIFICATION': False,
156+
'GITEA.SSL_CA_CERT': None
157+
}.get(k, d)
158+
mock_get_settings.return_value = settings
159+
160+
mock_api_client = mock_api_client_cls.return_value
161+
mock_api_client.configuration.api_key = {'Authorization': 'token test-token'}
162+
mock_resp = MagicMock()
163+
mock_resp.data = BytesIO(b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01') # JPEG header bytes
164+
mock_api_client.call_api.return_value = mock_resp
165+
166+
from pr_agent.git_providers.gitea_provider import RepoApi
167+
168+
repo_api = RepoApi(mock_api_client)
169+
170+
# Must not raise; result is a string (content filtered by extension downstream).
171+
assert isinstance(repo_api.get_file_content('owner', 'repo', 'sha1', 'assets/image.webp'), str)
172+
173+
174+
@patch('pr_agent.git_providers.gitea_provider.get_settings')
175+
@patch('pr_agent.git_providers.gitea_provider.giteapy.ApiClient')
176+
def test_gitea_provider_decodes_non_utf8_diff_with_replacement(self, mock_api_client_cls, mock_get_settings):
177+
settings = MagicMock()
178+
settings.get.side_effect = lambda k, d=None: {
179+
'GITEA.URL': 'https://gitea.example.com',
180+
'GITEA.PERSONAL_ACCESS_TOKEN': 'test-token',
181+
'GITEA.REPO_SETTING': None,
182+
'GITEA.SKIP_SSL_VERIFICATION': False,
183+
'GITEA.SSL_CA_CERT': None
184+
}.get(k, d)
185+
mock_get_settings.return_value = settings
186+
187+
mock_api_client = mock_api_client_cls.return_value
188+
mock_api_client.configuration.api_key = {'Authorization': 'token test-token'}
189+
mock_resp = MagicMock()
190+
mock_resp.data = BytesIO(b'diff --git a/image.png b/image.webp\n+' + bytes([0xff]) + b'binary')
191+
mock_api_client.call_api.return_value = mock_resp
192+
193+
from pr_agent.git_providers.gitea_provider import RepoApi
194+
195+
repo_api = RepoApi(mock_api_client)
196+
197+
diff = repo_api.get_pull_request_diff('owner', 'repo', 123)
198+
199+
assert 'diff --git a/image.png b/image.webp' in diff
200+
assert '�' in diff
201+
args, kwargs = mock_api_client.call_api.call_args
202+
assert args[0] == '/repos/owner/repo/pulls/123.diff'
203+
assert kwargs.get('auth_settings') == ['AuthorizationHeaderToken']
107204
def test_get_repo_settings_returns_bytes(self):
108205
"""Regression for #2347: get_repo_settings must return bytes so that
109206
utils.apply_repo_settings can os.write() it and later .decode() it. The

0 commit comments

Comments
 (0)