From 329fdc72eb3af4a4074986821ad9e10d72c1c2d9 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Wed, 7 Jan 2026 14:59:07 +0100 Subject: [PATCH 1/5] more tests --- pyproject.toml | 11 +++++++++++ uv.lock | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eaf9244..75f6f0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,3 +44,14 @@ dev = [ [project.scripts] "debx" = "debx.__main__:main" + +[tool.coverage.run] +branch = true +source = ["debx"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] diff --git a/uv.lock b/uv.lock index 9a9323d..751e3c8 100644 --- a/uv.lock +++ b/uv.lock @@ -238,7 +238,7 @@ wheels = [ [[package]] name = "debx" -version = "0.2.10" +version = "0.2.11" source = { editable = "." } [package.dev-dependencies] From 01608cbf196a3286e07a19bb2acb1a3bb74b5439 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Wed, 7 Jan 2026 15:12:37 +0100 Subject: [PATCH 2/5] WIP --- .coverage | Bin 0 -> 86016 bytes debx/cli/inspect.py | 161 +++--- tests/test_ar.py | 89 ++- tests/test_builder.py | 47 +- tests/test_coverage.py | 371 ++++++++++++ tests/test_main.py | 1229 +++++++++++++++++++++++++++++++++++++++- 6 files changed, 1814 insertions(+), 83 deletions(-) create mode 100644 .coverage create mode 100644 tests/test_coverage.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..e1a602ffeeaf30c76122db710df9b5f81f7f1878 GIT binary patch literal 86016 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 str: - if not sys.stdout.isatty(): - sys.stderr.write( - "Hint: probably you trying process this output. Please see the output formats for better results.\n", - ) - def format_size(size: int) -> str: - if size == 0: - return "0B" - size_names = ("B", "K", "M", "G", "T", "P", "E", "Z", "Y") - i = int(math.floor(math.log(size, 1024))) - p = math.pow(1024, i) - s = round(size / p, 1) - if s.is_integer(): - s = int(s) - return f"{s}{size_names[i]}" - - def format_mode(mode: Optional[int], item_type: Optional[str] = None) -> str: - if mode is None: - return "----------" - - result = "" - - if item_type is not None: - if item_type == "directory" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.directory): - result += "d" - elif item_type == "symlink" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.symlink): - result += "l" - elif item_type == "char" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.char): - result += "c" - elif item_type == "block" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.block): - result += "b" - elif item_type == "fifo" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.fifo): - result += "p" - else: - if stat.S_ISDIR(mode): - result += "d" - elif stat.S_ISLNK(mode): - result += "l" - else: - result += "-" +def _format_size(size: int) -> str: + """Format size in human-readable format.""" + if size == 0: + return "0B" + size_names = ("B", "K", "M", "G", "T", "P", "E", "Z", "Y") + i = int(math.floor(math.log(size, 1024))) + p = math.pow(1024, i) + s = round(size / p, 1) + if s.is_integer(): + s = int(s) + return f"{s}{size_names[i]}" + + +def _format_mode(mode: Optional[int], item_type: Optional[str] = None) -> str: + """Format file mode as ls-style permission string.""" + if mode is None: + return "----------" + + result = "" + + if item_type is not None: + if item_type == "directory" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.directory): + result += "d" + elif item_type == "symlink" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.symlink): + result += "l" + elif item_type == "char" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.char): + result += "c" + elif item_type == "block" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.block): + result += "b" + elif item_type == "fifo" or (isinstance(item_type, TarInfoType) and item_type == TarInfoType.fifo): + result += "p" else: if stat.S_ISDIR(mode): result += "d" @@ -66,43 +57,59 @@ def format_mode(mode: Optional[int], item_type: Optional[str] = None) -> str: result += "l" else: result += "-" - result += "r" if mode & stat.S_IRUSR else "-" - result += "w" if mode & stat.S_IWUSR else "-" - result += "x" if mode & stat.S_IXUSR else "-" - result += "r" if mode & stat.S_IRGRP else "-" - result += "w" if mode & stat.S_IWGRP else "-" - result += "x" if mode & stat.S_IXGRP else "-" - result += "r" if mode & stat.S_IROTH else "-" - result += "w" if mode & stat.S_IWOTH else "-" - result += "x" if mode & stat.S_IXOTH else "-" - return result - - def format_time(mtime: Optional[int], user_locale: Optional[str] = None) -> str: - if mtime is None: - return " " - - old_locale = locale.getlocale(locale.LC_TIME) - if user_locale: - try: - locale.setlocale(locale.LC_TIME, user_locale) - except locale.Error: - pass - - dt = datetime.datetime.fromtimestamp(mtime) - now = datetime.datetime.now() - - if dt.year == now.year: - result = dt.strftime("%d %b %H:%M") + else: + if stat.S_ISDIR(mode): + result += "d" + elif stat.S_ISLNK(mode): + result += "l" else: - result = dt.strftime("%d %b %Y") + result += "-" + result += "r" if mode & stat.S_IRUSR else "-" + result += "w" if mode & stat.S_IWUSR else "-" + result += "x" if mode & stat.S_IXUSR else "-" + result += "r" if mode & stat.S_IRGRP else "-" + result += "w" if mode & stat.S_IWGRP else "-" + result += "x" if mode & stat.S_IXGRP else "-" + result += "r" if mode & stat.S_IROTH else "-" + result += "w" if mode & stat.S_IWOTH else "-" + result += "x" if mode & stat.S_IXOTH else "-" + return result + + +def _format_time(mtime: Optional[int], user_locale: Optional[str] = None) -> str: + """Format modification time in ls-style format.""" + if mtime is None: + return " " + + old_locale = locale.getlocale(locale.LC_TIME) + if user_locale: + try: + locale.setlocale(locale.LC_TIME, user_locale) + except locale.Error: + pass + + dt = datetime.datetime.fromtimestamp(mtime) + now = datetime.datetime.now() + + if dt.year == now.year: + result = dt.strftime("%d %b %H:%M") + else: + result = dt.strftime("%d %b %Y") + + if user_locale: + try: + locale.setlocale(locale.LC_TIME, old_locale) + except locale.Error: + locale.setlocale(locale.LC_TIME, 'C') + + return result - if user_locale: - try: - locale.setlocale(locale.LC_TIME, old_locale) - except locale.Error: - locale.setlocale(locale.LC_TIME, 'C') - return result +def format_ls(items: list[InspectItem]) -> str: + if not sys.stdout.isatty(): + sys.stderr.write( + "Hint: probably you trying process this output. Please see the output formats for better results.\n", + ) if not items: return "total 0" @@ -120,11 +127,11 @@ def format_time(mtime: Optional[int], user_locale: Optional[str] = None) -> str: file_name = item["file"] + "/" + item.get("path", "") else: file_name = item["file"] - file_size = format_size(item.get("size", 0)) - file_mode = format_mode(item.get("mode", None), item.get("type", None)) + file_size = _format_size(item.get("size", 0)) + file_mode = _format_mode(item.get("mode", None), item.get("type", None)) file_uid = str(item.get("uid", 0)).rjust(max_uid_len) file_gid = str(item.get("gid", 0)).rjust(max_gid_len) - file_time = format_time(item.get("mtime", None)) + file_time = _format_time(item.get("mtime", None)) path_info = "" if item.get("path") and item.get("type") == "archive": diff --git a/tests/test_ar.py b/tests/test_ar.py index 215e173..2fa7207 100644 --- a/tests/test_ar.py +++ b/tests/test_ar.py @@ -2,8 +2,8 @@ import pytest from io import BytesIO -from debx import ArFile, pack_ar_archive, unpack_ar_archive, EmptyHeaderError, TruncatedHeaderError - +from debx import ArFile, pack_ar_archive, unpack_ar_archive, EmptyHeaderError, TruncatedHeaderError, DebReader, \ + TruncatedDataError TEST_CONTENT = b"test file content" TEST_NAME = "testfile.txt" @@ -49,3 +49,88 @@ def test_arfile_from_file(tmp_path): assert ar_file.name == TEST_NAME assert ar_file.content == TEST_CONTENT + + +class TestReaderErrors: + """Tests for DebReader error handling.""" + + def test_missing_debian_binary(self): + """Test error when debian-binary is missing.""" + # Create an AR archive without debian-binary + ar_content = pack_ar_archive( + ArFile.from_bytes(b"content", "control.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.bz2"), + ) + with pytest.raises(KeyError, match="Missing 'debian-binary'"): + DebReader(io.BytesIO(ar_content)) + + def test_invalid_debian_binary_version(self): + """Test error when debian-binary has wrong version.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"3.0\n", "debian-binary"), + ArFile.from_bytes(b"content", "control.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.bz2"), + ) + with pytest.raises(ValueError, match="Invalid debian-binary version"): + DebReader(io.BytesIO(ar_content)) + + def test_missing_data_tar(self): + """Test error when data.tar is missing.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"content", "control.tar.gz"), + ) + with pytest.raises(KeyError, match="Missing 'data.tar'"): + DebReader(io.BytesIO(ar_content)) + + def test_multiple_data_tar_files(self): + """Test error when multiple data.tar files exist.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"content", "control.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.bz2"), + ) + with pytest.raises(ValueError, match="Multiple data.tar files"): + DebReader(io.BytesIO(ar_content)) + + def test_unsupported_compression(self): + """Test error for unsupported compression format.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"content", "control.tar.gz"), + ArFile.from_bytes(b"content", "data.tar.xz"), + ) + with pytest.raises(ValueError, match="Unsupported compression format"): + DebReader(io.BytesIO(ar_content)) + + +class TestArErrors: + """Tests for AR archive error handling.""" + + def test_truncated_data_error(self): + """Test TruncatedDataError when data is incomplete.""" + # Create a header that claims more data than available + header = b"testfile.txt " # 16 bytes name + header += b"1234567890 " # 12 bytes mtime + header += b"0 " # 6 bytes uid + header += b"0 " # 6 bytes gid + header += b"100644 " # 8 bytes mode + header += b"1000 " # 10 bytes size (claims 1000 bytes) + header += b"\x60\x0A" # 2 bytes magic + + ar_archive = b"!\n" + header + b"short" # Only 5 bytes of data + + with pytest.raises(TruncatedDataError): + list(unpack_ar_archive(io.BytesIO(ar_archive))) + + +class TestEmptyArArchive: + """Tests for empty AR archive handling.""" + + def test_empty_ar_archive(self): + """Test unpacking empty AR archive.""" + # Just the AR magic, no files + ar_content = b"!\n" + files = list(unpack_ar_archive(io.BytesIO(ar_content))) + assert files == [] diff --git a/tests/test_builder.py b/tests/test_builder.py index 6451981..52db836 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -5,8 +5,7 @@ import pytest from pathlib import PurePosixPath, Path -from debx import unpack_ar_archive, DebBuilder - +from debx import unpack_ar_archive, DebBuilder, Deb822, DebReader @pytest.fixture @@ -89,3 +88,47 @@ def test_md5sum_calculation(builder): assert len(builder.md5sums) == 1 md5 = builder.md5sums[PurePosixPath("/test/file")] assert md5 == hashlib.md5(test_data).hexdigest() + + +class TestBuilderEdgeCases: + """Tests for DebBuilder edge cases.""" + + def test_add_control_entry_absolute_path(self): + """Test add_control_entry with absolute path.""" + builder = DebBuilder() + builder.add_control_entry("/control", "Package: test") + # The path should be normalized + assert "control" in builder.control_files + + def test_symlink_in_data_tar(self): + """Test that symlinks are properly included in data.tar.""" + builder = DebBuilder() + builder.add_data_entry(b"content", "/usr/bin/target", mode=0o755) + builder.add_data_entry(b"", "/usr/bin/link", symlink_to="/usr/bin/target") + + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + + deb_content = builder.pack() + + # Verify package can be read + reader = DebReader(io.BytesIO(deb_content)) + names = reader.data.getnames() + assert "usr/bin/target" in names + assert "usr/bin/link" in names + + def test_directory_at_root(self): + """Test that root directory is skipped.""" + builder = DebBuilder() + builder.add_data_entry(b"content", "/file.txt") + + # get_directories should not include root + dirs = list(builder.get_directories()) + for d in dirs: + assert d.name != "/" diff --git a/tests/test_coverage.py b/tests/test_coverage.py new file mode 100644 index 0000000..fb3dfd6 --- /dev/null +++ b/tests/test_coverage.py @@ -0,0 +1,371 @@ +""" +Additional tests to increase code coverage. +""" +import csv +import io +import json +import os +import stat +import sys +import tempfile +import time +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +from debx import DebBuilder, unpack_ar_archive +from debx.cli.inspect import format_ls +from debx.cli.types import InspectItem +from debx.cli.pack import parse_file + + +class TestPackDirectoryErrors: + """Tests for pack command directory error handling.""" + + def test_parse_file_relative_dest_error(self): + """Test parse_file with relative destination path.""" + from argparse import ArgumentTypeError + + with tempfile.TemporaryDirectory() as tmp: + test_file = Path(tmp) / "test.txt" + test_file.write_bytes(b"content") + + with pytest.raises(ArgumentTypeError, match="must be absolute"): + parse_file(f"{test_file}:relative/path") + + +class TestStatModeFallback: + """Tests for stat mode fallback in format_mode.""" + + def test_format_ls_type_none_regular_mode(self): + """Test format_ls with type=None and regular file mode.""" + regular_mode = 0o100644 # Regular file with 644 permissions + items = [ + InspectItem( + file="regular.txt", + size=100, + type=None, + mode=regular_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as regular file with '-' prefix + assert any('-rw-r--r--' in line for line in lines) + + def test_format_ls_type_none_dir_mode(self): + """Test format_ls with type=None and directory mode.""" + dir_mode = stat.S_IFDIR | 0o755 # Directory with 755 permissions + items = [ + InspectItem( + file="mydir", + size=0, + type=None, + mode=dir_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as directory with 'd' prefix + assert any('drwxr-xr-x' in line for line in lines) + + def test_format_ls_type_none_symlink_mode(self): + """Test format_ls with type=None and symlink mode.""" + link_mode = stat.S_IFLNK | 0o777 # Symlink with 777 permissions + items = [ + InspectItem( + file="mylink", + size=0, + type=None, + mode=link_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as symlink with 'l' prefix + assert any('lrwxrwxrwx' in line for line in lines) + + def test_format_ls_unknown_type_dir_mode_fallback(self): + """Test format_ls with unknown type that falls back to stat dir check.""" + # Use an unknown type string but with directory mode + dir_mode = stat.S_IFDIR | 0o755 + items = [ + InspectItem( + file="unknown_dir", + size=0, + type="unknown_custom_type", # Not a recognized type + mode=dir_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should fall back to stat check and show 'd' + assert any('d' in line for line in lines[1:]) + + def test_format_ls_unknown_type_symlink_mode_fallback(self): + """Test format_ls with unknown type that falls back to stat symlink check.""" + # Use an unknown type string but with symlink mode + link_mode = stat.S_IFLNK | 0o777 + items = [ + InspectItem( + file="unknown_link", + size=0, + type="unknown_custom_type", # Not a recognized type + mode=link_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should fall back to stat check and show 'l' + assert any('l' in line for line in lines[1:]) + + +class TestPackDirectoryMode: + """Tests for pack command directory handling.""" + + def test_parse_file_directory_with_mode(self, tmp_path): + """Test parse_file with directory and mode modifier shows warning.""" + # Create a directory with a file + test_dir = tmp_path / "mydir" + test_dir.mkdir() + (test_dir / "file.txt").write_bytes(b"content") + + with patch("sys.stderr.write") as mock_stderr: + result = list(parse_file(f"{test_dir}:/opt/mydir:mode=0755")) + + # Should have called stderr.write with warning + mock_stderr.assert_called() + assert len(result) == 1 + + def test_parse_file_unsupported_type(self, tmp_path): + """Test parse_file with unsupported file type (non-existent path).""" + from argparse import ArgumentTypeError + + # Use a path that exists but is neither file nor directory nor symlink + # by mocking Path.is_file and Path.is_dir to return False + nonexistent = tmp_path / "nonexistent" + + with pytest.raises((ArgumentTypeError, FileNotFoundError)): + list(parse_file(f"{nonexistent}:/var/run/test")) + + def test_parse_file_invalid_regex_match(self): + """Test parse_file when regex doesn't match.""" + from argparse import ArgumentTypeError + + # This has a colon but doesn't match the regex properly + with pytest.raises(ArgumentTypeError, match="Invalid file format"): + parse_file("::") # Edge case that has colons but invalid format + + +class TestInvalidArArchive: + """Tests for invalid AR archive handling.""" + + def test_invalid_ar_magic(self): + """Test unpack_ar_archive with invalid magic bytes.""" + invalid_ar = b"INVALID!\nsome data" + with pytest.raises(ValueError, match="Invalid ar archive"): + list(unpack_ar_archive(io.BytesIO(invalid_ar))) + + def test_truncated_ar_magic(self): + """Test unpack_ar_archive with truncated magic bytes.""" + truncated_ar = b"! 0 + + def test_format_time_with_none_mtime(self): + """Test _format_time with None mtime.""" + from debx.cli.inspect import _format_time + + result = _format_time(None) + assert result == " " + + def test_format_time_with_valid_locale(self): + """Test _format_time with a valid locale.""" + from debx.cli.inspect import _format_time + import locale + + current_time = int(time.time()) + + # Use 'C' locale which should always be available + result = _format_time(current_time, user_locale='C') + assert len(result) > 0 + + def test_format_time_with_invalid_locale_on_set(self): + """Test _format_time when setting locale fails.""" + from debx.cli.inspect import _format_time + import locale + + current_time = int(time.time()) + original_setlocale = locale.setlocale + + def mock_setlocale(category, loc=None): + if loc is not None and loc not in (None, '', ('en_US', 'UTF-8'), ('C', 'UTF-8'), 'C'): + raise locale.Error("Invalid locale") + return original_setlocale(category, loc) + + with patch.object(locale, 'setlocale', side_effect=mock_setlocale): + # This should not raise, just silently ignore the locale error + result = _format_time(current_time, user_locale='invalid_locale_xyz') + assert len(result) > 0 + + def test_format_time_with_locale_restore_error(self): + """Test _format_time when restoring locale fails and falls back to 'C'.""" + from debx.cli.inspect import _format_time + import locale + + current_time = int(time.time()) + original_setlocale = locale.setlocale + call_count = [0] + + def mock_setlocale(category, loc=None): + call_count[0] += 1 + # First call: getlocale returns tuple + # Second call: setting user_locale - allow it + # Third call: restoring old locale - fail + # Fourth call: fallback to 'C' - allow it + if call_count[0] == 3: + # Fail when trying to restore old locale + raise locale.Error("Cannot restore locale") + if loc == 'C' or loc is None: + return original_setlocale(category, loc) + # Allow setting user_locale + return original_setlocale(category, 'C') + + with patch.object(locale, 'setlocale', side_effect=mock_setlocale): + with patch.object(locale, 'getlocale', return_value=('invalid', 'locale')): + # This should not raise, should fallback to 'C' + result = _format_time(current_time, user_locale='C') + assert len(result) > 0 + + +class TestDeb822ContinuationWithoutField: + """Tests for Deb822 parsing edge cases.""" + + def test_continuation_line_without_prior_field(self): + """Test parsing continuation line without prior field definition.""" + from debx import Deb822 + + # A line starting with space but no prior field defined + text = " continuation without field\nPackage: test\n" + result = Deb822.parse(text) + # Should just skip the orphan continuation line + assert result["Package"] == "test" + + def test_continuation_after_comment(self): + """Test continuation line after a comment.""" + from debx import Deb822 + + text = "# comment\n continuation\nPackage: test\n" + result = Deb822.parse(text) + assert result["Package"] == "test" + + +class TestFormatSizeDecimal: + """Tests for _format_size with decimal values.""" + + def test_format_size_decimal(self): + """Test _format_size with sizes that result in decimal values.""" + from debx.cli.inspect import _format_size + + # 1536 bytes = 1.5K (not an integer) + result = _format_size(1536) + assert result == "1.5K" + + # 2560 bytes = 2.5K + result = _format_size(2560) + assert result == "2.5K" + + def test_format_size_integer(self): + """Test _format_size with sizes that result in integer values.""" + from debx.cli.inspect import _format_size + + # 1024 bytes = 1K (integer) + result = _format_size(1024) + assert result == "1K" + + # 2048 bytes = 2K (integer) + result = _format_size(2048) + assert result == "2K" + + diff --git a/tests/test_main.py b/tests/test_main.py index 17ca9c2..42edaf7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,10 +5,11 @@ import pytest from unittest.mock import MagicMock, patch -from debx import ArFile, pack_ar_archive, unpack_ar_archive -from debx.cli.inspect import cli_inspect +from debx import ArFile, pack_ar_archive, unpack_ar_archive, DebBuilder, Deb822 +from debx.cli.inspect import cli_inspect, format_ls, format_csv, format_json from debx.cli.pack import parse_file, cli_pack from debx.cli.sign import cli_sign_extract_payload, cli_sign_write_signature, cli_sign +from debx.cli.types import InspectItem, TarInfoType from debx.cli.unpack import cli_unpack @@ -322,3 +323,1227 @@ def test_cli_sign_invalid_arguments(mock_package): result = cli_sign(args) assert result == 1 mock_log.assert_called_with("No action specified") + + +class TestInspectFormatting: + """Tests for inspect formatting functions.""" + + def test_format_ls_empty_items(self): + """Test format_ls with empty list.""" + result = format_ls([]) + assert result == "total 0" + + def test_format_ls_mode_none(self): + """Test format_ls when mode is None.""" + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=None, + uid=0, + gid=0, + mtime=None, + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "----------" in result + + def test_format_ls_symlink_type(self): + """Test format_ls with symlink type.""" + items = [ + InspectItem( + file="link", + size=0, + type="symlink", + mode=0o777, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert result.startswith("total") + assert "l" in result # symlink indicator + + def test_format_ls_directory_type(self): + """Test format_ls with directory type.""" + items = [ + InspectItem( + file="dir", + size=0, + type="directory", + mode=0o755, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "d" in result # directory indicator + + def test_format_ls_char_type(self): + """Test format_ls with char device type.""" + items = [ + InspectItem( + file="char", + size=0, + type="char", + mode=0o666, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "c" in result # char device indicator + + def test_format_ls_block_type(self): + """Test format_ls with block device type.""" + items = [ + InspectItem( + file="block", + size=0, + type="block", + mode=0o660, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "b" in result # block device indicator + + def test_format_ls_fifo_type(self): + """Test format_ls with fifo type.""" + items = [ + InspectItem( + file="fifo", + size=0, + type="fifo", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "p" in result # fifo indicator + + def test_format_ls_old_year(self): + """Test format_ls with old year timestamp.""" + # Use a timestamp from 2020 + old_time = 1577836800 # 2020-01-01 + items = [ + InspectItem( + file="old.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=old_time, + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "2020" in result + + def test_format_ls_with_path(self): + """Test format_ls with path set.""" + items = [ + InspectItem( + file="data.tar.gz", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path="usr/bin/test", + ) + ] + result = format_ls(items) + assert "data.tar.gz/usr/bin/test" in result + + def test_format_ls_stat_dir_mode(self): + """Test format_ls using stat.S_ISDIR for mode detection.""" + # Create item with directory mode but no explicit type + dir_mode = stat.S_IFDIR | 0o755 + items = [ + InspectItem( + file="dir", + size=0, + type=None, + mode=dir_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "d" in result + + def test_format_ls_stat_link_mode(self): + """Test format_ls using stat.S_ISLNK for mode detection.""" + # Create item with symlink mode but no explicit type + link_mode = stat.S_IFLNK | 0o777 + items = [ + InspectItem( + file="link", + size=0, + type=None, + mode=link_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "l" in result + + def test_format_csv(self): + """Test format_csv function.""" + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=1234567890, + md5="abc123", + path=None, + ) + ] + result = format_csv(items) + assert "file" in result + assert "test.txt" in result + assert "100" in result + + def test_format_json(self): + """Test format_json function.""" + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=1234567890, + md5="abc123", + path=None, + ) + ] + result = format_json(items) + assert '"file": "test.txt"' in result + assert '"size": 100' in result + + +class TestCliInspect: + """Tests for CLI inspect command.""" + + def test_inspect_json_format(self, tmp_path): + """Test inspect with JSON format.""" + # Create a test package + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=str(pkg_path), format="json") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "debian-binary" in output + + def test_inspect_csv_format(self, tmp_path): + """Test inspect with CSV format.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=str(pkg_path), format="csv") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "file" in output + + def test_inspect_unknown_format(self, tmp_path): + """Test inspect with unknown format.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=str(pkg_path), format="invalid") + with patch("sys.stderr", new_callable=io.StringIO) as mock_stderr: + result = cli_inspect(args) + + assert result == 1 + assert "Unknown format" in mock_stderr.getvalue() + + +class TestCliSign: + """Tests for CLI sign command.""" + + def test_sign_extract_tty_error(self, tmp_path): + """Test sign extract when stdout is tty.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=True, update=False, output=None) + + with patch("sys.stdout.isatty", return_value=True): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_extract_no_control(self, tmp_path): + """Test sign extract when control file is missing.""" + # Create package without control + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"data", "data.tar.bz2"), + ) + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=pkg_path) + + with patch("sys.stdout.isatty", return_value=False): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_extract_no_data(self, tmp_path): + """Test sign extract when data file is missing.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"control", "control.tar.gz"), + ) + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=pkg_path) + + with patch("sys.stdout.isatty", return_value=False): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_write_invalid_signature(self, tmp_path): + """Test sign write with invalid signature.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_path = tmp_path / "signed.deb" + args = Namespace(package=pkg_path, output=output_path) + + with patch("sys.stdin.buffer.read", return_value=b"invalid signature"): + result = cli_sign_write_signature(args) + + assert result == 1 + + def test_sign_both_flags_error(self, tmp_path): + """Test sign with both --extract and --update.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=True, update=True, output=None) + result = cli_sign(args) + + assert result == 1 + + def test_sign_extract_with_output_error(self, tmp_path): + """Test sign extract with --output flag.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace( + package=pkg_path, extract=True, update=False, + output=tmp_path / "out.deb" + ) + result = cli_sign(args) + + assert result == 1 + + def test_sign_update_default_output(self, tmp_path): + """Test sign update with default output path.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + signature = b"-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----" + + args = Namespace(package=pkg_path, extract=False, update=True, output=None) + + with patch("sys.stdin.buffer.read", return_value=signature): + result = cli_sign(args) + + assert result == 0 + assert (tmp_path / "test.signed.deb").exists() + + def test_sign_update_custom_output(self, tmp_path): + """Test sign update with custom output path (covers branch 87->89).""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + signature = b"-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----" + + custom_output = tmp_path / "custom_output.deb" + args = Namespace(package=pkg_path, extract=False, update=True, output=custom_output) + + with patch("sys.stdin.buffer.read", return_value=signature): + result = cli_sign(args) + + assert result == 0 + assert custom_output.exists() + + def test_sign_no_action_error(self, tmp_path): + """Test sign with no action specified.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=False, update=False, output=None) + result = cli_sign(args) + + assert result == 1 + + +class TestCliUnpack: + """Tests for CLI unpack command.""" + + def test_unpack_default_directory(self, tmp_path): + """Test unpack with default directory name.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "mypackage.deb" + pkg_path.write_bytes(builder.pack()) + + # Change to tmp_path so the default directory is created there + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + args = Namespace(package=str(pkg_path), directory=None, keep_archives=False) + result = cli_unpack(args) + finally: + os.chdir(old_cwd) + + assert result == 0 + assert (tmp_path / "mypackage").exists() + + def test_unpack_keep_archives(self, tmp_path): + """Test unpack with --keep-archives flag.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_dir = tmp_path / "output" + args = Namespace(package=str(pkg_path), directory=str(output_dir), keep_archives=True) + result = cli_unpack(args) + + assert result == 0 + assert (output_dir / "control.tar.gz").exists() + assert (output_dir / "data.tar.bz2").exists() + + +class TestCliPack: + """Tests for CLI pack command.""" + + def test_parse_file_no_colon(self): + """Test parse_file with missing colon.""" + from argparse import ArgumentTypeError + with pytest.raises(ArgumentTypeError, match="Invalid file format"): + parse_file("nocolon") + + def test_parse_file_symlink(self, tmp_path): + """Test parse_file with symlink.""" + # Create a symlink + target = tmp_path / "target" + target.write_bytes(b"content") + link = tmp_path / "link" + link.symlink_to(target) + + result = list(parse_file(f"{link}:/usr/bin/link")) + assert len(result) == 1 + assert result[0]["name"] == "/usr/bin/link" + + +class TestFormatLsIntegration: + """Integration tests for format_ls with TarInfoType.""" + + def test_format_ls_with_tarinfoType_directory(self): + """Test format_ls with TarInfoType.directory.""" + items = [ + InspectItem( + file="dir", + size=0, + type=TarInfoType.directory.name, + mode=0o755, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "d" in result + + def test_format_ls_with_tarinfoType_symlink(self): + """Test format_ls with TarInfoType.symlink.""" + items = [ + InspectItem( + file="link", + size=0, + type=TarInfoType.symlink.name, + mode=0o777, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + assert "l" in result + + def test_format_ls_regular_file_with_type(self): + """Test format_ls with regular file type (not dir/symlink).""" + items = [ + InspectItem( + file="file.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + # Should have regular file indicator (-) + lines = result.strip().split('\n') + assert any(line.startswith('-') for line in lines[1:]) + + def test_format_ls_unknown_type_regular_mode(self): + """Test format_ls with unknown type but regular file mode.""" + items = [ + InspectItem( + file="file.txt", + size=100, + type="unknown_type", + mode=0o644, # Regular file mode + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + # Should fall through to stat check and show as regular file + lines = result.strip().split('\n') + assert len(lines) >= 2 + + def test_format_ls_archive_type_with_path(self): + """Test format_ls with archive type and path (shows arrow).""" + items = [ + InspectItem( + file="data.tar.gz", + size=100, + type="archive", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path="internal/file.txt", + ) + ] + result = format_ls(items) + assert " -> internal/file.txt" in result + + def test_format_ls_tty_hint(self): + """Test format_ls shows hint when not tty.""" + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + with patch("sys.stdout.isatty", return_value=False): + with patch("sys.stderr.write") as mock_stderr: + format_ls(items) + # Should write hint to stderr + mock_stderr.assert_called() + + +class TestInspectXzFormat: + """Tests for XZ compressed packages.""" + + def test_inspect_tar_xz_package(self, tmp_path): + """Test inspecting package with .tar.xz data.""" + import tarfile + + # Create a .tar.xz file + xz_path = tmp_path / "data.tar.xz" + with tarfile.open(xz_path, "w:xz") as tar: + # Add a file to the tar + data = b"test content" + info = tarfile.TarInfo(name="usr/bin/test") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + # Create a minimal control.tar.gz + control_path = tmp_path / "control.tar.gz" + with tarfile.open(control_path, "w:gz") as tar: + control_data = b"Package: test\nVersion: 1.0\n" + info = tarfile.TarInfo(name="control") + info.size = len(control_data) + tar.addfile(info, io.BytesIO(control_data)) + + # Create AR archive (deb package) + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_file(control_path, "control.tar.gz"), + ArFile.from_file(xz_path, "data.tar.xz"), + ) + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=str(pkg_path), format="ls") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "data.tar.xz" in output + + +class TestMainEntryPoint: + """Tests for __main__.py entry point.""" + + def test_main_no_args(self): + """Test main with no arguments shows help.""" + from debx.__main__ import main, PARSER + + with patch.object(sys, 'argv', ['debx']): + with patch.object(PARSER, 'print_help') as mock_help: + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + mock_help.assert_called_once() + + def test_main_inspect_command(self, tmp_path): + """Test main with inspect command.""" + from debx.__main__ import main + + # Create a test package + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + with patch.object(sys, 'argv', ['debx', 'inspect', str(pkg_path)]): + with patch("sys.stdout", new_callable=io.StringIO): + with patch("sys.stdout.isatty", return_value=True): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + def test_main_pack_command(self, tmp_path): + """Test main with pack command.""" + from debx.__main__ import main + + # Create control file + control_file = tmp_path / "control" + control_file.write_text("""Package: test +Version: 1.0 +Architecture: all +Maintainer: Test +Description: Test +""") + + # Create data file + data_file = tmp_path / "binary" + data_file.write_bytes(b"#!/bin/sh\necho hello") + + output_path = tmp_path / "output.deb" + + with patch.object(sys, 'argv', [ + 'debx', 'pack', + '-c', f'{control_file}:/control', + '-d', f'{data_file}:/usr/bin/test:mode=0755', + '-o', str(output_path) + ]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + assert output_path.exists() + + def test_main_unpack_command(self, tmp_path): + """Test main with unpack command.""" + from debx.__main__ import main + + # Create a test package + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_dir = tmp_path / "output" + + with patch.object(sys, 'argv', [ + 'debx', 'unpack', str(pkg_path), '-d', str(output_dir) + ]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + assert output_dir.exists() + + +class TestInspectNoMd5sums: + """Test inspect when md5sums file doesn't exist.""" + + def test_inspect_without_md5sums(self, tmp_path): + """Test inspecting package without md5sums in control.""" + import tarfile + + # Create control.tar.gz without md5sums + control_path = tmp_path / "control.tar.gz" + with tarfile.open(control_path, "w:gz") as tar: + control_data = b"Package: test\nVersion: 1.0\n" + info = tarfile.TarInfo(name="control") + info.size = len(control_data) + tar.addfile(info, io.BytesIO(control_data)) + + # Create data.tar.bz2 + data_path = tmp_path / "data.tar.bz2" + with tarfile.open(data_path, "w:bz2") as tar: + data = b"test content" + info = tarfile.TarInfo(name="usr/bin/test") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + # Create AR archive + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_file(control_path, "control.tar.gz"), + ArFile.from_file(data_path, "data.tar.bz2"), + ) + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=str(pkg_path), format="ls") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + + +class TestInspectNoControlTar: + """Tests for inspect when control.tar is missing.""" + + def test_inspect_without_control_tar(self, tmp_path): + """Test inspecting package without control.tar.""" + import tarfile + + # Create only data.tar.bz2, no control.tar + data_path = tmp_path / "data.tar.bz2" + with tarfile.open(data_path, "w:bz2") as tar: + data = b"test content" + info = tarfile.TarInfo(name="usr/bin/test") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + # Create AR archive without control.tar + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_file(data_path, "data.tar.bz2"), + ) + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=str(pkg_path), format="ls") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "data.tar.bz2" in output + + +class TestSignExtractSuccess: + """Test successful sign extract operation.""" + + def test_sign_extract_success(self, tmp_path): + """Test sign extract with valid package.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=pkg_path) + + # Create a mock stdout with buffer attribute + mock_stdout = MagicMock() + mock_stdout.isatty.return_value = False + mock_stdout.buffer = io.BytesIO() + + with patch("sys.stdout", mock_stdout): + result = cli_sign_extract_payload(args) + + assert result == 0 + assert len(mock_stdout.buffer.getvalue()) > 0 + + def test_sign_extract_via_cli_sign(self, tmp_path): + """Test sign extract success through cli_sign function (covers line 85).""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=pkg_path, extract=True, update=False, output=None) + + # Create a mock stdout with buffer attribute + mock_stdout = MagicMock() + mock_stdout.isatty.return_value = False + mock_stdout.buffer = io.BytesIO() + + with patch("sys.stdout", mock_stdout): + result = cli_sign(args) + + assert result == 0 + assert len(mock_stdout.buffer.getvalue()) > 0 + + +class TestInspectPlainTar: + """Test inspect with plain .tar files (no compression).""" + + def test_inspect_plain_tar_package(self, tmp_path): + """Test inspecting package with plain .tar data (mode='r').""" + import tarfile + + # Create a plain .tar file + tar_path = tmp_path / "data.tar" + with tarfile.open(tar_path, "w") as tar: + data = b"test content" + info = tarfile.TarInfo(name="usr/bin/test") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + # Create control.tar.gz + control_path = tmp_path / "control.tar.gz" + with tarfile.open(control_path, "w:gz") as tar: + control_data = b"Package: test\nVersion: 1.0\n" + info = tarfile.TarInfo(name="control") + info.size = len(control_data) + tar.addfile(info, io.BytesIO(control_data)) + + # Create AR archive + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_file(control_path, "control.tar.gz"), + ArFile.from_file(tar_path, "data.tar"), + ) + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=str(pkg_path), format="ls") + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + assert "data.tar" in output + + +class TestInspectIntegration: + """Integration tests for inspect command with all formats.""" + + @pytest.fixture + def test_package(self, tmp_path): + """Create a test deb package with various file types.""" + builder = DebBuilder() + + control = Deb822({ + "Package": "integration-test", + "Version": "1.2.3", + "Architecture": "amd64", + "Maintainer": "Test User ", + "Description": "Integration test package\n" + " This is a multi-line description.\n" + " Used for testing inspect formats.", + "Depends": "libc6", + }) + builder.add_control_entry("control", control.dump()) + + # Add various data files + builder.add_data_entry(b"#!/bin/bash\necho hello", "/usr/bin/hello", mode=0o755) + builder.add_data_entry(b"Configuration file", "/etc/hello.conf", mode=0o644) + builder.add_data_entry(b"Library content", "/usr/lib/libhello.so", mode=0o644) + builder.add_data_entry(b"Documentation", "/usr/share/doc/hello/README", mode=0o644) + + # Add a conffiles entry + builder.add_control_entry("conffiles", "/etc/hello.conf\n") + + pkg_path = tmp_path / "integration-test_1.2.3_amd64.deb" + pkg_path.write_bytes(builder.pack()) + + return pkg_path + + def test_inspect_json_format(self, test_package): + """Test inspect with JSON output format.""" + args = Namespace(package=str(test_package), format="json") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + + # Parse JSON and verify structure + data = json.loads(output) + assert isinstance(data, list) + assert len(data) > 0 + + # Verify expected files are present + files = {item.get("path") or item.get("file") for item in data} + assert "debian-binary" in files + assert any("control.tar" in f for f in files) + assert any("data.tar" in f for f in files) + + # Verify data files are present + paths = {item.get("path") for item in data if item.get("path")} + assert "./usr/bin/hello" in paths or "usr/bin/hello" in paths + assert "./etc/hello.conf" in paths or "etc/hello.conf" in paths + + # Verify JSON structure has expected keys + for item in data: + assert "file" in item + assert "size" in item + assert "type" in item + assert "mode" in item + + def test_inspect_csv_format(self, test_package): + """Test inspect with CSV output format.""" + args = Namespace(package=str(test_package), format="csv") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + + # Parse CSV and verify structure + reader = csv.reader(io.StringIO(output)) + rows = list(reader) + + # First row should be headers + headers = rows[0] + assert "file" in headers + assert "size" in headers + assert "type" in headers + assert "mode" in headers + assert "path" in headers + + # Should have data rows + assert len(rows) > 1 + + # Verify data files are in output + assert "debian-binary" in output + assert "control.tar" in output + assert "data.tar" in output + assert "usr/bin/hello" in output + + def test_inspect_find_format(self, test_package): + """Test inspect with find-style output format.""" + args = Namespace(package=str(test_package), format="find") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + + # Verify output is line-based paths + lines = output.strip().split("\n") + assert len(lines) > 0 + + # Verify expected files/paths are present + assert "debian-binary" in output + assert any("control.tar" in line for line in lines) + assert any("data.tar" in line for line in lines) + assert any("usr/bin/hello" in line for line in lines) + assert any("etc/hello.conf" in line for line in lines) + + def test_inspect_ls_format(self, test_package): + """Test inspect with ls-style output format.""" + args = Namespace(package=str(test_package), format="ls") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0 + output = mock_stdout.getvalue() + + # Verify ls-style output structure + lines = output.strip().split("\n") + assert lines[0].startswith("total ") + + # Verify permission strings are present + assert any(line.startswith("-r") for line in lines) # regular files + + # Verify expected files are present + assert "debian-binary" in output + assert "control.tar" in output + assert "data.tar" in output + assert "usr/bin/hello" in output + assert "etc/hello.conf" in output + + # Verify human-readable sizes are present (e.g., "B", "K", "M") + assert any(c in output for c in ["B", "K", "M"]) + + def test_inspect_ls_format_non_tty(self, test_package): + """Test inspect with ls format when stdout is not a tty (shows hint).""" + args = Namespace(package=str(test_package), format="ls") + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=False): + with patch("sys.stderr", new_callable=io.StringIO) as mock_stderr: + result = cli_inspect(args) + + assert result == 0 + # Should show hint about using other formats + assert "Hint" in mock_stderr.getvalue() + # But still produce output + assert "total" in mock_stdout.getvalue() + + def test_inspect_all_formats_consistency(self, test_package): + """Test that all formats contain the same files.""" + formats = ["json", "csv", "find", "ls"] + file_counts = {} + + for fmt in formats: + args = Namespace(package=str(test_package), format=fmt) + + with patch("sys.stdout", new_callable=io.StringIO) as mock_stdout: + with patch("sys.stdout.isatty", return_value=True): + result = cli_inspect(args) + + assert result == 0, f"Format {fmt} failed" + + output = mock_stdout.getvalue() + # Count mentions of key files + file_counts[fmt] = { + "debian-binary": output.count("debian-binary"), + "hello": output.count("hello"), + } + + # All formats should mention debian-binary exactly once + for fmt in formats: + assert file_counts[fmt]["debian-binary"] >= 1, f"Format {fmt} missing debian-binary" + assert file_counts[fmt]["hello"] >= 1, f"Format {fmt} missing hello files" + + +class TestLocaleHandling: + """Tests for locale handling in format_time.""" + + def test_format_ls_with_locale_error(self): + """Test format_ls when locale setting fails.""" + import locale + + items = [ + InspectItem( + file="test.txt", + size=100, + type="regular", + mode=0o644, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + + # Mock locale.setlocale to raise an error + original_setlocale = locale.setlocale + call_count = [0] + + def failing_setlocale(category, locale_str=None): + call_count[0] += 1 + if call_count[0] == 1: + # First call (getting old locale) succeeds + return original_setlocale(category, locale_str) + elif locale_str and locale_str != 'C': + # Setting new locale fails + raise locale.Error("test error") + return original_setlocale(category, locale_str) + + with patch.object(locale, 'setlocale', side_effect=failing_setlocale): + # This should not raise an error + result = format_ls(items) + + assert "test.txt" in result From 2aae6443dfcd98922b20dfd6a036c2671183a215 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Wed, 7 Jan 2026 15:28:53 +0100 Subject: [PATCH 3/5] Add more tests --- .coverage | Bin 86016 -> 86016 bytes debx/builder.py | 4 +- pyproject.toml | 1 + tests/test_ar.py | 16 + tests/test_builder.py | 40 + tests/{test_main.py => test_cli_inspect.py} | 876 +++++--------------- tests/test_cli_pack.py | 143 ++++ tests/test_cli_sign.py | 296 +++++++ tests/test_cli_unpack.py | 90 ++ tests/test_coverage.py | 371 --------- tests/test_deb822.py | 18 + tests/test_entrypoint.py | 112 +++ tests/test_integration.py | 198 +++++ uv.lock | 28 + 14 files changed, 1135 insertions(+), 1058 deletions(-) rename tests/{test_main.py => test_cli_inspect.py} (53%) create mode 100644 tests/test_cli_pack.py create mode 100644 tests/test_cli_sign.py create mode 100644 tests/test_cli_unpack.py delete mode 100644 tests/test_coverage.py create mode 100644 tests/test_entrypoint.py create mode 100644 tests/test_integration.py diff --git a/.coverage b/.coverage index e1a602ffeeaf30c76122db710df9b5f81f7f1878..5b060daa990eee71f078bb7d149111e95ec42dcb 100644 GIT binary patch delta 7548 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 delta 7593 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 diff --git a/debx/builder.py b/debx/builder.py index 4c1c3a2..b6381cf 100644 --- a/debx/builder.py +++ b/debx/builder.py @@ -117,11 +117,11 @@ def create_data_tar(self) -> bytes: with io.BytesIO() as fp: with tarfile.open(fileobj=fp, mode="w:bz2", format=tarfile.GNU_FORMAT, compresslevel=9) as tar: for directory_info in self.get_directories(): - logging.debug(f"Adding directory to data archive: %s", directory_info.path) + logging.debug("Adding directory to data archive: %s", directory_info.path) tar.addfile(directory_info) for item in sorted(self.data_files.values(), key=lambda x: x.tar_info.name): - logging.debug(f"Adding data to archive: %s", item.tar_info.name) + logging.debug("Adding data to archive: %s", item.tar_info.name) if item.tar_info.type == tarfile.SYMTYPE: tar.addfile(item.tar_info) else: diff --git a/pyproject.toml b/pyproject.toml index 75f6f0b..c7969c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "markdown-pytest>=0.3.2", "pytest>=9.0.2", "pytest-cov>=7.0.0", + "ruff>=0.14.10", ] [project.urls] diff --git a/tests/test_ar.py b/tests/test_ar.py index 2fa7207..eae891a 100644 --- a/tests/test_ar.py +++ b/tests/test_ar.py @@ -134,3 +134,19 @@ def test_empty_ar_archive(self): ar_content = b"!\n" files = list(unpack_ar_archive(io.BytesIO(ar_content))) assert files == [] + + +class TestInvalidArArchive: + """Tests for invalid AR archive handling.""" + + def test_invalid_ar_magic(self): + """Test unpack_ar_archive with invalid magic bytes.""" + invalid_ar = b"INVALID!\nsome data" + with pytest.raises(ValueError, match="Invalid ar archive"): + list(unpack_ar_archive(io.BytesIO(invalid_ar))) + + def test_truncated_ar_magic(self): + """Test unpack_ar_archive with truncated magic bytes.""" + truncated_ar = b"!\n" - "Description: Test package\n" - " This is a test package for testing purposes.\n" - ) - - # Create some data files - data_dir = tmp_path / "data" - data_dir.mkdir() - - bin_dir = data_dir / "bin" - bin_dir.mkdir(parents=True) - - bin_file = bin_dir / "test-script" - bin_file.write_text("#!/bin/sh\necho 'Hello, world!'\n") - bin_file.chmod(0o755) - - etc_dir = data_dir / "etc" / "test-package" - etc_dir.mkdir(parents=True) - - config_file = etc_dir / "config" - config_file.write_text("# Test configuration\nSETTING=value\n") - - return tmp_path - - -class TestIntegration: - def test_pack_and_unpack(self, test_package_structure, tmp_path): - """Integration test for packing and unpacking a deb package""" - # Skip if running in CI without proper permissions - if "CI" in os.environ: - pytest.skip("Skipping integration test in CI environment") - - package_dir = test_package_structure - output_deb = tmp_path / "output.deb" - extract_dir = tmp_path / "extract" - extract_dir.mkdir() - - # Pack arguments - pack_args = MagicMock() - pack_args.control = [ - [{"content": (package_dir / "control" / "control").read_bytes(), - "name": "control", "mode": 0o644}] - ] - pack_args.data = [ - [{"content": (package_dir / "data" / "bin" / "test-script").read_bytes(), - "name": "/usr/bin/test-script", "mode": 0o755}], - [{"content": (package_dir / "data" / "etc" / "test-package" / "config").read_bytes(), - "name": "/etc/test-package/config", "mode": 0o644}] + def test_format_ls_type_none_regular_mode(self): + """Test format_ls with type=None and regular file mode.""" + regular_mode = 0o100644 # Regular file with 644 permissions + items = [ + InspectItem( + file="regular.txt", + size=100, + type=None, + mode=regular_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) ] - pack_args.deb = str(output_deb) - - # Run pack command - cli_pack(pack_args) - - # Verify deb file was created - assert output_deb.exists() - - # Unpack arguments - unpack_args = MagicMock() - unpack_args.package = str(output_deb) - unpack_args.directory = str(extract_dir) - - # Run unpack command - cli_unpack(unpack_args) - - # Verify files were extracted - assert (extract_dir / "debian-binary").exists() - assert (extract_dir / "control").exists() - assert (extract_dir / "data").exists() - - -class TestInspect: - def test_inspect(self, test_package_structure): - """Test the inspect command""" - package_dir = test_package_structure - output_deb = package_dir / "output.deb" + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as regular file with '-' prefix + assert any('-rw-r--r--' in line for line in lines) - # Pack the package - pack_args = MagicMock() - pack_args.control = [ - [{"content": (package_dir / "control" / "control").read_bytes(), - "name": "control", "mode": 0o644}] - ] - pack_args.data = [ - [{"content": (package_dir / "data" / "bin" / "test-script").read_bytes(), - "name": "/usr/bin/test-script", "mode": 0o755}], - [{"content": (package_dir / "data" / "etc" / "test-package" / "config").read_bytes(), - "name": "/etc/test-package/config", "mode": 0o644}] + def test_format_ls_type_none_dir_mode(self): + """Test format_ls with type=None and directory mode.""" + dir_mode = stat.S_IFDIR | 0o755 # Directory with 755 permissions + items = [ + InspectItem( + file="mydir", + size=0, + type=None, + mode=dir_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) ] - pack_args.deb = str(output_deb) - - # Run pack command - cli_pack(pack_args) - - # Inspect arguments - inspect_args = MagicMock() - inspect_args.package = str(output_deb) - - # Run inspect command - cli_inspect(inspect_args) - - # Verify output - assert output_deb.exists() - - def test_inspect_format_lst(self, test_package_structure): - """Test the inspect command with --format=ls""" - package_dir = test_package_structure - output_deb = package_dir / "output.deb" + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as directory with 'd' prefix + assert any('drwxr-xr-x' in line for line in lines) - # Pack the package - pack_args = MagicMock() - pack_args.control = [ - [{"content": (package_dir / "control" / "control").read_bytes(), - "name": "control", "mode": 0o644}] - ] - pack_args.data = [ - [{"content": (package_dir / "data" / "bin" / "test-script").read_bytes(), - "name": "/usr/bin/test-script", "mode": 0o755}], - [{"content": (package_dir / "data" / "etc" / "test-package" / "config").read_bytes(), - "name": "/etc/test-package/config", "mode": 0o644}] + def test_format_ls_type_none_symlink_mode(self): + """Test format_ls with type=None and symlink mode.""" + link_mode = stat.S_IFLNK | 0o777 # Symlink with 777 permissions + items = [ + InspectItem( + file="mylink", + size=0, + type=None, + mode=link_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) ] - pack_args.deb = str(output_deb) + result = format_ls(items) + lines = result.strip().split('\n') + # Should show as symlink with 'l' prefix + assert any('lrwxrwxrwx' in line for line in lines) - # Run pack command - cli_pack(pack_args) + def test_format_ls_unknown_type_dir_mode_fallback(self): + """Test format_ls with unknown type that falls back to stat dir check.""" + # Use an unknown type string but with directory mode + dir_mode = stat.S_IFDIR | 0o755 + items = [ + InspectItem( + file="unknown_dir", + size=0, + type="unknown_custom_type", # Not a recognized type + mode=dir_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should fall back to stat check and show 'd' + assert any('d' in line for line in lines[1:]) - # Inspect arguments - inspect_args = MagicMock() - inspect_args.package = str(output_deb) - inspect_args.format = 'ls' + def test_format_ls_unknown_type_symlink_mode_fallback(self): + """Test format_ls with unknown type that falls back to stat symlink check.""" + # Use an unknown type string but with symlink mode + link_mode = stat.S_IFLNK | 0o777 + items = [ + InspectItem( + file="unknown_link", + size=0, + type="unknown_custom_type", # Not a recognized type + mode=link_mode, + uid=0, + gid=0, + mtime=int(time.time()), + md5=None, + path=None, + ) + ] + result = format_ls(items) + lines = result.strip().split('\n') + # Should fall back to stat check and show 'l' + assert any('l' in line for line in lines[1:]) - # Run inspect command - cli_inspect(inspect_args) - # Verify output - assert output_deb.exists() +class TestFormatTimeLocale: + """Tests for format_time locale handling.""" - def test_inspect_format_find(self, test_package_structure): - """Test the inspect command with --format=find""" - package_dir = test_package_structure - output_deb = package_dir / "output.deb" + def test_format_time_without_locale(self): + """Test _format_time without user_locale.""" + from debx.cli.inspect import _format_time - # Pack the package - pack_args = MagicMock() - pack_args.control = [ - [{"content": (package_dir / "control" / "control").read_bytes(), - "name": "control", "mode": 0o644}] - ] - pack_args.data = [ - [{"content": (package_dir / "data" / "bin" / "test-script").read_bytes(), - "name": "/usr/bin/test-script", "mode": 0o755}], - [{"content": (package_dir / "data" / "etc" / "test-package" / "config").read_bytes(), - "name": "/etc/test-package/config", "mode": 0o644}] - ] - pack_args.deb = str(output_deb) + current_time = int(time.time()) + result = _format_time(current_time) + assert len(result) > 0 - # Run pack command - cli_pack(pack_args) + def test_format_time_with_none_mtime(self): + """Test _format_time with None mtime.""" + from debx.cli.inspect import _format_time - # Inspect arguments - inspect_args = MagicMock() - inspect_args.package = str(output_deb) - inspect_args.format = 'find' + result = _format_time(None) + assert result == " " - # Run inspect command - cli_inspect(inspect_args) + def test_format_time_with_valid_locale(self): + """Test _format_time with a valid locale.""" + from debx.cli.inspect import _format_time + import locale - # Verify output - assert output_deb.exists() + current_time = int(time.time()) + # Use 'C' locale which should always be available + result = _format_time(current_time, user_locale='C') + assert len(result) > 0 -@pytest.fixture -def mock_package(tmp_path): - control_file = ArFile(name="control.tar.gz", content=b"control content", size=15) - data_file = ArFile(name="data.tar.gz", content=b"data content", size=12) - package_path = tmp_path / "test.deb" - package_path.write_bytes(pack_ar_archive(control_file, data_file)) - return package_path + def test_format_time_with_invalid_locale_on_set(self): + """Test _format_time when setting locale fails.""" + from debx.cli.inspect import _format_time + import locale + current_time = int(time.time()) + original_setlocale = locale.setlocale -def test_cli_sign_extract_payload(mock_package, capsys): - args = MagicMock() - args.package = mock_package - args.output = None + def mock_setlocale(category, loc=None): + if loc is not None and loc not in (None, '', ('en_US', 'UTF-8'), ('C', 'UTF-8'), 'C'): + raise locale.Error("Invalid locale") + return original_setlocale(category, loc) - with patch("sys.stdout", new_callable=io.BytesIO) as mock_stdout: - mock_stdout.buffer = mock_stdout + with patch.object(locale, 'setlocale', side_effect=mock_setlocale): + # This should not raise, just silently ignore the locale error + result = _format_time(current_time, user_locale='invalid_locale_xyz') + assert len(result) > 0 - result = cli_sign_extract_payload(args) - assert result == 0 + def test_format_time_with_locale_restore_error(self): + """Test _format_time when restoring locale fails and falls back to 'C'.""" + from debx.cli.inspect import _format_time + import locale - output = mock_stdout.getvalue() - assert b"control content" in output - assert b"data content" in output + current_time = int(time.time()) + original_setlocale = locale.setlocale + call_count = [0] + def mock_setlocale(category, loc=None): + call_count[0] += 1 + # First call: getlocale returns tuple + # Second call: setting user_locale - allow it + # Third call: restoring old locale - fail + # Fourth call: fallback to 'C' - allow it + if call_count[0] == 3: + # Fail when trying to restore old locale + raise locale.Error("Cannot restore locale") + if loc == 'C' or loc is None: + return original_setlocale(category, loc) + # Allow setting user_locale + return original_setlocale(category, 'C') -def test_cli_sign_write_signature(mock_package, tmp_path): - signature = b"-----BEGIN PGP SIGNATURE-----\nMockSignature\n-----END PGP SIGNATURE-----" - output_path = tmp_path / "signed.deb" + with patch.object(locale, 'setlocale', side_effect=mock_setlocale): + with patch.object(locale, 'getlocale', return_value=('invalid', 'locale')): + # This should not raise, should fallback to 'C' + result = _format_time(current_time, user_locale='C') + assert len(result) > 0 - args = MagicMock() - args.package = mock_package - args.output = output_path - with patch("sys.stdin", new=io.BytesIO(signature)) as mock_stdin: - mock_stdin.buffer = mock_stdin - result = cli_sign_write_signature(args) - assert result == 0 +class TestFormatSizeDecimal: + """Tests for _format_size with decimal values.""" - with output_path.open("rb") as f: - files = list(unpack_ar_archive(f)) - assert any(file.name == "_gpgorigin" and file.content == signature for file in files) + def test_format_size_decimal(self): + """Test _format_size with sizes that result in decimal values.""" + from debx.cli.inspect import _format_size + # 1536 bytes = 1.5K (not an integer) + result = _format_size(1536) + assert result == "1.5K" -def test_cli_sign_invalid_arguments(mock_package): - args = MagicMock() - args.extract = True - args.update = True - args.package = mock_package - args.output = None + # 2560 bytes = 2.5K + result = _format_size(2560) + assert result == "2.5K" - with patch("debx.cli.sign.log.error") as mock_log: - result = cli_sign(args) - assert result == 1 - mock_log.assert_called_with("Cannot use --extract and --update at the same time") + def test_format_size_integer(self): + """Test _format_size with sizes that result in integer values.""" + from debx.cli.inspect import _format_size - args.extract = False - args.update = False + # 1024 bytes = 1K (integer) + result = _format_size(1024) + assert result == "1K" - with patch("debx.cli.sign.log.error") as mock_log: - result = cli_sign(args) - assert result == 1 - mock_log.assert_called_with("No action specified") + # 2048 bytes = 2K (integer) + result = _format_size(2048) + assert result == "2K" class TestInspectFormatting: @@ -636,243 +543,6 @@ def test_inspect_unknown_format(self, tmp_path): assert "Unknown format" in mock_stderr.getvalue() -class TestCliSign: - """Tests for CLI sign command.""" - - def test_sign_extract_tty_error(self, tmp_path): - """Test sign extract when stdout is tty.""" - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(b"dummy") - - args = Namespace(package=pkg_path, extract=True, update=False, output=None) - - with patch("sys.stdout.isatty", return_value=True): - result = cli_sign_extract_payload(args) - - assert result == 1 - - def test_sign_extract_no_control(self, tmp_path): - """Test sign extract when control file is missing.""" - # Create package without control - ar_content = pack_ar_archive( - ArFile.from_bytes(b"2.0\n", "debian-binary"), - ArFile.from_bytes(b"data", "data.tar.bz2"), - ) - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(ar_content) - - args = Namespace(package=pkg_path) - - with patch("sys.stdout.isatty", return_value=False): - result = cli_sign_extract_payload(args) - - assert result == 1 - - def test_sign_extract_no_data(self, tmp_path): - """Test sign extract when data file is missing.""" - ar_content = pack_ar_archive( - ArFile.from_bytes(b"2.0\n", "debian-binary"), - ArFile.from_bytes(b"control", "control.tar.gz"), - ) - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(ar_content) - - args = Namespace(package=pkg_path) - - with patch("sys.stdout.isatty", return_value=False): - result = cli_sign_extract_payload(args) - - assert result == 1 - - def test_sign_write_invalid_signature(self, tmp_path): - """Test sign write with invalid signature.""" - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(builder.pack()) - - output_path = tmp_path / "signed.deb" - args = Namespace(package=pkg_path, output=output_path) - - with patch("sys.stdin.buffer.read", return_value=b"invalid signature"): - result = cli_sign_write_signature(args) - - assert result == 1 - - def test_sign_both_flags_error(self, tmp_path): - """Test sign with both --extract and --update.""" - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(b"dummy") - - args = Namespace(package=pkg_path, extract=True, update=True, output=None) - result = cli_sign(args) - - assert result == 1 - - def test_sign_extract_with_output_error(self, tmp_path): - """Test sign extract with --output flag.""" - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(b"dummy") - - args = Namespace( - package=pkg_path, extract=True, update=False, - output=tmp_path / "out.deb" - ) - result = cli_sign(args) - - assert result == 1 - - def test_sign_update_default_output(self, tmp_path): - """Test sign update with default output path.""" - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(builder.pack()) - - signature = b"-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----" - - args = Namespace(package=pkg_path, extract=False, update=True, output=None) - - with patch("sys.stdin.buffer.read", return_value=signature): - result = cli_sign(args) - - assert result == 0 - assert (tmp_path / "test.signed.deb").exists() - - def test_sign_update_custom_output(self, tmp_path): - """Test sign update with custom output path (covers branch 87->89).""" - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(builder.pack()) - - signature = b"-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----" - - custom_output = tmp_path / "custom_output.deb" - args = Namespace(package=pkg_path, extract=False, update=True, output=custom_output) - - with patch("sys.stdin.buffer.read", return_value=signature): - result = cli_sign(args) - - assert result == 0 - assert custom_output.exists() - - def test_sign_no_action_error(self, tmp_path): - """Test sign with no action specified.""" - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(b"dummy") - - args = Namespace(package=pkg_path, extract=False, update=False, output=None) - result = cli_sign(args) - - assert result == 1 - - -class TestCliUnpack: - """Tests for CLI unpack command.""" - - def test_unpack_default_directory(self, tmp_path): - """Test unpack with default directory name.""" - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "mypackage.deb" - pkg_path.write_bytes(builder.pack()) - - # Change to tmp_path so the default directory is created there - old_cwd = os.getcwd() - os.chdir(tmp_path) - try: - args = Namespace(package=str(pkg_path), directory=None, keep_archives=False) - result = cli_unpack(args) - finally: - os.chdir(old_cwd) - - assert result == 0 - assert (tmp_path / "mypackage").exists() - - def test_unpack_keep_archives(self, tmp_path): - """Test unpack with --keep-archives flag.""" - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(builder.pack()) - - output_dir = tmp_path / "output" - args = Namespace(package=str(pkg_path), directory=str(output_dir), keep_archives=True) - result = cli_unpack(args) - - assert result == 0 - assert (output_dir / "control.tar.gz").exists() - assert (output_dir / "data.tar.bz2").exists() - - -class TestCliPack: - """Tests for CLI pack command.""" - - def test_parse_file_no_colon(self): - """Test parse_file with missing colon.""" - from argparse import ArgumentTypeError - with pytest.raises(ArgumentTypeError, match="Invalid file format"): - parse_file("nocolon") - - def test_parse_file_symlink(self, tmp_path): - """Test parse_file with symlink.""" - # Create a symlink - target = tmp_path / "target" - target.write_bytes(b"content") - link = tmp_path / "link" - link.symlink_to(target) - - result = list(parse_file(f"{link}:/usr/bin/link")) - assert len(result) == 1 - assert result[0]["name"] == "/usr/bin/link" - - class TestFormatLsIntegration: """Integration tests for format_ls with TarInfoType.""" @@ -1036,108 +706,6 @@ def test_inspect_tar_xz_package(self, tmp_path): assert "data.tar.xz" in output -class TestMainEntryPoint: - """Tests for __main__.py entry point.""" - - def test_main_no_args(self): - """Test main with no arguments shows help.""" - from debx.__main__ import main, PARSER - - with patch.object(sys, 'argv', ['debx']): - with patch.object(PARSER, 'print_help') as mock_help: - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 1 - mock_help.assert_called_once() - - def test_main_inspect_command(self, tmp_path): - """Test main with inspect command.""" - from debx.__main__ import main - - # Create a test package - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(builder.pack()) - - with patch.object(sys, 'argv', ['debx', 'inspect', str(pkg_path)]): - with patch("sys.stdout", new_callable=io.StringIO): - with patch("sys.stdout.isatty", return_value=True): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - - def test_main_pack_command(self, tmp_path): - """Test main with pack command.""" - from debx.__main__ import main - - # Create control file - control_file = tmp_path / "control" - control_file.write_text("""Package: test -Version: 1.0 -Architecture: all -Maintainer: Test -Description: Test -""") - - # Create data file - data_file = tmp_path / "binary" - data_file.write_bytes(b"#!/bin/sh\necho hello") - - output_path = tmp_path / "output.deb" - - with patch.object(sys, 'argv', [ - 'debx', 'pack', - '-c', f'{control_file}:/control', - '-d', f'{data_file}:/usr/bin/test:mode=0755', - '-o', str(output_path) - ]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - - assert output_path.exists() - - def test_main_unpack_command(self, tmp_path): - """Test main with unpack command.""" - from debx.__main__ import main - - # Create a test package - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(builder.pack()) - - output_dir = tmp_path / "output" - - with patch.object(sys, 'argv', [ - 'debx', 'unpack', str(pkg_path), '-d', str(output_dir) - ]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - - assert output_dir.exists() - - class TestInspectNoMd5sums: """Test inspect when md5sums file doesn't exist.""" @@ -1213,68 +781,6 @@ def test_inspect_without_control_tar(self, tmp_path): assert "data.tar.bz2" in output -class TestSignExtractSuccess: - """Test successful sign extract operation.""" - - def test_sign_extract_success(self, tmp_path): - """Test sign extract with valid package.""" - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(builder.pack()) - - args = Namespace(package=pkg_path) - - # Create a mock stdout with buffer attribute - mock_stdout = MagicMock() - mock_stdout.isatty.return_value = False - mock_stdout.buffer = io.BytesIO() - - with patch("sys.stdout", mock_stdout): - result = cli_sign_extract_payload(args) - - assert result == 0 - assert len(mock_stdout.buffer.getvalue()) > 0 - - def test_sign_extract_via_cli_sign(self, tmp_path): - """Test sign extract success through cli_sign function (covers line 85).""" - builder = DebBuilder() - control = Deb822({ - "Package": "test", - "Version": "1.0", - "Architecture": "all", - "Maintainer": "Test ", - "Description": "Test", - }) - builder.add_control_entry("control", control.dump()) - builder.add_data_entry(b"content", "/usr/bin/test") - - pkg_path = tmp_path / "test.deb" - pkg_path.write_bytes(builder.pack()) - - args = Namespace(package=pkg_path, extract=True, update=False, output=None) - - # Create a mock stdout with buffer attribute - mock_stdout = MagicMock() - mock_stdout.isatty.return_value = False - mock_stdout.buffer = io.BytesIO() - - with patch("sys.stdout", mock_stdout): - result = cli_sign(args) - - assert result == 0 - assert len(mock_stdout.buffer.getvalue()) > 0 - - class TestInspectPlainTar: """Test inspect with plain .tar files (no compression).""" diff --git a/tests/test_cli_pack.py b/tests/test_cli_pack.py new file mode 100644 index 0000000..14c9803 --- /dev/null +++ b/tests/test_cli_pack.py @@ -0,0 +1,143 @@ +""" +Tests for CLI pack command. +""" +import tempfile +from argparse import ArgumentTypeError +from pathlib import Path +from unittest.mock import patch + +import pytest + +from debx.cli.pack import parse_file + + +class TestParseFile: + def test_invalid_format(self): + """Test that parse_file raises an error for invalid formats""" + with pytest.raises(ArgumentTypeError, match="Invalid file format"): + list(parse_file("no_colon_here")) + + def test_simple_file(self, tmp_path): + """Test parsing a simple file with no modifiers""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + result = list(parse_file(f"{test_file}:/dest/path")) + assert len(result) == 1 + assert str(result[0]["name"]) == "/dest/path" + assert result[0]["content"] == b"test content" + + def test_file_with_modifiers(self, tmp_path): + """Test parsing a file with modifiers""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + result = list(parse_file(f"{test_file}:/dest/path:mode=0755,uid=1000,gid=2000,mtime=1234567890")) + assert len(result) == 1 + assert str(result[0]["name"]) == "/dest/path" + assert result[0]["content"] == b"test content" + assert result[0]["mode"] == 0o755 + assert result[0]["uid"] == 1000 + assert result[0]["gid"] == 2000 + assert result[0]["mtime"] == 1234567890 + + def test_directory(self, tmp_path): + """Test parsing a directory""" + test_dir = tmp_path / "test_dir" + test_dir.mkdir() + + file1 = test_dir / "file1.txt" + file1.write_text("file1 content") + + subdir = test_dir / "subdir" + subdir.mkdir() + + file2 = subdir / "file2.txt" + file2.write_text("file2 content") + + result = list(parse_file(f"{test_dir}:/dest/path")) + assert len(result) == 2 + + # Sort results to ensure consistent order for testing + result.sort(key=lambda x: str(x["name"])) + + assert str(result[0]["name"]) == "/dest/path/file1.txt" + assert result[0]["content"] == b"file1 content" + + assert str(result[1]["name"]) == "/dest/path/subdir/file2.txt" + assert result[1]["content"] == b"file2 content" + + def test_relative_path_error(self, tmp_path): + """Test that relative destination paths raise an error""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + with pytest.raises(ArgumentTypeError, match="Destination path must be absolute"): + list(parse_file(f"{test_file}:relative/path")) + + +class TestPackDirectoryErrors: + """Tests for pack command directory error handling.""" + + def test_parse_file_relative_dest_error(self): + """Test parse_file with relative destination path.""" + with tempfile.TemporaryDirectory() as tmp: + test_file = Path(tmp) / "test.txt" + test_file.write_bytes(b"content") + + with pytest.raises(ArgumentTypeError, match="must be absolute"): + parse_file(f"{test_file}:relative/path") + + +class TestPackDirectoryMode: + """Tests for pack command directory handling.""" + + def test_parse_file_directory_with_mode(self, tmp_path): + """Test parse_file with directory and mode modifier shows warning.""" + # Create a directory with a file + test_dir = tmp_path / "mydir" + test_dir.mkdir() + (test_dir / "file.txt").write_bytes(b"content") + + with patch("sys.stderr.write") as mock_stderr: + result = list(parse_file(f"{test_dir}:/opt/mydir:mode=0755")) + + # Should have called stderr.write with warning + mock_stderr.assert_called() + assert len(result) == 1 + + def test_parse_file_unsupported_type(self, tmp_path): + """Test parse_file with unsupported file type (non-existent path).""" + # Use a path that exists but is neither file nor directory nor symlink + # by mocking Path.is_file and Path.is_dir to return False + nonexistent = tmp_path / "nonexistent" + + with pytest.raises((ArgumentTypeError, FileNotFoundError)): + list(parse_file(f"{nonexistent}:/var/run/test")) + + def test_parse_file_invalid_regex_match(self): + """Test parse_file when regex doesn't match.""" + # This has a colon but doesn't match the regex properly + with pytest.raises(ArgumentTypeError, match="Invalid file format"): + parse_file("::") # Edge case that has colons but invalid format + + +class TestCliPack: + """Tests for CLI pack command.""" + + def test_parse_file_no_colon(self): + """Test parse_file with missing colon.""" + with pytest.raises(ArgumentTypeError, match="Invalid file format"): + parse_file("nocolon") + + def test_parse_file_symlink(self, tmp_path): + """Test parse_file with symlink.""" + # Create a symlink + target = tmp_path / "target" + target.write_bytes(b"content") + link = tmp_path / "link" + link.symlink_to(target) + + result = list(parse_file(f"{link}:/usr/bin/link")) + assert len(result) == 1 + assert result[0]["name"] == "/usr/bin/link" diff --git a/tests/test_cli_sign.py b/tests/test_cli_sign.py new file mode 100644 index 0000000..793eef9 --- /dev/null +++ b/tests/test_cli_sign.py @@ -0,0 +1,296 @@ +""" +Tests for CLI sign command. +""" +import io +from argparse import Namespace +from unittest.mock import patch, MagicMock + +import pytest + +from debx import ArFile, pack_ar_archive, unpack_ar_archive, DebBuilder, Deb822 +from debx.cli.sign import cli_sign_extract_payload, cli_sign_write_signature, cli_sign + + +@pytest.fixture +def mock_package(tmp_path): + control_file = ArFile(name="control.tar.gz", content=b"control content", size=15) + data_file = ArFile(name="data.tar.gz", content=b"data content", size=12) + package_path = tmp_path / "test.deb" + package_path.write_bytes(pack_ar_archive(control_file, data_file)) + return package_path + + +def test_cli_sign_extract_payload(mock_package, capsys): + args = MagicMock() + args.package = mock_package + args.output = None + + with patch("sys.stdout", new_callable=io.BytesIO) as mock_stdout: + mock_stdout.buffer = mock_stdout + + result = cli_sign_extract_payload(args) + assert result == 0 + + output = mock_stdout.getvalue() + assert b"control content" in output + assert b"data content" in output + + +def test_cli_sign_write_signature(mock_package, tmp_path): + signature = b"-----BEGIN PGP SIGNATURE-----\nMockSignature\n-----END PGP SIGNATURE-----" + output_path = tmp_path / "signed.deb" + + args = MagicMock() + args.package = mock_package + args.output = output_path + + with patch("sys.stdin", new=io.BytesIO(signature)) as mock_stdin: + mock_stdin.buffer = mock_stdin + result = cli_sign_write_signature(args) + assert result == 0 + + with output_path.open("rb") as f: + files = list(unpack_ar_archive(f)) + assert any(file.name == "_gpgorigin" and file.content == signature for file in files) + + +def test_cli_sign_invalid_arguments(mock_package): + args = MagicMock() + args.extract = True + args.update = True + args.package = mock_package + args.output = None + + with patch("debx.cli.sign.log.error") as mock_log: + result = cli_sign(args) + assert result == 1 + mock_log.assert_called_with("Cannot use --extract and --update at the same time") + + args.extract = False + args.update = False + + with patch("debx.cli.sign.log.error") as mock_log: + result = cli_sign(args) + assert result == 1 + mock_log.assert_called_with("No action specified") + + +class TestCliSign: + """Tests for CLI sign command.""" + + def test_sign_extract_tty_error(self, tmp_path): + """Test sign extract when stdout is tty.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=True, update=False, output=None) + + with patch("sys.stdout.isatty", return_value=True): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_extract_no_control(self, tmp_path): + """Test sign extract when control file is missing.""" + # Create package without control + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"data", "data.tar.bz2"), + ) + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=pkg_path) + + with patch("sys.stdout.isatty", return_value=False): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_extract_no_data(self, tmp_path): + """Test sign extract when data file is missing.""" + ar_content = pack_ar_archive( + ArFile.from_bytes(b"2.0\n", "debian-binary"), + ArFile.from_bytes(b"control", "control.tar.gz"), + ) + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(ar_content) + + args = Namespace(package=pkg_path) + + with patch("sys.stdout.isatty", return_value=False): + result = cli_sign_extract_payload(args) + + assert result == 1 + + def test_sign_write_invalid_signature(self, tmp_path): + """Test sign write with invalid signature.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_path = tmp_path / "signed.deb" + args = Namespace(package=pkg_path, output=output_path) + + with patch("sys.stdin.buffer.read", return_value=b"invalid signature"): + result = cli_sign_write_signature(args) + + assert result == 1 + + def test_sign_both_flags_error(self, tmp_path): + """Test sign with both --extract and --update.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=True, update=True, output=None) + result = cli_sign(args) + + assert result == 1 + + def test_sign_extract_with_output_error(self, tmp_path): + """Test sign extract with --output flag.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace( + package=pkg_path, extract=True, update=False, + output=tmp_path / "out.deb" + ) + result = cli_sign(args) + + assert result == 1 + + def test_sign_update_default_output(self, tmp_path): + """Test sign update with default output path.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + signature = b"-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----" + + args = Namespace(package=pkg_path, extract=False, update=True, output=None) + + with patch("sys.stdin.buffer.read", return_value=signature): + result = cli_sign(args) + + assert result == 0 + assert (tmp_path / "test.signed.deb").exists() + + def test_sign_update_custom_output(self, tmp_path): + """Test sign update with custom output path (covers branch 87->89).""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + signature = b"-----BEGIN PGP SIGNATURE-----\ntest\n-----END PGP SIGNATURE-----" + + custom_output = tmp_path / "custom_output.deb" + args = Namespace(package=pkg_path, extract=False, update=True, output=custom_output) + + with patch("sys.stdin.buffer.read", return_value=signature): + result = cli_sign(args) + + assert result == 0 + assert custom_output.exists() + + def test_sign_no_action_error(self, tmp_path): + """Test sign with no action specified.""" + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(b"dummy") + + args = Namespace(package=pkg_path, extract=False, update=False, output=None) + result = cli_sign(args) + + assert result == 1 + + +class TestSignExtractSuccess: + """Test successful sign extract operation.""" + + def test_sign_extract_success(self, tmp_path): + """Test sign extract with valid package.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=pkg_path) + + # Create a mock stdout with buffer attribute + mock_stdout = MagicMock() + mock_stdout.isatty.return_value = False + mock_stdout.buffer = io.BytesIO() + + with patch("sys.stdout", mock_stdout): + result = cli_sign_extract_payload(args) + + assert result == 0 + assert len(mock_stdout.buffer.getvalue()) > 0 + + def test_sign_extract_via_cli_sign(self, tmp_path): + """Test sign extract success through cli_sign function (covers line 85).""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + args = Namespace(package=pkg_path, extract=True, update=False, output=None) + + # Create a mock stdout with buffer attribute + mock_stdout = MagicMock() + mock_stdout.isatty.return_value = False + mock_stdout.buffer = io.BytesIO() + + with patch("sys.stdout", mock_stdout): + result = cli_sign(args) + + assert result == 0 + assert len(mock_stdout.buffer.getvalue()) > 0 diff --git a/tests/test_cli_unpack.py b/tests/test_cli_unpack.py new file mode 100644 index 0000000..9c98668 --- /dev/null +++ b/tests/test_cli_unpack.py @@ -0,0 +1,90 @@ +""" +Tests for CLI unpack command. +""" +import os +from argparse import Namespace + +from debx import DebBuilder, Deb822 +from debx.cli.unpack import cli_unpack + + +class TestCliUnpack: + """Tests for CLI unpack command.""" + + def test_unpack_default_directory(self, tmp_path): + """Test unpack with default directory name.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "mypackage.deb" + pkg_path.write_bytes(builder.pack()) + + # Change to tmp_path so the default directory is created there + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + args = Namespace(package=str(pkg_path), directory=None, keep_archives=False) + result = cli_unpack(args) + finally: + os.chdir(old_cwd) + + assert result == 0 + assert (tmp_path / "mypackage").exists() + + def test_unpack_keep_archives(self, tmp_path): + """Test unpack with --keep-archives flag.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_dir = tmp_path / "output" + args = Namespace(package=str(pkg_path), directory=str(output_dir), keep_archives=True) + result = cli_unpack(args) + + assert result == 0 + assert (output_dir / "control.tar.gz").exists() + assert (output_dir / "data.tar.bz2").exists() + + def test_unpack_with_directory(self, tmp_path): + """Test unpack with specified directory.""" + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_dir = tmp_path / "custom_output" + args = Namespace(package=str(pkg_path), directory=str(output_dir), keep_archives=False) + result = cli_unpack(args) + + assert result == 0 + assert output_dir.exists() + assert (output_dir / "debian-binary").exists() + assert (output_dir / "control").exists() + assert (output_dir / "data").exists() diff --git a/tests/test_coverage.py b/tests/test_coverage.py deleted file mode 100644 index fb3dfd6..0000000 --- a/tests/test_coverage.py +++ /dev/null @@ -1,371 +0,0 @@ -""" -Additional tests to increase code coverage. -""" -import csv -import io -import json -import os -import stat -import sys -import tempfile -import time -from argparse import Namespace -from pathlib import Path -from unittest.mock import patch, MagicMock - -import pytest - -from debx import DebBuilder, unpack_ar_archive -from debx.cli.inspect import format_ls -from debx.cli.types import InspectItem -from debx.cli.pack import parse_file - - -class TestPackDirectoryErrors: - """Tests for pack command directory error handling.""" - - def test_parse_file_relative_dest_error(self): - """Test parse_file with relative destination path.""" - from argparse import ArgumentTypeError - - with tempfile.TemporaryDirectory() as tmp: - test_file = Path(tmp) / "test.txt" - test_file.write_bytes(b"content") - - with pytest.raises(ArgumentTypeError, match="must be absolute"): - parse_file(f"{test_file}:relative/path") - - -class TestStatModeFallback: - """Tests for stat mode fallback in format_mode.""" - - def test_format_ls_type_none_regular_mode(self): - """Test format_ls with type=None and regular file mode.""" - regular_mode = 0o100644 # Regular file with 644 permissions - items = [ - InspectItem( - file="regular.txt", - size=100, - type=None, - mode=regular_mode, - uid=0, - gid=0, - mtime=int(time.time()), - md5=None, - path=None, - ) - ] - result = format_ls(items) - lines = result.strip().split('\n') - # Should show as regular file with '-' prefix - assert any('-rw-r--r--' in line for line in lines) - - def test_format_ls_type_none_dir_mode(self): - """Test format_ls with type=None and directory mode.""" - dir_mode = stat.S_IFDIR | 0o755 # Directory with 755 permissions - items = [ - InspectItem( - file="mydir", - size=0, - type=None, - mode=dir_mode, - uid=0, - gid=0, - mtime=int(time.time()), - md5=None, - path=None, - ) - ] - result = format_ls(items) - lines = result.strip().split('\n') - # Should show as directory with 'd' prefix - assert any('drwxr-xr-x' in line for line in lines) - - def test_format_ls_type_none_symlink_mode(self): - """Test format_ls with type=None and symlink mode.""" - link_mode = stat.S_IFLNK | 0o777 # Symlink with 777 permissions - items = [ - InspectItem( - file="mylink", - size=0, - type=None, - mode=link_mode, - uid=0, - gid=0, - mtime=int(time.time()), - md5=None, - path=None, - ) - ] - result = format_ls(items) - lines = result.strip().split('\n') - # Should show as symlink with 'l' prefix - assert any('lrwxrwxrwx' in line for line in lines) - - def test_format_ls_unknown_type_dir_mode_fallback(self): - """Test format_ls with unknown type that falls back to stat dir check.""" - # Use an unknown type string but with directory mode - dir_mode = stat.S_IFDIR | 0o755 - items = [ - InspectItem( - file="unknown_dir", - size=0, - type="unknown_custom_type", # Not a recognized type - mode=dir_mode, - uid=0, - gid=0, - mtime=int(time.time()), - md5=None, - path=None, - ) - ] - result = format_ls(items) - lines = result.strip().split('\n') - # Should fall back to stat check and show 'd' - assert any('d' in line for line in lines[1:]) - - def test_format_ls_unknown_type_symlink_mode_fallback(self): - """Test format_ls with unknown type that falls back to stat symlink check.""" - # Use an unknown type string but with symlink mode - link_mode = stat.S_IFLNK | 0o777 - items = [ - InspectItem( - file="unknown_link", - size=0, - type="unknown_custom_type", # Not a recognized type - mode=link_mode, - uid=0, - gid=0, - mtime=int(time.time()), - md5=None, - path=None, - ) - ] - result = format_ls(items) - lines = result.strip().split('\n') - # Should fall back to stat check and show 'l' - assert any('l' in line for line in lines[1:]) - - -class TestPackDirectoryMode: - """Tests for pack command directory handling.""" - - def test_parse_file_directory_with_mode(self, tmp_path): - """Test parse_file with directory and mode modifier shows warning.""" - # Create a directory with a file - test_dir = tmp_path / "mydir" - test_dir.mkdir() - (test_dir / "file.txt").write_bytes(b"content") - - with patch("sys.stderr.write") as mock_stderr: - result = list(parse_file(f"{test_dir}:/opt/mydir:mode=0755")) - - # Should have called stderr.write with warning - mock_stderr.assert_called() - assert len(result) == 1 - - def test_parse_file_unsupported_type(self, tmp_path): - """Test parse_file with unsupported file type (non-existent path).""" - from argparse import ArgumentTypeError - - # Use a path that exists but is neither file nor directory nor symlink - # by mocking Path.is_file and Path.is_dir to return False - nonexistent = tmp_path / "nonexistent" - - with pytest.raises((ArgumentTypeError, FileNotFoundError)): - list(parse_file(f"{nonexistent}:/var/run/test")) - - def test_parse_file_invalid_regex_match(self): - """Test parse_file when regex doesn't match.""" - from argparse import ArgumentTypeError - - # This has a colon but doesn't match the regex properly - with pytest.raises(ArgumentTypeError, match="Invalid file format"): - parse_file("::") # Edge case that has colons but invalid format - - -class TestInvalidArArchive: - """Tests for invalid AR archive handling.""" - - def test_invalid_ar_magic(self): - """Test unpack_ar_archive with invalid magic bytes.""" - invalid_ar = b"INVALID!\nsome data" - with pytest.raises(ValueError, match="Invalid ar archive"): - list(unpack_ar_archive(io.BytesIO(invalid_ar))) - - def test_truncated_ar_magic(self): - """Test unpack_ar_archive with truncated magic bytes.""" - truncated_ar = b"! 0 - - def test_format_time_with_none_mtime(self): - """Test _format_time with None mtime.""" - from debx.cli.inspect import _format_time - - result = _format_time(None) - assert result == " " - - def test_format_time_with_valid_locale(self): - """Test _format_time with a valid locale.""" - from debx.cli.inspect import _format_time - import locale - - current_time = int(time.time()) - - # Use 'C' locale which should always be available - result = _format_time(current_time, user_locale='C') - assert len(result) > 0 - - def test_format_time_with_invalid_locale_on_set(self): - """Test _format_time when setting locale fails.""" - from debx.cli.inspect import _format_time - import locale - - current_time = int(time.time()) - original_setlocale = locale.setlocale - - def mock_setlocale(category, loc=None): - if loc is not None and loc not in (None, '', ('en_US', 'UTF-8'), ('C', 'UTF-8'), 'C'): - raise locale.Error("Invalid locale") - return original_setlocale(category, loc) - - with patch.object(locale, 'setlocale', side_effect=mock_setlocale): - # This should not raise, just silently ignore the locale error - result = _format_time(current_time, user_locale='invalid_locale_xyz') - assert len(result) > 0 - - def test_format_time_with_locale_restore_error(self): - """Test _format_time when restoring locale fails and falls back to 'C'.""" - from debx.cli.inspect import _format_time - import locale - - current_time = int(time.time()) - original_setlocale = locale.setlocale - call_count = [0] - - def mock_setlocale(category, loc=None): - call_count[0] += 1 - # First call: getlocale returns tuple - # Second call: setting user_locale - allow it - # Third call: restoring old locale - fail - # Fourth call: fallback to 'C' - allow it - if call_count[0] == 3: - # Fail when trying to restore old locale - raise locale.Error("Cannot restore locale") - if loc == 'C' or loc is None: - return original_setlocale(category, loc) - # Allow setting user_locale - return original_setlocale(category, 'C') - - with patch.object(locale, 'setlocale', side_effect=mock_setlocale): - with patch.object(locale, 'getlocale', return_value=('invalid', 'locale')): - # This should not raise, should fallback to 'C' - result = _format_time(current_time, user_locale='C') - assert len(result) > 0 - - -class TestDeb822ContinuationWithoutField: - """Tests for Deb822 parsing edge cases.""" - - def test_continuation_line_without_prior_field(self): - """Test parsing continuation line without prior field definition.""" - from debx import Deb822 - - # A line starting with space but no prior field defined - text = " continuation without field\nPackage: test\n" - result = Deb822.parse(text) - # Should just skip the orphan continuation line - assert result["Package"] == "test" - - def test_continuation_after_comment(self): - """Test continuation line after a comment.""" - from debx import Deb822 - - text = "# comment\n continuation\nPackage: test\n" - result = Deb822.parse(text) - assert result["Package"] == "test" - - -class TestFormatSizeDecimal: - """Tests for _format_size with decimal values.""" - - def test_format_size_decimal(self): - """Test _format_size with sizes that result in decimal values.""" - from debx.cli.inspect import _format_size - - # 1536 bytes = 1.5K (not an integer) - result = _format_size(1536) - assert result == "1.5K" - - # 2560 bytes = 2.5K - result = _format_size(2560) - assert result == "2.5K" - - def test_format_size_integer(self): - """Test _format_size with sizes that result in integer values.""" - from debx.cli.inspect import _format_size - - # 1024 bytes = 1K (integer) - result = _format_size(1024) - assert result == "1K" - - # 2048 bytes = 2K (integer) - result = _format_size(2048) - assert result == "2K" - - diff --git a/tests/test_deb822.py b/tests/test_deb822.py index b379dc0..ae10206 100644 --- a/tests/test_deb822.py +++ b/tests/test_deb822.py @@ -125,3 +125,21 @@ def test_mapping_update(): assert deb["Package"] == "pkg" deb.update([("Arch", "amd64"), ("Priority", "optional")]) assert set(deb.keys()) == {"Package", "Version", "Arch", "Priority"} + + +class TestDeb822ContinuationWithoutField: + """Tests for Deb822 parsing edge cases.""" + + def test_continuation_line_without_prior_field(self): + """Test parsing continuation line without prior field definition.""" + # A line starting with space but no prior field defined + text = " continuation without field\nPackage: test\n" + result = Deb822.parse(text) + # Should just skip the orphan continuation line + assert result["Package"] == "test" + + def test_continuation_after_comment(self): + """Test continuation line after a comment.""" + text = "# comment\n continuation\nPackage: test\n" + result = Deb822.parse(text) + assert result["Package"] == "test" diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py new file mode 100644 index 0000000..cc5345e --- /dev/null +++ b/tests/test_entrypoint.py @@ -0,0 +1,112 @@ +""" +Tests for CLI entry point (__main__.py). +""" +import io +import sys +from unittest.mock import patch + +import pytest + +from debx import DebBuilder, Deb822 + + +class TestEntryPoint: + """Tests for __main__.py entry point.""" + + def test_main_no_args(self): + """Test main with no arguments shows help.""" + from debx.__main__ import main, PARSER + + with patch.object(sys, 'argv', ['debx']): + with patch.object(PARSER, 'print_help') as mock_help: + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + mock_help.assert_called_once() + + def test_main_inspect_command(self, tmp_path): + """Test main with inspect command.""" + from debx.__main__ import main + + # Create a test package + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + with patch.object(sys, 'argv', ['debx', 'inspect', str(pkg_path)]): + with patch("sys.stdout", new_callable=io.StringIO): + with patch("sys.stdout.isatty", return_value=True): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + def test_main_pack_command(self, tmp_path): + """Test main with pack command.""" + from debx.__main__ import main + + # Create control file + control_file = tmp_path / "control" + control_file.write_text("""Package: test +Version: 1.0 +Architecture: all +Maintainer: Test +Description: Test +""") + + # Create data file + data_file = tmp_path / "binary" + data_file.write_bytes(b"#!/bin/sh\necho hello") + + output_path = tmp_path / "output.deb" + + with patch.object(sys, 'argv', [ + 'debx', 'pack', + '-c', f'{control_file}:/control', + '-d', f'{data_file}:/usr/bin/test:mode=0755', + '-o', str(output_path) + ]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + assert output_path.exists() + + def test_main_unpack_command(self, tmp_path): + """Test main with unpack command.""" + from debx.__main__ import main + + # Create a test package + builder = DebBuilder() + control = Deb822({ + "Package": "test", + "Version": "1.0", + "Architecture": "all", + "Maintainer": "Test ", + "Description": "Test", + }) + builder.add_control_entry("control", control.dump()) + builder.add_data_entry(b"content", "/usr/bin/test") + + pkg_path = tmp_path / "test.deb" + pkg_path.write_bytes(builder.pack()) + + output_dir = tmp_path / "output" + + with patch.object(sys, 'argv', [ + 'debx', 'unpack', str(pkg_path), '-d', str(output_dir) + ]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + assert output_dir.exists() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..fe0215a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,198 @@ +""" +Integration tests for CLI commands working together. +""" +import os +from unittest.mock import MagicMock + +import pytest + +from debx.cli.inspect import cli_inspect +from debx.cli.pack import cli_pack +from debx.cli.unpack import cli_unpack + + +@pytest.fixture +def test_package_structure(tmp_path): + """Create a test package structure for integration tests""" + # Create some control files + control_dir = tmp_path / "control" + control_dir.mkdir() + + control_file = control_dir / "control" + control_file.write_text( + "Package: test-package\n" + "Version: 1.0.0\n" + "Architecture: all\n" + "Maintainer: Test \n" + "Description: Test package\n" + " This is a test package for testing purposes.\n" + ) + + # Create some data files + data_dir = tmp_path / "data" + data_dir.mkdir() + + bin_dir = data_dir / "bin" + bin_dir.mkdir(parents=True) + + bin_file = bin_dir / "test-script" + bin_file.write_text("#!/bin/sh\necho 'Hello, world!'\n") + bin_file.chmod(0o755) + + etc_dir = data_dir / "etc" / "test-package" + etc_dir.mkdir(parents=True) + + config_file = etc_dir / "config" + config_file.write_text("# Test configuration\nSETTING=value\n") + + return tmp_path + + +class TestPackUnpackIntegration: + """Integration tests for pack and unpack commands.""" + + def test_pack_and_unpack(self, test_package_structure, tmp_path): + """Integration test for packing and unpacking a deb package""" + # Skip if running in CI without proper permissions + if "CI" in os.environ: + pytest.skip("Skipping integration test in CI environment") + + package_dir = test_package_structure + output_deb = tmp_path / "output.deb" + extract_dir = tmp_path / "extract" + extract_dir.mkdir() + + # Pack arguments + pack_args = MagicMock() + pack_args.control = [ + [{"content": (package_dir / "control" / "control").read_bytes(), + "name": "control", "mode": 0o644}] + ] + pack_args.data = [ + [{"content": (package_dir / "data" / "bin" / "test-script").read_bytes(), + "name": "/usr/bin/test-script", "mode": 0o755}], + [{"content": (package_dir / "data" / "etc" / "test-package" / "config").read_bytes(), + "name": "/etc/test-package/config", "mode": 0o644}] + ] + pack_args.deb = str(output_deb) + + # Run pack command + cli_pack(pack_args) + + # Verify deb file was created + assert output_deb.exists() + + # Unpack arguments + unpack_args = MagicMock() + unpack_args.package = str(output_deb) + unpack_args.directory = str(extract_dir) + + # Run unpack command + cli_unpack(unpack_args) + + # Verify files were extracted + assert (extract_dir / "debian-binary").exists() + assert (extract_dir / "control").exists() + assert (extract_dir / "data").exists() + + +class TestPackInspectIntegration: + """Integration tests for pack and inspect commands.""" + + def test_inspect(self, test_package_structure): + """Test the inspect command on a packed package""" + package_dir = test_package_structure + output_deb = package_dir / "output.deb" + + # Pack the package + pack_args = MagicMock() + pack_args.control = [ + [{"content": (package_dir / "control" / "control").read_bytes(), + "name": "control", "mode": 0o644}] + ] + pack_args.data = [ + [{"content": (package_dir / "data" / "bin" / "test-script").read_bytes(), + "name": "/usr/bin/test-script", "mode": 0o755}], + [{"content": (package_dir / "data" / "etc" / "test-package" / "config").read_bytes(), + "name": "/etc/test-package/config", "mode": 0o644}] + ] + pack_args.deb = str(output_deb) + + # Run pack command + cli_pack(pack_args) + + # Inspect arguments + inspect_args = MagicMock() + inspect_args.package = str(output_deb) + + # Run inspect command + cli_inspect(inspect_args) + + # Verify output + assert output_deb.exists() + + def test_inspect_format_ls(self, test_package_structure): + """Test the inspect command with --format=ls""" + package_dir = test_package_structure + output_deb = package_dir / "output.deb" + + # Pack the package + pack_args = MagicMock() + pack_args.control = [ + [{"content": (package_dir / "control" / "control").read_bytes(), + "name": "control", "mode": 0o644}] + ] + pack_args.data = [ + [{"content": (package_dir / "data" / "bin" / "test-script").read_bytes(), + "name": "/usr/bin/test-script", "mode": 0o755}], + [{"content": (package_dir / "data" / "etc" / "test-package" / "config").read_bytes(), + "name": "/etc/test-package/config", "mode": 0o644}] + ] + pack_args.deb = str(output_deb) + + # Run pack command + cli_pack(pack_args) + + # Inspect arguments + inspect_args = MagicMock() + inspect_args.package = str(output_deb) + inspect_args.format = 'ls' + + # Run inspect command + cli_inspect(inspect_args) + + # Verify output + assert output_deb.exists() + + def test_inspect_format_find(self, test_package_structure): + """Test the inspect command with --format=find""" + package_dir = test_package_structure + output_deb = package_dir / "output.deb" + + # Pack the package + pack_args = MagicMock() + pack_args.control = [ + [{"content": (package_dir / "control" / "control").read_bytes(), + "name": "control", "mode": 0o644}] + ] + pack_args.data = [ + [{"content": (package_dir / "data" / "bin" / "test-script").read_bytes(), + "name": "/usr/bin/test-script", "mode": 0o755}], + [{"content": (package_dir / "data" / "etc" / "test-package" / "config").read_bytes(), + "name": "/etc/test-package/config", "mode": 0o644}] + ] + pack_args.deb = str(output_deb) + + # Run pack command + cli_pack(pack_args) + + # Inspect arguments + inspect_args = MagicMock() + inspect_args.package = str(output_deb) + inspect_args.format = 'find' + + # Run inspect command + cli_inspect(inspect_args) + + # Verify output + assert output_deb.exists() diff --git a/uv.lock b/uv.lock index 751e3c8..1e35a04 100644 --- a/uv.lock +++ b/uv.lock @@ -247,6 +247,7 @@ dev = [ { name = "markdown-pytest" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -257,6 +258,7 @@ dev = [ { name = "markdown-pytest", specifier = ">=0.3.2" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.14.10" }, ] [[package]] @@ -394,6 +396,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + [[package]] name = "tomli" version = "2.3.0" From 420a2be266c47576483f2230bf90e331c8e1254c Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Wed, 7 Jan 2026 15:32:44 +0100 Subject: [PATCH 4/5] Add keywords and bump --- pyproject.toml | 43 ++++++++++++++++++++++++++++++++++++++----- uv.lock | 2 +- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c7969c6..e55db70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "debx" -version = "0.2.11" -description = "Minimal Python library to programmatically construct Debian .deb packages" +version = "0.2.12" +description = "Minimal Python library to programmatically construct and inspect Debian .deb packages" readme = "README.md" authors = [ { name = "Dmitry Orlov", email = "me@mosquito.su" } @@ -9,16 +9,51 @@ authors = [ license = "MIT" license-files = ["COPYING"] requires-python = ">=3.10" +keywords = [ + "administrator", + "apt", + "ar", + "archive", + "automation", + "build", + "cross-platform", + "deb", + "debian", + "development", + "devops", + "dpkg", + "infrastructure", + "library", + "linux", + "macos", + "package", + "packaging", + "system", + "tool", + "windows", +] + classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", + "Environment :: Console", "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Archiving :: Packaging", + "Topic :: System :: Installation/Setup", + "Topic :: System :: Software Distribution", "Typing :: Typed", ] @@ -52,7 +87,5 @@ source = ["debx"] [tool.coverage.report] exclude_lines = [ - "pragma: no cover", - "if TYPE_CHECKING:", "if __name__ == .__main__.:", ] diff --git a/uv.lock b/uv.lock index 1e35a04..c2b6e20 100644 --- a/uv.lock +++ b/uv.lock @@ -238,7 +238,7 @@ wheels = [ [[package]] name = "debx" -version = "0.2.11" +version = "0.2.12" source = { editable = "." } [package.dev-dependencies] From 0a97f1ad01bc52ffc3c12dd8698c2788d2312a20 Mon Sep 17 00:00:00 2001 From: Dmitry Orlov Date: Wed, 7 Jan 2026 15:36:32 +0100 Subject: [PATCH 5/5] Use path for windows --- tests/test_builder.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_builder.py b/tests/test_builder.py index ce42592..50034c0 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -157,8 +157,11 @@ def test_root_directory_skip_in_get_directories(self): builder = DebBuilder() builder.add_data_entry(b"content", "/usr/bin/test") - # Manually add "/" to directories to test the skip logic - builder.directories.add(Path("/")) + # Get one of the existing directories to determine the path type used + existing_dir = next(iter(builder.directories)) + # Create root path using the same path type for sorting compatibility + root_path = type(existing_dir)("/") + builder.directories.add(root_path) # Get directories - should skip "/" dirs = list(builder.get_directories()) @@ -169,6 +172,7 @@ def test_root_directory_skip_in_get_directories(self): assert d.name != "" # But other directories should be present - dir_paths = [d.name for d in dirs] - assert "usr" in dir_paths - assert "usr/bin" in dir_paths + # Normalize backslashes to forward slashes for cross-platform compatibility + dir_parts = [PurePosixPath(d.name.replace("\\", "/")).parts for d in dirs] + assert ("usr",) in dir_parts + assert ("usr", "bin") in dir_parts