From e9935cbcf62f7d2f83b9234f6db4e41a3ca480cb Mon Sep 17 00:00:00 2001 From: Jakub Jelen Date: Fri, 30 Aug 2024 12:01:52 +0200 Subject: [PATCH 1/8] sftp: Fix downloading files Fixes: #341 Signed-off-by: Jakub Jelen --- src/pylibsshext/sftp.pyx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pylibsshext/sftp.pyx b/src/pylibsshext/sftp.pyx index 6331c529d..98077a00b 100644 --- a/src/pylibsshext/sftp.pyx +++ b/src/pylibsshext/sftp.pyx @@ -100,7 +100,7 @@ cdef class SFTP: raise LibsshSFTPException("Reading data from remote file [%s] failed with error [%s]" % (remote_file, self._get_sftp_error_str())) - with open(local_file, 'wb+') as f: + with open(local_file, 'ab') as f: bytes_written = f.write(read_buffer[:file_data]) if bytes_written and file_data != bytes_written: sftp.sftp_close(rf) From 9001f9ae0f8dd61c5cca9442d6f8e77becc1be6f Mon Sep 17 00:00:00 2001 From: Jakub Jelen Date: Fri, 30 Aug 2024 11:20:00 +0200 Subject: [PATCH 2/8] tests: Reproducer for #341 Signed-off-by: Jakub Jelen --- tests/unit/sftp_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unit/sftp_test.py b/tests/unit/sftp_test.py index fc70c4512..88add8a56 100644 --- a/tests/unit/sftp_test.py +++ b/tests/unit/sftp_test.py @@ -2,6 +2,8 @@ """Tests suite for sftp.""" +import random +import string import uuid import pytest @@ -63,3 +65,29 @@ def test_get(dst_path, src_path, sftp_session, transmit_payload): """Check that SFTP file download works.""" sftp_session.get(str(src_path), str(dst_path)) assert dst_path.read_bytes() == transmit_payload + + +@pytest.fixture +def large_payload(): + """Generate a large 1025 byte (1024 + 1B) test payload.""" + payload_len = 1024 + 1 + random_bytes = [ord(random.choice(string.printable)) for _ in range(payload_len)] + return bytes(random_bytes) + + +@pytest.fixture +def src_path_large(tmp_path, large_payload): + """Return a remote path to a 1025 byte-sized file. + + The pylibssh chunk size is 1024 so the test needs a file that would + execute at least two loops. + """ + path = tmp_path / 'large.txt' + path.write_bytes(large_payload) + return path + + +def test_get_large(dst_path, src_path_large, sftp_session, large_payload): + """Check that SFTP can download large file.""" + sftp_session.get(str(src_path_large), str(dst_path)) + assert dst_path.read_bytes() == large_payload From 09df17e7d024c74490e6d8c45f13f38b7ecb0137 Mon Sep 17 00:00:00 2001 From: Jakub Jelen Date: Fri, 30 Aug 2024 11:23:57 +0200 Subject: [PATCH 3/8] tests: Symmetric reproducer for upload Signed-off-by: Jakub Jelen --- tests/unit/sftp_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/sftp_test.py b/tests/unit/sftp_test.py index 88add8a56..1572c0d66 100644 --- a/tests/unit/sftp_test.py +++ b/tests/unit/sftp_test.py @@ -87,6 +87,12 @@ def src_path_large(tmp_path, large_payload): return path +def test_put_large(dst_path, src_path_large, sftp_session, large_payload): + """Check that SFTP can upload large file.""" + sftp_session.put(str(src_path_large), str(dst_path)) + assert dst_path.read_bytes() == large_payload + + def test_get_large(dst_path, src_path_large, sftp_session, large_payload): """Check that SFTP can download large file.""" sftp_session.get(str(src_path_large), str(dst_path)) From d3678e767b280c37785868275584ab5b46e31337 Mon Sep 17 00:00:00 2001 From: Jakub Jelen Date: Fri, 15 Nov 2024 14:08:48 +0100 Subject: [PATCH 4/8] sftp: Fix overwriting existing files while downloading Thanks @kucharskim for the report and testing! Signed-off-by: Jakub Jelen --- src/pylibsshext/sftp.pyx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pylibsshext/sftp.pyx b/src/pylibsshext/sftp.pyx index 98077a00b..6220ad8ae 100644 --- a/src/pylibsshext/sftp.pyx +++ b/src/pylibsshext/sftp.pyx @@ -91,16 +91,16 @@ cdef class SFTP: if rf is NULL: raise LibsshSFTPException("Opening remote file [%s] for read failed with error [%s]" % (remote_file, self._get_sftp_error_str())) - while True: - file_data = sftp.sftp_read(rf, read_buffer, sizeof(char) * 1024) - if file_data == 0: - break - elif file_data < 0: - sftp.sftp_close(rf) - raise LibsshSFTPException("Reading data from remote file [%s] failed with error [%s]" - % (remote_file, self._get_sftp_error_str())) - - with open(local_file, 'ab') as f: + with open(local_file, 'wb') as f: + while True: + file_data = sftp.sftp_read(rf, read_buffer, sizeof(char) * 1024) + if file_data == 0: + break + elif file_data < 0: + sftp.sftp_close(rf) + raise LibsshSFTPException("Reading data from remote file [%s] failed with error [%s]" + % (remote_file, self._get_sftp_error_str())) + bytes_written = f.write(read_buffer[:file_data]) if bytes_written and file_data != bytes_written: sftp.sftp_close(rf) From 8e9addf4ec62c8ad8fb21f788abe3fa8bead1c2e Mon Sep 17 00:00:00 2001 From: Jakub Jelen Date: Fri, 15 Nov 2024 14:09:43 +0100 Subject: [PATCH 5/8] tests: Reproduer for overriding existing files Signed-off-by: Jakub Jelen --- tests/unit/sftp_test.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/sftp_test.py b/tests/unit/sftp_test.py index 1572c0d66..2b8cdae0f 100644 --- a/tests/unit/sftp_test.py +++ b/tests/unit/sftp_test.py @@ -50,6 +50,21 @@ def dst_path(file_paths_pair): return path +@pytest.fixture +def other_payload(): + """Generate a binary test payload.""" + uuid_name = uuid.uuid4() + return 'Original content: {name!s}'.format(name=uuid_name).encode() + + +@pytest.fixture +def pre_existing_dst_path(dst_path, other_payload): + """Return a data destination path.""" + dst_path.write_bytes(other_payload) + assert dst_path.exists() + return dst_path + + def test_make_sftp(sftp_session): """Smoke-test SFTP instance creation.""" assert sftp_session @@ -67,6 +82,12 @@ def test_get(dst_path, src_path, sftp_session, transmit_payload): assert dst_path.read_bytes() == transmit_payload +def test_get_existing(pre_existing_dst_path, src_path, sftp_session, transmit_payload): + """Check that SFTP file download works when target file exists.""" + sftp_session.get(str(src_path), str(pre_existing_dst_path)) + assert pre_existing_dst_path.read_bytes() == transmit_payload + + @pytest.fixture def large_payload(): """Generate a large 1025 byte (1024 + 1B) test payload.""" From eb90d06f7f1a9971dc0f205b23ac0611fc7b1471 Mon Sep 17 00:00:00 2001 From: Jakub Jelen Date: Fri, 15 Nov 2024 14:11:09 +0100 Subject: [PATCH 6/8] tests: Symetric test for upload to override existing file Signed-off-by: Jakub Jelen --- tests/unit/sftp_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/sftp_test.py b/tests/unit/sftp_test.py index 2b8cdae0f..aed295fd8 100644 --- a/tests/unit/sftp_test.py +++ b/tests/unit/sftp_test.py @@ -88,6 +88,12 @@ def test_get_existing(pre_existing_dst_path, src_path, sftp_session, transmit_pa assert pre_existing_dst_path.read_bytes() == transmit_payload +def test_put_existing(pre_existing_dst_path, src_path, sftp_session, transmit_payload): + """Check that SFTP file upload works when target file exists.""" + sftp_session.put(str(src_path), str(pre_existing_dst_path)) + assert pre_existing_dst_path.read_bytes() == transmit_payload + + @pytest.fixture def large_payload(): """Generate a large 1025 byte (1024 + 1B) test payload.""" From de78d7464d1ab68775787d774140718854efd3ce Mon Sep 17 00:00:00 2001 From: Jakub Jelen Date: Thu, 23 Jan 2025 19:19:17 +0100 Subject: [PATCH 7/8] tests: Simplify generating payload Signed-off-by: Jakub Jelen --- tests/unit/sftp_test.py | 52 ++++++++++++----------------------------- 1 file changed, 15 insertions(+), 37 deletions(-) diff --git a/tests/unit/sftp_test.py b/tests/unit/sftp_test.py index aed295fd8..ce1a28ffe 100644 --- a/tests/unit/sftp_test.py +++ b/tests/unit/sftp_test.py @@ -20,11 +20,21 @@ def sftp_session(ssh_client_session): del sftp_sess # noqa: WPS420 -@pytest.fixture -def transmit_payload(): - """Generate a binary test payload.""" - uuid_name = uuid.uuid4() - return 'Hello, {name!s}'.format(name=uuid_name).encode() +@pytest.fixture( + params=(32, 1024 + 1), + ids=('small-payload', 'large-payload'), +) +def transmit_payload(request: pytest.FixtureRequest) -> bytes: + """Generate binary test payloads of assorted sizes. + + The choice 32 is arbitrary small value. + + The choice 1024 + 1 is meant to be 1B larger than the chunk size used in + :file:`sftp.pyx` to make sure we excercise at least two rounds of reading/writing. + """ + payload_len = request.param + random_bytes = [ord(random.choice(string.printable)) for _ in range(payload_len)] + return bytes(random_bytes) @pytest.fixture @@ -92,35 +102,3 @@ def test_put_existing(pre_existing_dst_path, src_path, sftp_session, transmit_pa """Check that SFTP file upload works when target file exists.""" sftp_session.put(str(src_path), str(pre_existing_dst_path)) assert pre_existing_dst_path.read_bytes() == transmit_payload - - -@pytest.fixture -def large_payload(): - """Generate a large 1025 byte (1024 + 1B) test payload.""" - payload_len = 1024 + 1 - random_bytes = [ord(random.choice(string.printable)) for _ in range(payload_len)] - return bytes(random_bytes) - - -@pytest.fixture -def src_path_large(tmp_path, large_payload): - """Return a remote path to a 1025 byte-sized file. - - The pylibssh chunk size is 1024 so the test needs a file that would - execute at least two loops. - """ - path = tmp_path / 'large.txt' - path.write_bytes(large_payload) - return path - - -def test_put_large(dst_path, src_path_large, sftp_session, large_payload): - """Check that SFTP can upload large file.""" - sftp_session.put(str(src_path_large), str(dst_path)) - assert dst_path.read_bytes() == large_payload - - -def test_get_large(dst_path, src_path_large, sftp_session, large_payload): - """Check that SFTP can download large file.""" - sftp_session.get(str(src_path_large), str(dst_path)) - assert dst_path.read_bytes() == large_payload From 09bf9fe5bc7b66f62f5ac79cdf416c972593c77c Mon Sep 17 00:00:00 2001 From: Jakub Jelen Date: Fri, 30 Aug 2024 13:21:44 +0200 Subject: [PATCH 8/8] Add history fragment Signed-off-by: Jakub Jelen --- docs/changelog-fragments/638.bugfix.rst | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog-fragments/638.bugfix.rst diff --git a/docs/changelog-fragments/638.bugfix.rst b/docs/changelog-fragments/638.bugfix.rst new file mode 100644 index 000000000..ac1ff6077 --- /dev/null +++ b/docs/changelog-fragments/638.bugfix.rst @@ -0,0 +1,5 @@ +Fixed reading files over SFTP that go over the pre-defined chunk size. + +Prior to this change, the files could end up being corrupted, ending up with the last read chunk written to the file instead of the entire payload. + +-- by :user:`Jakuje`