diff --git a/ansible_collections/f5networks/f5_modules/plugins/module_utils/icontrol.py b/ansible_collections/f5networks/f5_modules/plugins/module_utils/icontrol.py index eba4baf69..5b40a431a 100644 --- a/ansible_collections/f5networks/f5_modules/plugins/module_utils/icontrol.py +++ b/ansible_collections/f5networks/f5_modules/plugins/module_utils/icontrol.py @@ -483,19 +483,27 @@ def upload_file(client, url, src, dest=None): Raises: F5ModuleError: Raised if ``retries`` limit is exceeded. """ - if isinstance(src, StringIO) or isinstance(src, BytesIO): + if isinstance(src, StringIO): + content_bytes = src.getvalue().encode('utf-8') + fileobj = BytesIO(content_bytes) + size = len(content_bytes) + is_file = False + elif isinstance(src, BytesIO): fileobj = src + fileobj.seek(0, os.SEEK_END) + size = fileobj.tell() + fileobj.seek(0) + is_file = False else: fileobj = open(src, 'rb') - - try: - size = os.stat(src).st_size - is_file = True - except TypeError: - src.seek(0, os.SEEK_END) - size = src.tell() - src.seek(0) - is_file = False + try: + size = os.stat(src).st_size + is_file = True + except TypeError: + src.seek(0, os.SEEK_END) + size = src.tell() + src.seek(0) + is_file = False # This appears to be the largest chunk size that iControlREST can handle. # diff --git a/ansible_collections/f5networks/f5_modules/tests/unit/module_utils/network/f5/test_icontrol_upload.py b/ansible_collections/f5networks/f5_modules/tests/unit/module_utils/network/f5/test_icontrol_upload.py index 14a543373..7882293e7 100644 --- a/ansible_collections/f5networks/f5_modules/tests/unit/module_utils/network/f5/test_icontrol_upload.py +++ b/ansible_collections/f5networks/f5_modules/tests/unit/module_utils/network/f5/test_icontrol_upload.py @@ -253,6 +253,35 @@ def test_multi_chunk_forced_small_chunks_final_chunk_accurate(self): ) ) + def test_stringio_multibyte_utf8_content_range_matches_actual_bytes(self): + """ + Verify StringIO containing multibyte UTF-8 characters encodes to bytes + so Content-Range total and chunk ranges match actual transmitted bytes. + """ + # Character count: 8 chars, but UTF-8 byte count: 10 bytes + # '\u00e9' is 2 bytes, '\u2014' is 3 bytes (3 ascii + 2 + 1 ascii + 3 + 1 ascii = 10 bytes) + content_str = u'abc\u00e9-\u2014x' + fileobj = StringIO(content_str) + + self.client.api.post.return_value = Mock(status=200) + result = upload_file(self.client, self.url, fileobj, 'cert.crt') + + assert result is True + assert self.client.api.post.call_count == 1 + + call_args = self.client.api.post.call_args + headers = call_args.kwargs.get('headers') or call_args[1]['headers'] + data = call_args.kwargs.get('data') or call_args[1]['data'] + + content_range = headers['Content-Range'] + range_part, total = content_range.split('/') + start, end = map(int, range_part.split('-')) + + expected_bytes = content_str.encode('utf-8') + assert int(total) == len(expected_bytes) + assert (end - start + 1) == len(data) + assert data == expected_bytes + if __name__ == '__main__': unittest.main()