Spaces:
Running
Running
DeepNeuro AI Bot
Sync to HF Space: Redesign Start Here tab — interactive demo, comparison table, collapsible explainers
d95586c | """Unit tests for LSB steganography engine.""" | |
| import os | |
| import tempfile | |
| import pytest | |
| from PIL import Image | |
| import numpy as np | |
| from steg_embedder import StegEmbedder | |
| def embedder(): | |
| return StegEmbedder() | |
| def rgb_image(): | |
| """Create a small 16x16 RGB test image.""" | |
| arr = np.random.randint(0, 256, (16, 16, 3), dtype=np.uint8) | |
| return Image.fromarray(arr, 'RGB') | |
| def rgba_image(): | |
| """Create a small 16x16 RGBA test image.""" | |
| arr = np.random.randint(0, 256, (16, 16, 4), dtype=np.uint8) | |
| return Image.fromarray(arr, 'RGBA') | |
| def large_rgb_image(): | |
| """Create a 64x64 RGB image for larger payloads.""" | |
| arr = np.random.randint(0, 256, (64, 64, 3), dtype=np.uint8) | |
| return Image.fromarray(arr, 'RGB') | |
| def temp_png_path(rgb_image): | |
| """Create a temporary PNG file for testing.""" | |
| with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: | |
| rgb_image.save(f.name, 'PNG') | |
| path = f.name | |
| yield path | |
| if os.path.exists(path): | |
| os.unlink(path) | |
| def temp_output_path(): | |
| """Create a temporary output file path.""" | |
| with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: | |
| path = f.name | |
| yield path | |
| if os.path.exists(path): | |
| os.unlink(path) | |
| class TestCapacity: | |
| def test_rgb_capacity_basic(self, embedder, rgb_image): | |
| cap = embedder.calculate_capacity(rgb_image, bits_per_channel=1) | |
| # 16x16x3 = 768 bits, minus header (16 * 8 = 128 bits) = 640 bits / 8 = 80 bytes | |
| assert cap == 80 | |
| def test_rgba_capacity(self, embedder, rgba_image): | |
| cap = embedder.calculate_capacity(rgba_image, bits_per_channel=1) | |
| # 16x16x4 = 1024 bits, minus 128 header = 896 / 8 = 112 bytes | |
| assert cap == 112 | |
| def test_capacity_scales_with_bits(self, embedder, rgb_image): | |
| cap1 = embedder.calculate_capacity(rgb_image, bits_per_channel=1) | |
| cap2 = embedder.calculate_capacity(rgb_image, bits_per_channel=2) | |
| # 2 bits should give roughly 2x capacity | |
| assert cap2 == 176 | |
| def test_capacity_scales_with_size(self, embedder, rgb_image, large_rgb_image): | |
| cap_small = embedder.calculate_capacity(rgb_image, bits_per_channel=1) | |
| cap_large = embedder.calculate_capacity(large_rgb_image, bits_per_channel=1) | |
| assert cap_large > cap_small | |
| # 64*64 vs 16*16 = 4096/256 = 16x pixels -> roughly 16x capacity | |
| assert cap_large == 1520 | |
| def test_unsupported_mode_raises(self, embedder): | |
| img = Image.new('L', (10, 10)) # Grayscale | |
| with pytest.raises(ValueError, match='Unsupported image mode'): | |
| embedder.calculate_capacity(img) | |
| def test_max_bits_per_channel(self, embedder, rgb_image): | |
| cap = embedder.calculate_capacity(rgb_image, bits_per_channel=4) | |
| assert cap == 368 | |
| def test_last_capacity_updated(self, embedder, rgb_image): | |
| embedder.calculate_capacity(rgb_image) | |
| assert embedder.last_capacity == 80 | |
| class TestEmbedExtractRoundTrip: | |
| def test_basic_round_trip(self, embedder, temp_png_path, temp_output_path): | |
| message = "Hello, World!" | |
| success, msg, stats = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success | |
| assert stats['data_size'] == len(message.encode('utf-8')) | |
| assert not stats['encrypted'] | |
| success2, msg2, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_round_trip_with_password(self, embedder, temp_png_path, temp_output_path): | |
| message = "Secret message with 🔒 encryption" | |
| password = "my-secret-key" | |
| success, msg, stats = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| password=password, bits_per_channel=1 | |
| ) | |
| assert success | |
| assert stats['encrypted'] | |
| success2, msg2, extracted = embedder.extract_data( | |
| temp_output_path, password=password, bits_per_channel=1 | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_round_trip_unicode(self, embedder, temp_png_path, temp_output_path): | |
| message = "日本語 Español Français 🌍 Emoji test ✓" | |
| success, msg, stats = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success | |
| success2, msg2, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_round_trip_bits2(self, embedder, temp_png_path, temp_output_path): | |
| message = "Using 2 bits per channel" | |
| success, _, _ = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, bits_per_channel=2 | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=2 | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_round_trip_bits4(self, embedder, temp_png_path, temp_output_path): | |
| message = "Maximum 4 bits per channel" | |
| success, _, _ = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, bits_per_channel=4 | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=4 | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_round_trip_empty_message(self, embedder, temp_png_path, temp_output_path): | |
| message = "" | |
| success, msg, stats = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success | |
| assert stats['data_size'] == 0 | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success2 | |
| assert extracted == "" | |
| def test_round_trip_long_message(self, embedder, temp_png_path, temp_output_path): | |
| # 16x16x3 with 1 bit = 80 byte capacity, so 60 chars should fit | |
| message = "A" * 60 | |
| success, _, _ = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_rgba_round_trip(self, embedder, temp_output_path): | |
| arr = np.random.randint(0, 256, (16, 16, 4), dtype=np.uint8) | |
| rgba_img = Image.fromarray(arr, 'RGBA') | |
| with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: | |
| rgba_img.save(f.name, 'PNG') | |
| rgba_path = f.name | |
| try: | |
| message = "RGBA stego test" | |
| success, _, _ = embedder.embed_data( | |
| rgba_path, message, temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| finally: | |
| if os.path.exists(rgba_path): | |
| os.unlink(rgba_path) | |
| def test_large_image_round_trip(self, embedder, temp_output_path): | |
| arr = np.random.randint(0, 256, (64, 64, 3), dtype=np.uint8) | |
| big_img = Image.fromarray(arr, 'RGB') | |
| with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: | |
| big_img.save(f.name, 'PNG') | |
| big_path = f.name | |
| try: | |
| message = "A longer message that would exceed smaller image capacity " * 10 | |
| success, _, _ = embedder.embed_data( | |
| big_path, message, temp_output_path, bits_per_channel=2 | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=2 | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| finally: | |
| if os.path.exists(big_path): | |
| os.unlink(big_path) | |
| class TestExtractErrors: | |
| def test_wrong_password(self, embedder, temp_png_path, temp_output_path): | |
| message = "Secret" | |
| password = "correct" | |
| embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| password=password, bits_per_channel=1 | |
| ) | |
| success, msg, extracted = embedder.extract_data( | |
| temp_output_path, password="wrong", bits_per_channel=1 | |
| ) | |
| # With XOR encryption, wrong password produces garbage text but | |
| # checksum is on encrypted bytes so extraction still "succeeds" | |
| assert success | |
| assert extracted != message | |
| def test_missing_password_for_encrypted(self, embedder, temp_png_path, temp_output_path): | |
| message = "Secret" | |
| embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| password="key", bits_per_channel=1 | |
| ) | |
| success, msg, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert not success | |
| assert "encrypted" in msg.lower() | |
| def test_no_data_in_image(self, embedder, temp_png_path): | |
| success, msg, extracted = embedder.extract_data( | |
| temp_png_path, bits_per_channel=1 | |
| ) | |
| assert not success | |
| assert "magic" in msg.lower() | |
| class TestEmbedErrors: | |
| def test_data_too_large(self, embedder, temp_png_path, temp_output_path): | |
| message = "X" * 100 # More than 80 byte capacity | |
| success, msg, stats = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, bits_per_channel=1 | |
| ) | |
| assert not success | |
| assert "large" in msg.lower() | |
| assert stats == {} | |
| def test_nonexistent_input(self, embedder, temp_output_path): | |
| success, msg, _ = embedder.embed_data( | |
| "/nonexistent/file.png", "test", temp_output_path | |
| ) | |
| assert not success | |
| class TestEncryption: | |
| def test_xor_encryption_decryption(self, embedder): | |
| original = "Test data for encryption" | |
| password = "test-key" | |
| encrypted = embedder._encrypt_data(original, password) | |
| assert encrypted != original.encode('utf-8') | |
| decrypted = embedder._decrypt_data(encrypted, password) | |
| assert decrypted == original | |
| def test_different_keys_produce_different_output(self, embedder): | |
| data = "test data" | |
| enc1 = embedder._encrypt_data(data, "key1") | |
| enc2 = embedder._encrypt_data(data, "key2") | |
| assert enc1 != enc2 | |
| def test_empty_data_encryption(self, embedder): | |
| encrypted = embedder._encrypt_data("", "key") | |
| assert encrypted == b"" | |
| decrypted = embedder._decrypt_data(encrypted, "key") | |
| assert decrypted == "" | |
| class TestBitConversion: | |
| def test_string_to_bits_round_trip(self, embedder): | |
| original = "Hello" | |
| bits = embedder._string_to_bits(original) | |
| result = embedder._bits_to_string(bits) | |
| assert result == original | |
| def test_bits_format(self, embedder): | |
| bits = embedder._string_to_bits("A") # 'A' = 65 = 01000001 | |
| assert bits == "01000001" | |
| def test_unicode_bit_conversion(self, embedder): | |
| original = "ñ" | |
| bits = embedder._string_to_bits(original) | |
| result = embedder._bits_to_string(bits) | |
| assert result == original | |
| class TestHeaderIntegrity: | |
| def test_magic_number_present(self, embedder): | |
| assert embedder.MAGIC_NUMBER == b'2PAC' | |
| def test_header_size(self, embedder): | |
| assert embedder.HEADER_SIZE == 12 | |
| def test_output_image_is_valid_png(self, embedder, temp_png_path, temp_output_path): | |
| embedder.embed_data(temp_png_path, "test", temp_output_path) | |
| img = Image.open(temp_output_path) | |
| assert img.format == 'PNG' | |
| assert img.size == (16, 16) | |
| def test_embedded_image_looks_similar(self, embedder, temp_png_path, temp_output_path): | |
| embedder.embed_data(temp_png_path, "test", temp_output_path, bits_per_channel=1) | |
| original = np.array(Image.open(temp_png_path)) | |
| embedded = np.array(Image.open(temp_output_path)) | |
| # With 1 LSB, max difference should be 1 per pixel | |
| diff = np.abs(original.astype(int) - embedded.astype(int)) | |
| assert np.max(diff) <= 1 | |
| class TestEdgeCases: | |
| def test_single_char_message(self, embedder, temp_png_path, temp_output_path): | |
| success, _, _ = embedder.embed_data( | |
| temp_png_path, "X", temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert extracted == "X" | |
| def test_special_chars(self, embedder, temp_png_path, temp_output_path): | |
| message = "\n\t\r\x00\x01" | |
| success, _, _ = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert extracted == message | |
| def test_password_with_special_chars(self, embedder, temp_png_path, temp_output_path): | |
| message = "secret" | |
| password = "!@#$%^&*()_+-=[]{}|;:',.<>?/~`" | |
| embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| password=password, bits_per_channel=1 | |
| ) | |
| success, _, extracted = embedder.extract_data( | |
| temp_output_path, password=password, bits_per_channel=1 | |
| ) | |
| assert success | |
| assert extracted == message | |
| def test_image_mode_converted_from_rgba(self, embedder, temp_output_path): | |
| arr = np.random.randint(0, 256, (16, 16, 4), dtype=np.uint8) | |
| rgba_img = Image.fromarray(arr, 'RGBA') | |
| with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: | |
| rgba_img.save(f.name, 'PNG') | |
| rgba_path = f.name | |
| try: | |
| message = "RGBA auto-convert test" | |
| success, msg, stats = embedder.embed_data( | |
| rgba_path, message, temp_output_path, bits_per_channel=1 | |
| ) | |
| assert success | |
| assert stats['encrypted'] is False | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1 | |
| ) | |
| assert extracted == message | |
| finally: | |
| if os.path.exists(rgba_path): | |
| os.unlink(rgba_path) | |
| class TestPixelScattering: | |
| def test_scatter_round_trip(self, embedder, temp_png_path, temp_output_path): | |
| message = "Scattered secret message" | |
| password = "scatter-key" | |
| success, _, stats = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| password=password, bits_per_channel=1, scatter=True | |
| ) | |
| assert success | |
| assert stats['scatter'] is True | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, password=password, | |
| bits_per_channel=1, scatter=True | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_scatter_without_password(self, embedder, temp_png_path, temp_output_path): | |
| message = "Scatter no password" | |
| success, _, _ = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| bits_per_channel=1, scatter=True | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, bits_per_channel=1, scatter=True | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_scatter_wrong_password_fails(self, embedder, temp_png_path, temp_output_path): | |
| message = "Secret" | |
| embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| password="correct", bits_per_channel=1, scatter=True | |
| ) | |
| # Wrong password → different permutation → can't find header | |
| success, msg, _ = embedder.extract_data( | |
| temp_output_path, password="wrong", | |
| bits_per_channel=1, scatter=True | |
| ) | |
| assert not success | |
| def test_scatter_mismatch_fails(self, embedder, temp_png_path, temp_output_path): | |
| message = "Scatter mismatch test" | |
| embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| password="key", bits_per_channel=1, scatter=True | |
| ) | |
| # Extract without scatter → sequential read of scattered data → fail | |
| success, _, _ = embedder.extract_data( | |
| temp_output_path, password="key", | |
| bits_per_channel=1, scatter=False | |
| ) | |
| assert not success | |
| def test_scatter_bits2_round_trip(self, embedder, temp_png_path, temp_output_path): | |
| message = "Scatter with 2 bits" | |
| success, _, _ = embedder.embed_data( | |
| temp_png_path, message, temp_output_path, | |
| password="key", bits_per_channel=2, scatter=True | |
| ) | |
| assert success | |
| success2, _, extracted = embedder.extract_data( | |
| temp_output_path, password="key", | |
| bits_per_channel=2, scatter=True | |
| ) | |
| assert success2 | |
| assert extracted == message | |
| def test_scatter_deterministic(self, embedder, temp_png_path): | |
| """Same password produces same permutation → same output.""" | |
| import tempfile | |
| msg = "determinism check" | |
| paths = [] | |
| for _ in range(2): | |
| with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: | |
| paths.append(f.name) | |
| embedder.embed_data( | |
| temp_png_path, msg, paths[-1], | |
| password="same", bits_per_channel=1, scatter=True | |
| ) | |
| try: | |
| img1 = np.array(Image.open(paths[0])) | |
| img2 = np.array(Image.open(paths[1])) | |
| assert np.array_equal(img1, img2) | |
| finally: | |
| for p in paths: | |
| if os.path.exists(p): | |
| os.unlink(p) | |