From 058b5c1eb5fb3bced6ba98a2d0436594f0f6e73d Mon Sep 17 00:00:00 2001 From: venus Date: Sun, 19 Jul 2026 03:46:26 -0500 Subject: [PATCH] [GEMINI] Organize test suite to mirror new ctf.cli and ctf.forensics package structures --- tests/{test_commands.py => cli/test_basic.py} | 3 + .../{test_cli_helpers.py => cli/test_flag.py} | 10 +- tests/cli/test_forensics.py | 204 +++++++++++ tests/{ => cli}/test_steg.py | 21 +- .../test_metadata.py} | 317 +++--------------- tests/forensics/test_parsers.py | 30 ++ 6 files changed, 304 insertions(+), 281 deletions(-) rename tests/{test_commands.py => cli/test_basic.py} (98%) rename tests/{test_cli_helpers.py => cli/test_flag.py} (95%) create mode 100644 tests/cli/test_forensics.py rename tests/{ => cli}/test_steg.py (93%) rename tests/{test_forensics.py => forensics/test_metadata.py} (53%) create mode 100644 tests/forensics/test_parsers.py diff --git a/tests/test_commands.py b/tests/cli/test_basic.py similarity index 98% rename from tests/test_commands.py rename to tests/cli/test_basic.py index 6f7ba88..a8b2b79 100644 --- a/tests/test_commands.py +++ b/tests/cli/test_basic.py @@ -1,6 +1,9 @@ +# tests/cli/test_basic.py +# {{{ imports from click.testing import CliRunner from ctf.cli.basic import basic_group from ctf.config import Config +# }}} # {{{ test_basic_test_cli def test_basic_test_cli(): diff --git a/tests/test_cli_helpers.py b/tests/cli/test_flag.py similarity index 95% rename from tests/test_cli_helpers.py rename to tests/cli/test_flag.py index 58a7e9b..a463335 100644 --- a/tests/test_cli_helpers.py +++ b/tests/cli/test_flag.py @@ -1,17 +1,19 @@ -# tests/test_cli_helpers.py +# tests/cli/test_flag.py +# {{{ imports import sys import pytest from pathlib import Path from click.testing import CliRunner from unittest.mock import patch from ctf.config import Config +# }}} # {{{ test_flag_cmd_cli def test_flag_cmd_cli(): """ Verifies the ctf flag command outputs last_flag with and without --plain. """ - from ctf.main import cli + from ctf.cli import cli cfg = Config() cfg.data["Competition"]["last_flag"] = "flag{test_cli_flag}" @@ -59,7 +61,7 @@ def test_flag_cmd_list_format(): """ Verifies that ctf flag --list / -l prints the current flag format. """ - from ctf.main import cli + from ctf.cli import cli cfg = Config() cfg.data["Competition"]["flag_format"] = "TEST_FORMAT{[a-z]+}" @@ -83,7 +85,7 @@ def test_flag_cmd_set_format(): """ Verifies that ctf flag --set / -s updates the flag format in config.toml. """ - from ctf.main import cli + from ctf.cli import cli runner = CliRunner() result = runner.invoke(cli, ["flag", "-s", "NEW_FLAG_FORMAT{[0-9]+}"]) diff --git a/tests/cli/test_forensics.py b/tests/cli/test_forensics.py new file mode 100644 index 0000000..006eaab --- /dev/null +++ b/tests/cli/test_forensics.py @@ -0,0 +1,204 @@ +# tests/cli/test_forensics.py +# {{{ imports +import struct +from pathlib import Path +from click.testing import CliRunner +from ctf.cli.forensics import forensics_group +# }}} + +# Define the persistent test environment path +TEST_ENV = Path("tests/env") + +# {{{ test_forensics_metadata_cli_success +def test_forensics_metadata_cli_success(): + """ + Verifies that 'forensics metadata' successfully runs via the CLI and prints + the metadata in a formatted table. + """ + runner = CliRunner() + test_file = TEST_ENV / "test_file.png" + with open(test_file, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + + result = runner.invoke(forensics_group, ["metadata", str(test_file)]) + assert result.exit_code == 0 + assert "Metadata: test_file.png" in result.output + assert "Size" in result.output + assert "89504E47" in result.output + assert "Detected Type" in result.output + assert "PNG Image" in result.output +# }}} + +# {{{ test_nested_metadata_cli_success +def test_nested_metadata_cli_success(): + """ + Verifies that 'ctf forensics metadata' successfully runs via the root CLI. + """ + from ctf.main import cli + runner = CliRunner() + test_file = TEST_ENV / "test_file_nested.png" + with open(test_file, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + + result = runner.invoke(cli, ["forensics", "metadata", str(test_file)]) + assert result.exit_code == 0 + assert "Metadata: test_file_nested.png" in result.output + assert "PNG Image" in result.output +# }}} + +# {{{ test_forensics_metadata_cli_missing_file +def test_forensics_metadata_cli_missing_file(): + """ + Verifies that the CLI fails gracefully when a non-existent file path is specified. + """ + runner = CliRunner() + result = runner.invoke(forensics_group, ["metadata", "non_existent_file.png"]) + assert result.exit_code != 0 + assert "does not exist" in result.output +# }}} + +# {{{ test_forensics_signatures_cli +def test_forensics_signatures_cli(): + """ + Verifies that 'forensics signatures' runs successfully and displays + the list of supported file signatures. + """ + runner = CliRunner() + result = runner.invoke(forensics_group, ["signatures"]) + assert result.exit_code == 0 + assert "Supported Magic Signatures" in result.output + assert "PNG Image" in result.output + assert "89504E47" in result.output +# }}} + +# {{{ test_exif_cli_options +def test_exif_cli_options(): + """ + Verifies CLI options -i/--inode and -e/--exif work as expected. + """ + from ctf.main import cli + runner = CliRunner() + test_file = TEST_ENV / "mock_exif_image.png" + + # Verify mutual exclusion error + result_err = runner.invoke(cli, ["forensics", "metadata", "-i", "-e", str(test_file)]) + assert result_err.exit_code != 0 + assert "Cannot specify more than one" in result_err.output + + # Verify --inode hides EXIF data table + result_inode = runner.invoke(cli, ["forensics", "metadata", "-i", str(test_file)]) + assert result_inode.exit_code == 0 + assert "Metadata: mock_exif_image.png" in result_inode.output + assert "EXIF Metadata" not in result_inode.output + + # Verify --exif hides POSIX table but displays EXIF data table + result_exif = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)]) + assert result_exif.exit_code == 0 + assert "Metadata: mock_exif_image.png" not in result_exif.output + assert "EXIF Metadata" in result_exif.output + assert "mock_exif_image" in result_exif.output + assert "Make" in result_exif.output + assert "TEST" in result_exif.output +# }}} + +# {{{ test_exif_cli_options_warning +def test_exif_cli_options_warning(): + """ + Verifies CLI warns when no EXIF or comment is found and -e option is used. + """ + from ctf.main import cli + runner = CliRunner() + test_file = TEST_ENV / "clean_no_exif.png" + with open(test_file, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + + result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)]) + assert result.exit_code == 0 + assert "No EXIF or comment metadata found" in result.output +# }}} + +# {{{ test_exif_cli_comment_only +def test_exif_cli_comment_only(): + """ + Verifies CLI displays comment when -e is used and only comment is found. + """ + from ctf.main import cli + runner = CliRunner() + comment_text = b"flag{comment_only}" + comment_len = len(comment_text) + 2 + mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + + test_file = TEST_ENV / "comment_only.jpg" + with open(test_file, "wb") as f: + f.write(mock_jpeg) + + result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)]) + assert result.exit_code == 0 + assert "Comment" in result.output + assert "flag{comment_only}" in result.output +# }}} + +# {{{ test_adobe_xmp_cli +def test_adobe_xmp_cli(): + """ + Verifies that the ctf inspect CLI prints the parsed XMP fields. + """ + from ctf.main import cli + runner = CliRunner() + test_file = TEST_ENV / "mock_xmp.jpg" + + result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)]) + assert result.exit_code == 0 + assert "EXIF Metadata" in result.output + assert "cc:license" in result.output + assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output +# }}} + +# {{{ test_cli_physical_option +def test_cli_physical_option(): + """ + Verifies the ctf forensics metadata CLI supports -p/--physical and mutual exclusion rules. + """ + from ctf.main import cli + runner = CliRunner() + test_file = TEST_ENV / "mock_physical.jpg" + + # Mutual exclusion check + result_err = runner.invoke(cli, ["forensics", "metadata", "-p", "-e", str(test_file)]) + assert result_err.exit_code != 0 + assert "Cannot specify more than one" in result_err.output + + # Physical view check + result_phys = runner.invoke(cli, ["forensics", "metadata", "-p", str(test_file)]) + assert result_phys.exit_code == 0 + assert "Physical Metadata" in result_phys.output + assert "Image Size" in result_phys.output + assert "1500x1000" in result_phys.output + assert "Metadata: mock_physical.jpg" not in result_phys.output + assert "EXIF Metadata" not in result_phys.output +# }}} + +# {{{ test_metadata_decoded_hints_cli +def test_metadata_decoded_hints_cli(): + """ + Verifies that metadata command successfully decodes and prints hex/base64 encoded metadata hints. + """ + from ctf.main import cli + runner = CliRunner() + + # Create a mock JPEG with a base64 encoded comment: "ZmxhZ3tiNjRfbWV0YWRhdGF9" -> "flag{b64_metadata}" + comment_text = b"ZmxhZ3tiNjRfbWV0YWRhdGF9" + comment_len = len(comment_text) + 2 + mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + b"\xff\xd9" + + test_file = TEST_ENV / "mock_encoded_comment.jpg" + with open(test_file, "wb") as f: + f.write(mock_jpeg) + + result = runner.invoke(cli, ["forensics", "metadata", str(test_file)]) + assert result.exit_code == 0 + assert "Decoded Metadata Hints" in result.output + assert "Comment" in result.output + assert "base64" in result.output + assert "flag{b64_metadata}" in result.output +# }}} diff --git a/tests/test_steg.py b/tests/cli/test_steg.py similarity index 93% rename from tests/test_steg.py rename to tests/cli/test_steg.py index b9e27a2..460c43a 100644 --- a/tests/test_steg.py +++ b/tests/cli/test_steg.py @@ -1,4 +1,5 @@ -# tests/test_steg.py +# tests/cli/test_steg.py +# {{{ imports import shutil import subprocess from pathlib import Path @@ -6,9 +7,10 @@ from unittest.mock import patch, MagicMock import click from click.testing import CliRunner import pytest - from ctf.steg import crack_steghide, SteghideCrackResult +# }}} +# {{{ test_crack_steghide_missing_binary def test_crack_steghide_missing_binary(): """ Verifies that crack_steghide returns failure if steghide is not installed. @@ -17,7 +19,9 @@ def test_crack_steghide_missing_binary(): res = crack_steghide(Path("somefile.jpg"), ["pass1", "pass2"]) assert not res.success assert "not found on system" in res.error_message +# }}} +# {{{ test_crack_steghide_file_not_found def test_crack_steghide_file_not_found(): """ Verifies that crack_steghide returns failure if the input file does not exist. @@ -26,7 +30,9 @@ def test_crack_steghide_file_not_found(): res = crack_steghide(Path("non_existent_file_12345.jpg"), ["pass1", "pass2"]) assert not res.success assert "File not found" in res.error_message +# }}} +# {{{ test_crack_steghide_success def test_crack_steghide_success(): """ Verifies that crack_steghide returns success, the password, and payload when cracked. @@ -36,13 +42,11 @@ def test_crack_steghide_success(): # We want to mock subprocess.run to write a payload to the out_file (-xf) # when the correct password is tried. def mock_run(cmd, *args, **kwargs): - # cmd: ['steghide', 'extract', '-sf', '...', '-p', password, '-xf', out_file, '-f'] password = cmd[5] out_file = cmd[7] mock_res = MagicMock() if password == "correct_pass": mock_res.returncode = 0 - # Write a mock payload file Path(out_file).write_bytes(b"flag{steghide_cracked_payload}") else: mock_res.returncode = 1 @@ -57,7 +61,9 @@ def test_crack_steghide_success(): assert res.password == "correct_pass" assert res.payload == b"flag{steghide_cracked_payload}" assert res.error_message is None +# }}} +# {{{ test_crack_steghide_failure def test_crack_steghide_failure(): """ Verifies that crack_steghide returns failure if the wordlist is exhausted. @@ -78,7 +84,9 @@ def test_crack_steghide_failure(): assert res.password is None assert res.payload is None assert "Password not found in wordlist" in res.error_message +# }}} +# {{{ test_cli_crack_steghide_success def test_cli_crack_steghide_success(tmp_path): """ Verifies that the crack-steghide CLI command prints success panel and previews payload. @@ -105,7 +113,9 @@ def test_cli_crack_steghide_success(tmp_path): assert "Successfully cracked!" in res.output assert "pass2" in res.output assert "flag{cli_steg_preview}" in res.output +# }}} +# {{{ test_cli_crack_steghide_success_output_file def test_cli_crack_steghide_success_output_file(tmp_path): """ Verifies that the crack-steghide CLI command writes the payload to a file when specified. @@ -133,7 +143,9 @@ def test_cli_crack_steghide_success_output_file(tmp_path): assert "Successfully cracked!" in res.output assert out_file.exists() assert out_file.read_bytes() == b"written_file_content" +# }}} +# {{{ test_cli_crack_steghide_failure def test_cli_crack_steghide_failure(tmp_path): """ Verifies that the crack-steghide CLI command displays correct error message on failure. @@ -156,3 +168,4 @@ def test_cli_crack_steghide_failure(tmp_path): res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file)]) assert res.exit_code == 0 assert "Cracking failed: Password not found in wordlist" in res.output +# }}} diff --git a/tests/test_forensics.py b/tests/forensics/test_metadata.py similarity index 53% rename from tests/test_forensics.py rename to tests/forensics/test_metadata.py index bdbb4d0..c85521b 100644 --- a/tests/test_forensics.py +++ b/tests/forensics/test_metadata.py @@ -1,52 +1,18 @@ -from pathlib import Path -import toml +# tests/forensics/test_metadata.py +# {{{ imports import os -import stat import sys -from click.testing import CliRunner -from ctf.utils import active_categories +import stat +from pathlib import Path +import pytest from ctf.forensics import get_metadata -from ctf.cli.forensics import forensics_group, metadata +from ctf.utils import active_categories +# }}} # Define the persistent test environment path TEST_ENV = Path("tests/env") -# ===================================================================== -# 1. Test Strategy Summary -# ===================================================================== -# Core scenarios tested: -# -# A. Happy Path: -# - Verifying metadata attributes (permissions, ownership, size, -# hard links, inode, device) map exactly to filesystem ground truth. -# -# B. Boundary Conditions: -# - Testing empty files (0 bytes) for correct apparent size and allocation. -# - Testing file permissions changes (e.g. read-only, executable) and -# making sure the parsed octal/symbolic codes match. -# - Testing multiple hard links (count > 1). -# -# C. Edge Cases & OS Portability: -# - Extended attributes (xattr) support, handling platforms where xattr -# is not present or supported on the active file system. -# - Graceful system identity fallbacks for UID/GID names when passwd/group -# databases are unavailable or running on non-Unix systems. -# -# D. Error Handling: -# - Accessing non-existent paths (CLI validation errors). -# ===================================================================== - -# ===================================================================== -# 2. Mocking/Setup Requirements -# ===================================================================== -# - Filesystem sandbox: Uses `tests/env/` to dynamically create files -# with specific mode bits, content, and links. -# - No external network/API mocking is required. -# - OS-specific conditional blocks handle platforms (e.g., Windows) -# where POSIX ownership resolution or xattr is not native. -# ===================================================================== - - +# {{{ test_active_categories_static def test_active_categories_static(): """Verifies active_categories using the pre-created 'comp1' folder.""" comp_dir = TEST_ENV / "comp1" @@ -59,10 +25,9 @@ def test_active_categories_static(): assert "web" in cat_names assert "pwn" in cat_names +# }}} - -# --- Task-Specific Universal Metadata Tests --- - +# {{{ test_inspect_permissions def test_inspect_permissions(): """ Task 1: POSIX Permissions. @@ -88,8 +53,9 @@ def test_inspect_permissions(): meta_new = get_metadata(test_file) assert meta_new.permissions_octal == "0o644" assert meta_new.permissions_symbolic == "-rw-r--r--" +# }}} - +# {{{ test_inspect_ownership def test_inspect_ownership(): """ Task 2: Ownership Identity. @@ -117,8 +83,9 @@ def test_inspect_ownership(): # Fallback for Windows assert isinstance(meta.owner_username, str) assert isinstance(meta.owner_groupname, str) +# }}} - +# {{{ test_inspect_allocation def test_inspect_allocation(): """ Task 3: Allocation Metrics. @@ -140,8 +107,9 @@ def test_inspect_allocation(): assert meta.allocated_size == expected_blocks_size else: assert meta.allocated_size >= 1234 +# }}} - +# {{{ test_inspect_hard_links def test_inspect_hard_links(): """ Task 4: Hard Link Count. @@ -166,8 +134,9 @@ def test_inspect_hard_links(): finally: if link_file.exists(): link_file.unlink() +# }}} - +# {{{ test_inspect_inode_device def test_inspect_inode_device(): """ Task 5: Inode & Device Identifiers. @@ -182,8 +151,9 @@ def test_inspect_inode_device(): assert meta.inode == stat_info.st_ino assert meta.device == stat_info.st_dev +# }}} - +# {{{ test_inspect_extended_attributes def test_inspect_extended_attributes(): """ Task 6: Extended Attributes (xattr). @@ -194,15 +164,12 @@ def test_inspect_extended_attributes(): with open(test_file, "wb") as f: f.write(b"data") - # Attempt to set an extended attribute (Linux/macOS specific) has_xattr_support = False if sys.platform in ("linux", "darwin"): try: - import xattr - # Use user namespace for custom attribute os.setxattr(str(test_file), "user.ctf_flag", b"FLAG{filesystem_metadata_ftw}") has_xattr_support = True - except (ImportError, OSError, AttributeError): + except (OSError, AttributeError): pass meta = get_metadata(test_file) @@ -211,81 +178,23 @@ def test_inspect_extended_attributes(): assert "user.ctf_flag" in meta.extended_attributes assert meta.extended_attributes["user.ctf_flag"] == "FLAG{filesystem_metadata_ftw}" else: - # Should gracefully return an empty dict if not supported or none defined assert isinstance(meta.extended_attributes, dict) +# }}} - -# --- CLI Validation Tests --- - -def test_forensics_metadata_cli_success(): - """ - Verifies that 'forensics metadata' successfully runs via the CLI and prints - the metadata in a formatted table. - """ - runner = CliRunner() - test_file = TEST_ENV / "test_file.png" - with open(test_file, "wb") as f: - f.write(b"\x89PNG\r\n\x1a\n") - - result = runner.invoke(forensics_group, ["metadata", str(test_file)]) - assert result.exit_code == 0 - assert "Metadata: test_file.png" in result.output - assert "Size" in result.output - assert "89504E47" in result.output - assert "Detected Type" in result.output - assert "PNG Image" in result.output - -def test_nested_metadata_cli_success(): - """ - Verifies that 'ctf forensics metadata' successfully runs via the root CLI. - """ - from ctf.main import cli - runner = CliRunner() - test_file = TEST_ENV / "test_file_nested.png" - with open(test_file, "wb") as f: - f.write(b"\x89PNG\r\n\x1a\n") - - result = runner.invoke(cli, ["forensics", "metadata", str(test_file)]) - assert result.exit_code == 0 - assert "Metadata: test_file_nested.png" in result.output - assert "PNG Image" in result.output - -def test_forensics_metadata_cli_missing_file(): - """ - Verifies that the CLI fails gracefully when a non-existent file path is specified. - """ - runner = CliRunner() - result = runner.invoke(forensics_group, ["metadata", "non_existent_file.png"]) - assert result.exit_code != 0 - assert "does not exist" in result.output - -def test_forensics_signatures_cli(): - """ - Verifies that 'forensics signatures' runs successfully and displays - the list of supported file signatures. - """ - runner = CliRunner() - result = runner.invoke(forensics_group, ["signatures"]) - assert result.exit_code == 0 - assert "Supported Magic Signatures" in result.output - assert "PNG Image" in result.output - assert "89504E47" in result.output - -# (Old flag-detect tests removed) - +# {{{ test_pure_exif_parsing def test_pure_exif_parsing(): """ Verifies that get_metadata successfully parses EXIF fields from a mock PNG. """ import struct tiff_data = ( - b"II\x2a\x00\x08\x00\x00\x00" # TIFF Header - b"\x02\x00" # Num entries - b"\x0f\x01\x02\x00\x05\x00\x00\x00\x26\x00\x00\x00" # Entry 1 (Make: offset 38) - b"\x10\x01\x02\x00\x06\x00\x00\x00\x2b\x00\x00\x00" # Entry 2 (Model: offset 43) - b"\x00\x00\x00\x00" # Next IFD offset - b"TEST\x00" # Make value - b"MODEL\x00" # Model value + b"II\x2a\x00\x08\x00\x00\x00" + b"\x02\x00" + b"\x0f\x01\x02\x00\x05\x00\x00\x00\x26\x00\x00\x00" + b"\x10\x01\x02\x00\x06\x00\x00\x00\x2b\x00\x00\x00" + b"\x00\x00\x00\x00" + b"TEST\x00" + b"MODEL\x00" ) png_header = b"\x89PNG\r\n\x1a\n" exif_chunk_type = b"eXIf" @@ -301,35 +210,9 @@ def test_pure_exif_parsing(): meta = get_metadata(test_file) assert meta.exif_data.get("Make") == "TEST" assert meta.exif_data.get("Model") == "MODEL" +# }}} -def test_exif_cli_options(): - """ - Verifies CLI options -i/--inode and -e/--exif work as expected. - """ - from ctf.main import cli - runner = CliRunner() - test_file = TEST_ENV / "mock_exif_image.png" - - # Verify mutual exclusion error - result_err = runner.invoke(cli, ["forensics", "metadata", "-i", "-e", str(test_file)]) - assert result_err.exit_code != 0 - assert "Cannot specify more than one" in result_err.output - - # Verify --inode hides EXIF data table - result_inode = runner.invoke(cli, ["forensics", "metadata", "-i", str(test_file)]) - assert result_inode.exit_code == 0 - assert "Metadata: mock_exif_image.png" in result_inode.output - assert "EXIF Metadata" not in result_inode.output - - # Verify --exif hides POSIX table but displays EXIF data table - result_exif = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)]) - assert result_exif.exit_code == 0 - assert "Metadata: mock_exif_image.png" not in result_exif.output - assert "EXIF Metadata" in result_exif.output - assert "mock_exif_image" in result_exif.output - assert "Make" in result_exif.output - assert "TEST" in result_exif.output - +# {{{ test_jpeg_comment_extraction def test_jpeg_comment_extraction(): """ Verifies that get_metadata successfully parses a JPEG comment (COM marker). @@ -351,41 +234,9 @@ def test_jpeg_comment_extraction(): meta = get_metadata(test_file) assert meta.comment == "picoCTF{test_comment_flag}" +# }}} -def test_exif_cli_options_warning(): - """ - Verifies CLI warns when no EXIF or comment is found and -e option is used. - """ - from ctf.main import cli - runner = CliRunner() - test_file = TEST_ENV / "clean_no_exif.png" - with open(test_file, "wb") as f: - f.write(b"\x89PNG\r\n\x1a\n") - - result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)]) - assert result.exit_code == 0 - assert "No EXIF or comment metadata found" in result.output - -def test_exif_cli_comment_only(): - """ - Verifies CLI displays comment when -e is used and only comment is found. - """ - from ctf.main import cli - import struct - runner = CliRunner() - comment_text = b"flag{comment_only}" - comment_len = len(comment_text) + 2 - mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text - - test_file = TEST_ENV / "comment_only.jpg" - with open(test_file, "wb") as f: - f.write(mock_jpeg) - - result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)]) - assert result.exit_code == 0 - assert "Comment" in result.output - assert "flag{comment_only}" in result.output - +# {{{ test_adobe_xmp_extraction def test_adobe_xmp_extraction(): """ Verifies that get_metadata successfully parses XMP fields (like license resource). @@ -417,21 +268,9 @@ def test_adobe_xmp_extraction(): meta = get_metadata(test_file) assert meta.exif_data.get("cc:license") == "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" +# }}} -def test_adobe_xmp_cli(): - """ - Verifies that the ctf inspect CLI prints the parsed XMP fields. - """ - from ctf.main import cli - runner = CliRunner() - test_file = TEST_ENV / "mock_xmp.jpg" - - result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)]) - assert result.exit_code == 0 - assert "EXIF Metadata" in result.output - assert "cc:license" in result.output - assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output - +# {{{ test_jpeg_physical_parsing def test_jpeg_physical_parsing(): """ Verifies that get_metadata successfully parses JPEG physical parameters (JFIF & SOF). @@ -454,7 +293,9 @@ def test_jpeg_physical_parsing(): assert meta.physical_data.get("Image Size") == "1500x1000" assert meta.physical_data.get("Megapixels") == "1.5" assert meta.physical_data.get("Encoding Process") == "Baseline DCT, Huffman coding" +# }}} +# {{{ test_jpeg_iptc_parsing def test_jpeg_iptc_parsing(): """ Verifies that get_metadata successfully parses JPEG IPTC metadata from APP13. @@ -478,28 +319,9 @@ def test_jpeg_iptc_parsing(): meta = get_metadata(test_file) assert meta.exif_data.get("CopyrightNotice") == "PicoCTF Rights" +# }}} -def test_cli_physical_option(): - """ - Verifies the ctf forensics metadata CLI supports -p/--physical and mutual exclusion rules. - """ - from ctf.main import cli - runner = CliRunner() - test_file = TEST_ENV / "mock_physical.jpg" - - # Mutual exclusion check - result_err = runner.invoke(cli, ["forensics", "metadata", "-p", "-e", str(test_file)]) - assert result_err.exit_code != 0 - assert "Cannot specify more than one" in result_err.output - - # Physical view check - result_phys = runner.invoke(cli, ["forensics", "metadata", "-p", str(test_file)]) - assert result_phys.exit_code == 0 - assert "Physical Metadata" in result_phys.output - assert "Image Size" in result_phys.output - assert "1500x1000" in result_phys.output - assert "Metadata: mock_physical.jpg" not in result_phys.output - assert "EXIF Metadata" not in result_phys.output +# {{{ test_png_text_chunks_decompression def test_png_text_chunks_decompression(): """ Verifies that get_metadata successfully parses tEXt and compressed zTXt chunks. @@ -507,11 +329,9 @@ def test_png_text_chunks_decompression(): import zlib import struct - # 1. tEXt chunk: Keyword (Copyright) + NUL + Text (PicoCTF) text_data = b"Copyright\x00PicoCTF" text_chunk = struct.pack(">I", len(text_data)) + b"tEXt" + text_data + b"\x00\x00\x00\x00" - # 2. zTXt chunk: Keyword (Author) + NUL + CompMethod(0) + Deflated Text (John Doe) deflated = zlib.compress(b"John Doe") ztxt_data = b"Author\x00\x00" + deflated ztxt_chunk = struct.pack(">I", len(ztxt_data)) + b"zTXt" + ztxt_data + b"\x00\x00\x00\x00" @@ -525,7 +345,9 @@ def test_png_text_chunks_decompression(): meta = get_metadata(test_file) assert meta.exif_data.get("Copyright") == "PicoCTF" assert meta.exif_data.get("Author") == "John Doe" +# }}} +# {{{ test_gif_comment_parsing def test_gif_comment_parsing(): """ Verifies that get_metadata successfully parses GIF comment blocks. @@ -542,7 +364,9 @@ def test_gif_comment_parsing(): meta = get_metadata(test_file) assert meta.comment == "GIFComment" +# }}} +# {{{ test_adobe_xmp_formatting def test_adobe_xmp_formatting(): """ Verifies that get_metadata parses rdf:Description attributes and formats other @@ -580,57 +404,4 @@ def test_adobe_xmp_formatting(): assert meta.exif_data.get("exif:Flash") == "exif:Fired | True, exif:Mode | 1" assert meta.exif_data.get("exif:EmptyAttr") == "exif:Value" assert "rdf:Description" not in meta.exif_data - -def test_metadata_decoded_hints_cli(): - """ - Verifies that metadata command successfully decodes and prints hex/base64 encoded metadata hints. - """ - from ctf.main import cli - runner = CliRunner() - - # Create a mock JPEG with a base64 encoded comment: "ZmxhZ3tiNjRfbWV0YWRhdGF9" -> "flag{b64_metadata}" - import struct - comment_text = b"ZmxhZ3tiNjRfbWV0YWRhdGF9" - comment_len = len(comment_text) + 2 - mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + b"\xff\xd9" - - test_file = TEST_ENV / "mock_encoded_comment.jpg" - with open(test_file, "wb") as f: - f.write(mock_jpeg) - - result = runner.invoke(cli, ["forensics", "metadata", str(test_file)]) - assert result.exit_code == 0 - assert "Decoded Metadata Hints" in result.output - assert "Comment" in result.output - assert "base64" in result.output - assert "flag{b64_metadata}" in result.output - -# {{{ test_parser_factory_and_classes -def test_parser_factory_and_classes(): - """ - Verifies that ParserFactory selects the correct parser subclass and - that individual FormatParser subclasses behave correctly. - """ - from ctf.forensics import ParserFactory, JpegParser, PngParser, GifParser, FallbackParser - - # Assert JPEG - jpeg_parser = ParserFactory.get_parser(b"\xff\xd8\xff\xe0\x00\x10JFIF") - assert isinstance(jpeg_parser, JpegParser) - - # Assert PNG - png_parser = ParserFactory.get_parser(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR") - assert isinstance(png_parser, PngParser) - - # Assert GIF - gif_parser = ParserFactory.get_parser(b"GIF89a\x01\x00\x01\x00") - assert isinstance(gif_parser, GifParser) - - # Assert Fallback - fallback_parser = ParserFactory.get_parser(b"MZ\x90\x00\x03\x00\x00\x00") - assert isinstance(fallback_parser, FallbackParser) - assert fallback_parser.parse_physical(b"") == {} - assert fallback_parser.get_comment(b"") == "" - assert fallback_parser.get_exif_tags(b"") == {} # }}} - - diff --git a/tests/forensics/test_parsers.py b/tests/forensics/test_parsers.py new file mode 100644 index 0000000..36ad3ba --- /dev/null +++ b/tests/forensics/test_parsers.py @@ -0,0 +1,30 @@ +# tests/forensics/test_parsers.py +# {{{ imports +from ctf.forensics import ParserFactory, JpegParser, PngParser, GifParser, FallbackParser +# }}} + +# {{{ test_parser_factory_and_classes +def test_parser_factory_and_classes(): + """ + Verifies that ParserFactory selects the correct parser subclass and + that individual FormatParser subclasses behave correctly. + """ + # Assert JPEG + jpeg_parser = ParserFactory.get_parser(b"\xff\xd8\xff\xe0\x00\x10JFIF") + assert isinstance(jpeg_parser, JpegParser) + + # Assert PNG + png_parser = ParserFactory.get_parser(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR") + assert isinstance(png_parser, PngParser) + + # Assert GIF + gif_parser = ParserFactory.get_parser(b"GIF89a\x01\x00\x01\x00") + assert isinstance(gif_parser, GifParser) + + # Assert Fallback + fallback_parser = ParserFactory.get_parser(b"MZ\x90\x00\x03\x00\x00\x00") + assert isinstance(fallback_parser, FallbackParser) + assert fallback_parser.parse_physical(b"") == {} + assert fallback_parser.get_comment(b"") == "" + assert fallback_parser.get_exif_tags(b"") == {} +# }}}