From 3163933e632b87877b414c16aeffdbc923d5a104 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 13 Aug 2026 18:22:48 -0500 Subject: [PATCH] fix(cli): read chi_square_indicator from the channel dict, not lsb_ratio `stegg analyze ` crashed with KeyError: 'chi_square_indicator' on every input. steg_core.analyze_image() sets chi_square_indicator directly on each channel dict (steg_core.py:875). Its own internal reader at :880 uses ch["chi_square_indicator"], confirming flat placement is canonical. But cli.py:469 and :502 read it nested under lsb_ratio, which only carries zeros/ones. The zeros/ones columns were already correct and are untouched. Adds test_analyze_cli.py, which drives the real CLI through Typer's CliRunner. analyze_image() itself was never broken, so a core-only test does not catch this. The test fails with the original KeyError before this change and passes after. Plain asserts with a __main__ runner, so it adds no test dependency (pytest is not currently declared). --- cli.py | 4 +-- test_analyze_cli.py | 80 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 test_analyze_cli.py diff --git a/cli.py b/cli.py index c831c48..c49070b 100644 --- a/cli.py +++ b/cli.py @@ -466,7 +466,7 @@ def analyze( for ch_name, ch_data in analysis['channels'].items(): lsb = ch_data['lsb_ratio'] - indicator = lsb['chi_square_indicator'] + indicator = ch_data['chi_square_indicator'] if indicator < 0.1: anomaly = "[green]✓ Normal[/green]" @@ -499,7 +499,7 @@ def analyze( # Verdict max_indicator = max( - ch['lsb_ratio']['chi_square_indicator'] + ch['chi_square_indicator'] for ch in analysis['channels'].values() ) diff --git a/test_analyze_cli.py b/test_analyze_cli.py new file mode 100644 index 0000000..091bc66 --- /dev/null +++ b/test_analyze_cli.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Regression test for the `analyze` CLI command. + +Bug: cli.py read `chi_square_indicator` from under each channel's `lsb_ratio` +dict, but steg_core.analyze_image() puts it directly on the channel dict +(only `zeros`/`ones` are nested under `lsb_ratio`). Every `analyze` run +crashed with KeyError: 'chi_square_indicator'. + +This exercises the real CLI path (not just analyze_image(), which was never +broken) via Typer's CliRunner, on both a clean carrier and a stego-carrying +image. Run directly: python test_analyze_cli.py +""" +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +import numpy as np +from PIL import Image +from typer.testing import CliRunner + +from cli import app + +runner = CliRunner() + + +def _noise_png(path: Path) -> None: + """A deterministic RGB noise image, a realistic analyze target.""" + rng = np.random.default_rng(1234) + arr = rng.integers(0, 256, size=(64, 64, 3), dtype=np.uint8) + Image.fromarray(arr, "RGB").save(path) + + +def test_analyze_runs_clean_on_noise_carrier(): + with tempfile.TemporaryDirectory() as td: + img = Path(td) / "carrier.png" + _noise_png(img) + result = runner.invoke(app, ["analyze", str(img)]) + assert result.exit_code == 0, ( + f"analyze exited {result.exit_code}; " + f"exception={result.exception!r}") + assert result.exception is None, result.exception + # The channel-analysis table must actually render its anomaly verdict. + assert "Verdict" in result.stdout, result.stdout + + +def test_analyze_runs_clean_on_stego_image(): + """An image with LSB data hidden should still analyze without crashing + (and is the case the detector actually exists for).""" + with tempfile.TemporaryDirectory() as td: + carrier = Path(td) / "carrier.png" + hidden = Path(td) / "hidden.png" + _noise_png(carrier) + enc = runner.invoke(app, [ + "encode-cmd", "-i", str(carrier), "-t", "regression secret", + "-o", str(hidden)]) + assert enc.exit_code == 0, ( + f"encode failed {enc.exit_code}: {enc.exception!r}") + result = runner.invoke(app, ["analyze", str(hidden)]) + assert result.exit_code == 0, ( + f"analyze exited {result.exit_code}; " + f"exception={result.exception!r}") + assert result.exception is None, result.exception + + +if __name__ == "__main__": + failures = 0 + for name, fn in sorted(globals().items()): + if name.startswith("test_") and callable(fn): + try: + fn() + print(f"ok {name}") + except AssertionError as e: + failures += 1 + print(f"FAIL {name}: {e}") + if failures: + print(f"\n{failures} test(s) failed") + sys.exit(1) + print("\nall tests passed")