#!/usr/bin/python3 -OO # Copyright 2007-2025 by The SABnzbd-Team (sabnzbd.org) # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. """ tests.test_decoder- Testing functions in decoder.py """ import binascii import os import pytest from io import BytesIO from random import randint from unittest import mock import sabctools import sabnzbd.decoder as decoder from sabnzbd.nzb import Article def uu(data: bytes): """Uuencode data and insert a period if necessary""" line = binascii.b2a_uu(data).rstrip(b"\n") # Dot stuffing if line.startswith(b"."): return b"." + line return line LINES_DATA = [os.urandom(45) for _ in range(32)] VALID_UU_LINES = [uu(data) for data in LINES_DATA] END_DATA = os.urandom(randint(1, 45)) VALID_UU_END = [ uu(END_DATA), b"`", b"end", ] class TestUuDecoder: def _generate_msg_part( self, part: str, insert_empty_line: bool = True, insert_excess_empty_lines: bool = False, insert_headers: bool = False, insert_end: bool = True, insert_dot_stuffing_line: bool = False, begin_line: bytes = b"begin 644 My Favorite Open Source Movie.mkv", ): """Generate message parts. Part may be one of 'begin', 'middle', or 'end' for multipart messages, or 'single' for a singlepart message. All uu payload is taken from VALID_UU_*. Returns Article with a random id and lowest_partnum correctly set, socket-style raw data, and the expected result of uu decoding for the generated message. """ article_id = "test@host" + os.urandom(8).hex() + ".sab" article = Article(article_id, randint(4321, 54321), None) article.lowest_partnum = True if part in ("begin", "single") else False # Mock an nzf so results from hashing and filename handling can be stored article.nzf = mock.Mock() # Store the message data and the expected decoding result data = [] result = [] # Always start with the response code line data.append(b"222 0 <" + bytes(article_id, encoding="ascii") + b">") if insert_empty_line: # Only insert other headers if there's an empty line if insert_headers: data.extend([b"x-hoop: is uitgestelde teleurstelling", b"Another-Header: Sure"]) # Insert the empty line between response code and body data.append(b"") if insert_excess_empty_lines: data.extend([b"", b""]) # Insert uu data into the body if part in ("begin", "single"): data.append(begin_line) if part in ("begin", "middle", "single"): size = randint(4, len(VALID_UU_LINES) - 1) data.extend(VALID_UU_LINES[:size]) result.extend(LINES_DATA[:size]) if insert_dot_stuffing_line: data.append(uu(b"\0" * 14)) result.append(b"\0" * 14) if part in ("end", "single"): if insert_end: data.extend(VALID_UU_END) result.append(END_DATA) # Signal the end of the message with a dot on a line of its own data.append(b".\r\n") # Join the data with \r\n line endings, just like we get from socket reads data = b"\r\n".join(data) # Concatenate expected result result = b"".join(result) return article, bytearray(data), result @staticmethod def _response(raw_data: bytes) -> sabctools.NNTPResponse: dec = sabctools.Decoder(len(raw_data)) reader = BytesIO(raw_data) reader.readinto(dec) dec.process(len(raw_data)) return next(dec) @pytest.mark.parametrize( "raw_data", [ b"222 0 \r\n.\r\n", b"222 0 \r\n\r\n.\r\n", b"222 0 \r\nfoobar\r\n.\r\n", # Plenty of list items, but (too) few actual lines b"222 0 \r\nX-Too-Short: yup\r\n.\r\n", ], ) def test_short_data(self, raw_data): with pytest.raises(decoder.BadUu): assert decoder.decode_uu(Article("foo@bar", 4321, None), self._response(raw_data)) @pytest.mark.parametrize( "raw_data", [ b"222 0 \r\n\r\n", # Missing altogether b"222 0 \r\n\r\nbeing\r\n", # Typo in 'begin' b"222 0 \r\n\r\nx-header: begin 644 foobar\r\n", # Not at start of the line b"666 0 \r\nbegin\r\n", # No empty line + wrong response code b"OMG 0 \r\nbegin\r\n", # No empty line + invalid response code b"222 0 \r\nbegin\r\n", # No perms b"222 0 \r\nbegin ABC DEF\r\n", # Permissions not octal b"222 0 \r\nbegin 755\r\n", # No filename b"222 0 \r\nbegin 644 \t \t\r\n", # Filename empty after stripping ], ) def test_missing_uu_begin(self, raw_data): article = Article("foo@bar", 1234, None) article.lowest_partnum = True filler = b"\r\n" * 4 with pytest.raises(decoder.BadUu): raw_data = bytearray(raw_data) raw_data.extend(filler) raw_data.extend(b".\r\n") assert decoder.decode_uu(article, self._response(raw_data)) @pytest.mark.parametrize("insert_empty_line", [True, False]) @pytest.mark.parametrize("insert_excess_empty_lines", [True, False]) @pytest.mark.parametrize("insert_headers", [True, False]) @pytest.mark.parametrize("insert_end", [True, False]) @pytest.mark.parametrize("insert_dot_stuffing_line", [True, False]) @pytest.mark.parametrize( "begin_line", [ b"begin 644 nospace.bin", b"begin 444 filename with spaces.txt", b"BEGIN 644 foobar", b"begin 0755 shell.sh", ], ) def test_singlepart( self, insert_empty_line, insert_excess_empty_lines, insert_headers, insert_end, insert_dot_stuffing_line, begin_line, ): """Test variations of a sane single part nzf with proper uu-encoded data""" # Generate a singlepart message article, raw_data, expected_result = self._generate_msg_part( "single", insert_empty_line, insert_excess_empty_lines, insert_headers, insert_end, insert_dot_stuffing_line, begin_line, ) assert decoder.decode_uu(article, self._response(raw_data)) == expected_result assert article.nzf.filename_checked @pytest.mark.parametrize("insert_empty_line", [True, False]) def test_multipart(self, insert_empty_line): """Test a simple multipart nzf""" # Generate and process a multipart msg decoded_data = expected_data = b"" for part in ("begin", "middle", "middle", "end"): article, data, result = self._generate_msg_part(part, insert_empty_line, False, False, True) decoded_data += decoder.decode_uu(article, self._response(data)) expected_data += result # Verify results assert decoded_data == expected_data assert article.nzf.filename_checked @pytest.mark.parametrize( "bad_data", [ VALID_UU_LINES[-1][:10] + bytes("ваше здоровье", encoding="utf8") + VALID_UU_LINES[-1][-10:], # Non-ascii ], ) def test_broken_uu(self, bad_data): article = Article("foo@bar", 4321, None) article.lowest_partnum = False filler = b"\r\n".join(VALID_UU_LINES[:4]) + b"\r\n" with pytest.raises(decoder.BadData): assert decoder.decode_uu( article, self._response(bytearray(b"222 0 \r\n" + filler + bad_data + b"\r\n.\r\n")) )