From c889f6d229c506b4480d2f6dae30a73c3a326830 Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 14:45:47 +0100 Subject: [PATCH 01/14] Added crc database creation --- aperisolve/analyzers/utils.py | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/aperisolve/analyzers/utils.py b/aperisolve/analyzers/utils.py index a20036a..693ea94 100644 --- a/aperisolve/analyzers/utils.py +++ b/aperisolve/analyzers/utils.py @@ -6,6 +6,9 @@ import os import threading from pathlib import Path from typing import Any +import itertools +import zlib +import sqlite3 _thread_lock = threading.Lock() @@ -46,3 +49,85 @@ def update_data( os.replace(tmp_file, json_file) # ensures file write is atomic finally: fcntl.flock(lock, fcntl.LOCK_UN) + + +def create_crc_db(): + db_filename = "ihdr_crcs.db" + + # Connect to database + conn = sqlite3.connect(db_filename) + cursor = conn.cursor() + + # Optimization: Turn off safety checks for bulk insertion speed + cursor.execute("PRAGMA synchronous = OFF") + cursor.execute("PRAGMA journal_mode = MEMORY") + + # Create table + # We store the CRC as an integer for faster indexing/storage + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ihdr ( + crc INTEGER, + width INTEGER, + height INTEGER + ) + """) + + # Drop index if exists (rebuilding it at the end is faster) + cursor.execute("DROP INDEX IF EXISTS idx_crc") + + max_width = 2000 + max_height = 2000 + W = range(1, max_width + 1) + H = range(1, max_height + 1) + + # Standard PNG IHDR parameters + bit_depth = [1, 2, 4, 8, 16] + color_type = [0, 2, 3, 4, 6] + compression_method = [0] # This is always 0 according to the PNG spec + filter_method = [0] # This is always 0 according to the PNG spec + interlace_method = [0, 1] + + # Create the generator + all_combinations = itertools.product( + W, H, bit_depth, color_type, compression_method, filter_method, interlace_method + ) + + batch_size = 100000 + batch = [] + count = 0 + + for w, h, bd, ct, comp, filt, inter in all_combinations: + # Construct IHDR chunk data + ihdr_data = ( + w.to_bytes(4, "big") + + h.to_bytes(4, "big") + + bytes([bd, ct, comp, filt, inter]) + ) + + # Calculate CRC + crc = zlib.crc32(b"IHDR" + ihdr_data) & 0xffffffff + + # Add to batch + batch.append((crc, w, h)) + + if len(batch) >= batch_size: + cursor.executemany("INSERT INTO ihdr VALUES (?,?,?)", batch) + conn.commit() + batch = [] + count += batch_size + if count % 1000000 == 0: + print(f"Inserted {count:,} rows...") + + # Insert remaining records + if batch: + cursor.executemany("INSERT INTO ihdr VALUES (?,?,?)", batch) + conn.commit() + + print("Insertion complete. Creating Index for faster lookups...") + # Creating the index allows for faster lookups + cursor.execute("CREATE INDEX idx_crc ON ihdr(crc)") + conn.commit() + + conn.close() + print(f"Database ready.") + return \ No newline at end of file From d517a31504af8a3b7b6d5cb8887ec03ed3eb776c Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 15:11:19 +0100 Subject: [PATCH 02/14] Added CRC database lookup functionality --- aperisolve/analyzers/image_resize.py | 124 +++++++++++++++++++++------ 1 file changed, 96 insertions(+), 28 deletions(-) diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index c9ed704..549d588 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -1,6 +1,7 @@ """Image size bruteforcer for common PNG sizes.""" import zlib +import sqlite3 from pathlib import Path from .utils import update_data @@ -142,6 +143,31 @@ EXPECTED_SIZES = [ ] +def write_recovered_png( + png_bytes: bytearray, + ihdr_offset: int, + width: int, + height: int, + output_path: Path, +) -> None: + """ + Rebuilds a PNG by patching IHDR width/height and writes it to disk. + """ + height_bytes = height.to_bytes(4, byteorder="big") + width_bytes = width.to_bytes(4, byteorder="big") + + # Splicing: [Start...IHDR+4] + [W] + [H] + [IHDR+12...End] + full_png_data = ( + png_bytes[: ihdr_offset + 4] + + width_bytes + + height_bytes + + png_bytes[ihdr_offset + 12 :] + ) + + with output_path.open("wb") as out_f: + out_f.write(full_png_data) + + def calc_checksum( header_chunk: bytearray, width_bytes: bytearray, height_bytes: bytearray ) -> bytearray: @@ -156,10 +182,43 @@ def calc_checksum( return bytearray((zlib.crc32(new_header) & 0xFFFFFFFF).to_bytes(4, byteorder="big")) +def lookup_crc(crc_bytes: bytes, logs: list) -> list: + """ + Queries the SQLite DB for the CRC and returns a list of (width, height) tuples. + """ + # Locate DB relative to this script file + db_path = Path(__file__).parent / "ihdr_crcs.db" + + if not db_path.exists(): + logs.append(f"Error: Database not found at {db_path}") + return [] + + try: + target_crc = int.from_bytes(crc_bytes, byteorder="big") + except ValueError: + logs.append(f"Invalid CRC bytes: {crc_bytes}") + return [] + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Query the database + cursor.execute("SELECT width, height FROM ihdr WHERE crc = ?", (target_crc,)) + results = cursor.fetchall() # Returns list of (width, height) + conn.close() + + if results: + logs.append(f"Database: Found {len(results)} match(es) for CRC {target_crc}") + return results + else: + logs.append(f"Database: No match found for CRC {target_crc}") + return [] + + def analyze_image_resize(input_img: Path, output_dir: Path) -> None: """ Analyze an image submission using python-based resize bruteforce. - Attempts to recover PNGs with modified resolutions but valid CRCs. + Strategy: Check common sizes first, then fall back to DB lookup. """ # 1. Setup Paths @@ -168,7 +227,6 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: # 3. Initialize Logs logs = [] - recovered_image = None try: with input_img.open("rb") as image: @@ -198,8 +256,9 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: logs.append(f"Target CRC found: 0x{target_crc.hex()}") match_found = False + candidates = [] - # Brute-force Loop + # --- STRATEGY 1: Check Common Sizes --- for size in EXPECTED_SIZES: width, height = size @@ -210,40 +269,49 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: # Check if this size matches the file's CRC if target_crc == calc_checksum(header_chunk, width_bytes, height_bytes): match_found = True - logs.append(f"Success: Match found! Dimensions: {width}x{height}") - - # Create Output Filename - img_name = f"recovered_{width}x{height}.png" - - # Create parent directories if they don't exist - output_dir.mkdir(parents=True, exist_ok=True) - output_path = output_dir / img_name - - # Splicing: [Start...IHDR+4] + [W] + [H] + [IHDR+12...End] - full_png_data = ( - b[: ihdr + 4] + width_bytes + height_bytes + b[ihdr + 12 :] - ) - - # Write the recovered file to disk - with output_path.open("wb") as out_f: - out_f.write(full_png_data) - - recovered_image = Path(output_dir.name) / img_name - break # Stop looking after finding the match + logs.append(f"Success (Common List): Match found! Dimensions: {width}x{height}") + candidates.append((width, height)) + break # Stop after first match + # --- STRATEGY 2: Check SQLite DB --- if not match_found: - logs.append("Failure: No matching dimensions found.") + logs.append("No common size matched. Checking database...") + db_matches = lookup_crc(target_crc, logs) + if db_matches: + candidates.extend(db_matches) + match_found = True + + # --- Generate Output Images --- + saved_img_urls = [] + + if match_found and candidates: + # Create parent directories if they don't exist + output_dir.mkdir(parents=True, exist_ok=True) + + # Iterate through ALL candidates found + for w, h in candidates: + img_name = f"recovered_{w}x{h}.png" + output_path = output_dir / img_name + write_recovered_png(b, ihdr, w, h, output_path) + + logs.append(f"Image saved: {img_name}") + + # Append the formatted URL to our list + saved_img_urls.append("/image/" + str(Path(output_dir.name) / img_name)) + + else: + logs.append("Failure: No matching dimensions found in List or DB.") # 4. Final Data Update output_data = { - "image_resize": { # Key name must match your module name in AperiSolve + "image_resize": { "status": "ok", "output": logs, + "png_images": saved_img_urls, } } - if recovered_image: - output_data["image_resize"]["image"] = "/image/" + str(recovered_image) + update_data(output_dir, output_data) except Exception as e: - update_data(output_dir, {"image_resize": {"status": "error", "error": str(e)}}) + update_data(output_dir, {"image_resize": {"status": "error", "error": str(e)}}) \ No newline at end of file From 623361b7bc78dc8707d247f18ffac591498b8e15 Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 15:11:41 +0100 Subject: [PATCH 03/14] Added handling of multiple png candidates --- aperisolve/static/js/aperisolve.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/aperisolve/static/js/aperisolve.js b/aperisolve/static/js/aperisolve.js index 6e98b57..2867666 100644 --- a/aperisolve/static/js/aperisolve.js +++ b/aperisolve/static/js/aperisolve.js @@ -279,6 +279,12 @@ function parseResult(result) { )}'/>`; } + if ("png_images" in result[tool]) { + for (const image of result[tool]["png_images"]) { + analyzer.innerHTML += `
`; + } + } + // Parse text output From e6e086d4795ecef4283efdb2f0baa560a26451f3 Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 15:12:23 +0100 Subject: [PATCH 04/14] added sqlite3 to requirements --- aperisolve/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aperisolve/requirements.txt b/aperisolve/requirements.txt index 14446ca..6de4dbd 100644 --- a/aperisolve/requirements.txt +++ b/aperisolve/requirements.txt @@ -7,4 +7,5 @@ psycopg2-binary types-Flask-SQLAlchemy sqlalchemy-stubs Pillow -gunicorn \ No newline at end of file +gunicorn +sqlite3 \ No newline at end of file From 2dba82d04fb7ac09a05f0bf30753830dc12f7120 Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 15:13:09 +0100 Subject: [PATCH 05/14] added crc database to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index aa5b26a..12546d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Python __pycache__/ .mypy_cache/ -results/ \ No newline at end of file +results/ +ihdr_crcs.db \ No newline at end of file From d99651d3919305ed81885b2b7438b04b986d1515 Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 15:16:22 +0100 Subject: [PATCH 06/14] Added database creation in Dockerfile --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 67235ca..5487996 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,6 @@ COPY aperisolve/ /aperisolve/ RUN pip install --no-cache-dir -r /aperisolve/requirements.txt +RUN PYTHONPATH=/ python -c "from aperisolve.utils import create_crc_db; create_crc_db()" + CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "aperisolve.wsgi:application"] \ No newline at end of file From 9cacbf7d139883b7a187729c85303a6084a972a8 Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 15:22:12 +0100 Subject: [PATCH 07/14] sqlite3 is builtin and thus not needed in requirements --- aperisolve/requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aperisolve/requirements.txt b/aperisolve/requirements.txt index 6de4dbd..14446ca 100644 --- a/aperisolve/requirements.txt +++ b/aperisolve/requirements.txt @@ -7,5 +7,4 @@ psycopg2-binary types-Flask-SQLAlchemy sqlalchemy-stubs Pillow -gunicorn -sqlite3 \ No newline at end of file +gunicorn \ No newline at end of file From 705e19701fb56a0ab4c36d69af2cc383ed691b95 Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 16:37:46 +0100 Subject: [PATCH 08/14] Linter --- aperisolve/analyzers/image_resize.py | 17 ++++++----- aperisolve/analyzers/utils.py | 45 ++++++++++++++++++---------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index 549d588..45a6194 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -3,6 +3,7 @@ import zlib import sqlite3 from pathlib import Path +from typing import List from .utils import update_data # Full list of expected sizes @@ -182,7 +183,7 @@ def calc_checksum( return bytearray((zlib.crc32(new_header) & 0xFFFFFFFF).to_bytes(4, byteorder="big")) -def lookup_crc(crc_bytes: bytes, logs: list) -> list: +def lookup_crc(crc_bytes: bytes, logs: List[str]) -> List[tuple[int, int]]: """ Queries the SQLite DB for the CRC and returns a list of (width, height) tuples. """ @@ -196,7 +197,7 @@ def lookup_crc(crc_bytes: bytes, logs: list) -> list: try: target_crc = int.from_bytes(crc_bytes, byteorder="big") except ValueError: - logs.append(f"Invalid CRC bytes: {crc_bytes}") + logs.append(f"Invalid CRC bytes: {crc_bytes!r}") return [] conn = sqlite3.connect(db_path) @@ -210,9 +211,9 @@ def lookup_crc(crc_bytes: bytes, logs: list) -> list: if results: logs.append(f"Database: Found {len(results)} match(es) for CRC {target_crc}") return results - else: - logs.append(f"Database: No match found for CRC {target_crc}") - return [] + + logs.append(f"Database: No match found for CRC {target_crc}") + return [] def analyze_image_resize(input_img: Path, output_dir: Path) -> None: @@ -269,7 +270,9 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: # Check if this size matches the file's CRC if target_crc == calc_checksum(header_chunk, width_bytes, height_bytes): match_found = True - logs.append(f"Success (Common List): Match found! Dimensions: {width}x{height}") + logs.append( + f"Success (Common List): Match found! Dimensions: {width}x{height}" + ) candidates.append((width, height)) break # Stop after first match @@ -314,4 +317,4 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: update_data(output_dir, output_data) except Exception as e: - update_data(output_dir, {"image_resize": {"status": "error", "error": str(e)}}) \ No newline at end of file + update_data(output_dir, {"image_resize": {"status": "error", "error": str(e)}}) diff --git a/aperisolve/analyzers/utils.py b/aperisolve/analyzers/utils.py index 693ea94..55fdbaa 100644 --- a/aperisolve/analyzers/utils.py +++ b/aperisolve/analyzers/utils.py @@ -14,6 +14,14 @@ _thread_lock = threading.Lock() MAX_PENDING_TIME = int(os.getenv("MAX_PENDING_TIME", "600")) # 10 minutes by default +# PATH CONFIGURATION +# Prioritize /data for Docker persistence, fallback to local directory for bare metal +DB_NAME = "ihdr_crcs.db" +if os.path.exists("/data"): + DB_PATH = os.path.join("/data", DB_NAME) +else: + DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), DB_NAME) + def update_data( output_dir: Path, new_data: dict[Any, Any], json_filename: str = "results.json" @@ -51,7 +59,11 @@ def update_data( fcntl.flock(lock, fcntl.LOCK_UN) -def create_crc_db(): +def create_crc_db() -> None: + """Creates and populates CRC lookup database with CRC, Width and Heigth tuples""" + if os.path.exists(DB_PATH): + print(f"Database already exists at {DB_PATH}. Skipping generation.") + return db_filename = "ihdr_crcs.db" # Connect to database @@ -64,51 +76,53 @@ def create_crc_db(): # Create table # We store the CRC as an integer for faster indexing/storage - cursor.execute(""" + cursor.execute( + """ CREATE TABLE IF NOT EXISTS ihdr ( crc INTEGER, width INTEGER, height INTEGER ) - """) + """ + ) # Drop index if exists (rebuilding it at the end is faster) cursor.execute("DROP INDEX IF EXISTS idx_crc") max_width = 2000 max_height = 2000 - W = range(1, max_width + 1) - H = range(1, max_height + 1) + w = range(1, max_width + 1) + h = range(1, max_height + 1) # Standard PNG IHDR parameters bit_depth = [1, 2, 4, 8, 16] color_type = [0, 2, 3, 4, 6] - compression_method = [0] # This is always 0 according to the PNG spec - filter_method = [0] # This is always 0 according to the PNG spec + compression_method = [0] # This is always 0 according to the PNG spec + filter_method = [0] # This is always 0 according to the PNG spec interlace_method = [0, 1] # Create the generator all_combinations = itertools.product( - W, H, bit_depth, color_type, compression_method, filter_method, interlace_method + w, h, bit_depth, color_type, compression_method, filter_method, interlace_method ) batch_size = 100000 batch = [] count = 0 - for w, h, bd, ct, comp, filt, inter in all_combinations: + for w_val, h_val, bd, ct, comp, filt, inter in all_combinations: # Construct IHDR chunk data ihdr_data = ( - w.to_bytes(4, "big") + - h.to_bytes(4, "big") + - bytes([bd, ct, comp, filt, inter]) + w_val.to_bytes(4, "big") + + h_val.to_bytes(4, "big") + + bytes([bd, ct, comp, filt, inter]) ) # Calculate CRC - crc = zlib.crc32(b"IHDR" + ihdr_data) & 0xffffffff + crc = zlib.crc32(b"IHDR" + ihdr_data) & 0xFFFFFFFF # Add to batch - batch.append((crc, w, h)) + batch.append((crc, w_val, h_val)) if len(batch) >= batch_size: cursor.executemany("INSERT INTO ihdr VALUES (?,?,?)", batch) @@ -129,5 +143,4 @@ def create_crc_db(): conn.commit() conn.close() - print(f"Database ready.") - return \ No newline at end of file + print("Database ready.") From e94962ccbb910be55fa398802f0e28b945fd7c57 Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 18:26:56 +0100 Subject: [PATCH 09/14] Fixed dev environment database setup --- Dockerfile | 6 ++++-- aperisolve/analyzers/image_resize.py | 5 +++-- aperisolve/analyzers/utils.py | 8 +++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5487996..fc2198a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,10 @@ RUN apt-get update && apt-get install -y \ COPY aperisolve/ /aperisolve/ +RUN mkdir -p /data + RUN pip install --no-cache-dir -r /aperisolve/requirements.txt -RUN PYTHONPATH=/ python -c "from aperisolve.utils import create_crc_db; create_crc_db()" +RUN PYTHONPATH=/ python -c "from aperisolve.analyzers.utils import create_crc_db; create_crc_db()" -CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "aperisolve.wsgi:application"] \ No newline at end of file +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "aperisolve.wsgi:application"] diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index 45a6194..66a62c7 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -4,7 +4,8 @@ import zlib import sqlite3 from pathlib import Path from typing import List -from .utils import update_data +from .utils import update_data, DB_PATH + # Full list of expected sizes EXPECTED_SIZES = [ @@ -188,7 +189,7 @@ def lookup_crc(crc_bytes: bytes, logs: List[str]) -> List[tuple[int, int]]: Queries the SQLite DB for the CRC and returns a list of (width, height) tuples. """ # Locate DB relative to this script file - db_path = Path(__file__).parent / "ihdr_crcs.db" + db_path = Path(DB_PATH) if not db_path.exists(): logs.append(f"Error: Database not found at {db_path}") diff --git a/aperisolve/analyzers/utils.py b/aperisolve/analyzers/utils.py index 55fdbaa..c539abd 100644 --- a/aperisolve/analyzers/utils.py +++ b/aperisolve/analyzers/utils.py @@ -64,10 +64,12 @@ def create_crc_db() -> None: if os.path.exists(DB_PATH): print(f"Database already exists at {DB_PATH}. Skipping generation.") return - db_filename = "ihdr_crcs.db" - # Connect to database - conn = sqlite3.connect(db_filename) + # Use the global DB_PATH variable so it writes to /data/ in Docker + # and /analyzers/ in local dev + print(f"Generating database at: {DB_PATH}") + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() # Optimization: Turn off safety checks for bulk insertion speed From 50578eca1266d4e6472ce7053adb3bfd3e62080b Mon Sep 17 00:00:00 2001 From: Philip Zimmermann Date: Sat, 13 Dec 2025 18:32:21 +0100 Subject: [PATCH 10/14] Added test png for png size change analyzer --- examples/size_change_png.png | Bin 0 -> 63291 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 examples/size_change_png.png diff --git a/examples/size_change_png.png b/examples/size_change_png.png new file mode 100644 index 0000000000000000000000000000000000000000..064c96a7c64cf3cff4d39e779b5c273664b52496 GIT binary patch literal 63291 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 AH~;_u literal 0 HcmV?d00001 From d3928427e50b1e5c48557d437a4517ef6cb38d06 Mon Sep 17 00:00:00 2001 From: Zeecka Date: Sun, 14 Dec 2025 22:32:14 +0100 Subject: [PATCH 11/14] CRC Lookup on PostgreSQL WIP --- Dockerfile | 4 +- aperisolve/analyzers/image_resize.py | 228 +++------------------------ aperisolve/analyzers/utils.py | 116 ++++---------- aperisolve/config.py | 3 + aperisolve/models.py | 64 +++++++- 5 files changed, 117 insertions(+), 298 deletions(-) diff --git a/Dockerfile b/Dockerfile index fc2198a..5e4eb5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,6 @@ RUN mkdir -p /data RUN pip install --no-cache-dir -r /aperisolve/requirements.txt -RUN PYTHONPATH=/ python -c "from aperisolve.analyzers.utils import create_crc_db; create_crc_db()" +ENV PYTHONUNBUFFERED=1 -CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "aperisolve.wsgi:application"] +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "--capture-output", "aperisolve.wsgi:application"] \ No newline at end of file diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index 66a62c7..c294ad9 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -7,144 +7,6 @@ from typing import List from .utils import update_data, DB_PATH -# Full list of expected sizes -EXPECTED_SIZES = [ - # ------------------------- - # ICONS (Square) - # ------------------------- - (16, 16), - (20, 20), - (24, 24), - (28, 28), - (32, 32), - (48, 48), - (64, 64), - (72, 72), - (96, 96), - (128, 128), - (192, 192), - (256, 256), - (384, 384), - (512, 512), - (768, 768), - (1024, 1024), - (1536, 1536), - (2048, 2048), - (4096, 4096), - # ------------------------- - # 1:1 Square (General) - # ------------------------- - (300, 300), - (500, 500), - (800, 800), - (1000, 1000), - (1500, 1500), - (2000, 2000), - (2500, 2500), - (3000, 3000), - # ------------------------- - # 4:3 Aspect (Classic Screens) - # ------------------------- - (320, 240), - (640, 480), - (800, 600), - (960, 720), - (1024, 768), - (1280, 960), - (1600, 1200), - (2048, 1536), - (2560, 1920), - (3200, 2400), - (4096, 3072), - # ------------------------- - # 3:2 Aspect (Photography) - # ------------------------- - (600, 400), - (900, 600), - (1200, 800), - (1500, 1000), - (1800, 1200), - (2400, 1600), - (3000, 2000), - (3600, 2400), - (4500, 3000), - (6000, 4000), - # ------------------------- - # 16:9 Aspect (Video / Web) - # ------------------------- - (426, 240), - (640, 360), - (854, 480), - (960, 540), - (1280, 720), - (1366, 768), - (1600, 900), - (1920, 1080), - (2560, 1440), - (3200, 1800), - (3440, 1935), - (3840, 2160), - (5120, 2880), - (7680, 4320), - (10240, 5760), - # ------------------------- - # 16:10 Aspect (Laptops) - # ------------------------- - (1280, 800), - (1440, 900), - (1680, 1050), - (1920, 1200), - (2560, 1600), - (2880, 1800), - (3840, 2400), - (5120, 3200), - # ------------------------- - # Mobile Portrait (9:16) - # ------------------------- - (720, 1280), - (1080, 1920), - (1440, 2560), - (2160, 3840), - (1440, 2960), - (1080, 2400), - (1170, 2532), - (1284, 2778), - (1290, 2796), - # ------------------------- - # Social Media Sizes - # ------------------------- - # Instagram - (1080, 1080), - (1080, 1350), - (1080, 1920), - # Facebook - (1200, 628), - (1200, 1200), - # Twitter/X - (1200, 675), - (1500, 500), - # YouTube Thumbnails - (1280, 720), - (1920, 1080), - # ------------------------- - # Banners / Headers - # ------------------------- - (1600, 500), - (1920, 500), - (2560, 700), - (3000, 1000), - (3840, 1200), - # ------------------------- - # Print-like Digital Sizes - # ------------------------- - (2550, 3300), # Letter @ 300 DPI - (2480, 3508), # A4 @ 300 DPI - (3508, 4961), # A3 @ 300 DPI - (4961, 7016), # A2 @ 300 DPI - (7016, 9933), # A1 @ 300 DPI -] - - def write_recovered_png( png_bytes: bytearray, ihdr_offset: int, @@ -184,36 +46,11 @@ def calc_checksum( return bytearray((zlib.crc32(new_header) & 0xFFFFFFFF).to_bytes(4, byteorder="big")) -def lookup_crc(crc_bytes: bytes, logs: List[str]) -> List[tuple[int, int]]: +def lookup_crc(crc_bytes: bytes) -> List[tuple[int, int]]: """ Queries the SQLite DB for the CRC and returns a list of (width, height) tuples. """ - # Locate DB relative to this script file - db_path = Path(DB_PATH) - - if not db_path.exists(): - logs.append(f"Error: Database not found at {db_path}") - return [] - - try: - target_crc = int.from_bytes(crc_bytes, byteorder="big") - except ValueError: - logs.append(f"Invalid CRC bytes: {crc_bytes!r}") - return [] - - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Query the database - cursor.execute("SELECT width, height FROM ihdr WHERE crc = ?", (target_crc,)) - results = cursor.fetchall() # Returns list of (width, height) - conn.close() - - if results: - logs.append(f"Database: Found {len(results)} match(es) for CRC {target_crc}") - return results - - logs.append(f"Database: No match found for CRC {target_crc}") + # TODO query DB return [] @@ -252,59 +89,32 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: # Extract Target CRC (The 4 bytes AFTER the chunk data) target_crc = b[ihdr + chunk_length : ihdr + chunk_length + 4] - # Isolate the header chunk (Type + Data) - header_chunk = b[ihdr : ihdr + chunk_length] - logs.append(f"Target CRC found: 0x{target_crc.hex()}") - match_found = False candidates = [] - - # --- STRATEGY 1: Check Common Sizes --- - for size in EXPECTED_SIZES: - width, height = size - - # Convert dimensions to 4-byte Big Endian arrays - width_bytes = bytearray(width.to_bytes(4, byteorder="big")) - height_bytes = bytearray(height.to_bytes(4, byteorder="big")) - - # Check if this size matches the file's CRC - if target_crc == calc_checksum(header_chunk, width_bytes, height_bytes): - match_found = True - logs.append( - f"Success (Common List): Match found! Dimensions: {width}x{height}" - ) - candidates.append((width, height)) - break # Stop after first match - - # --- STRATEGY 2: Check SQLite DB --- - if not match_found: - logs.append("No common size matched. Checking database...") - db_matches = lookup_crc(target_crc, logs) - if db_matches: - candidates.extend(db_matches) - match_found = True - - # --- Generate Output Images --- saved_img_urls = [] - if match_found and candidates: - # Create parent directories if they don't exist - output_dir.mkdir(parents=True, exist_ok=True) + + db_matches = lookup_crc(target_crc) + if db_matches: + candidates.extend(db_matches) + if candidates: + # Create parent directories if they don't exist + output_dir.mkdir(parents=True, exist_ok=True) - # Iterate through ALL candidates found - for w, h in candidates: - img_name = f"recovered_{w}x{h}.png" - output_path = output_dir / img_name - write_recovered_png(b, ihdr, w, h, output_path) + # Iterate through ALL candidates found + for w, h in candidates: + img_name = f"recovered_{w}x{h}.png" + output_path = output_dir / img_name + write_recovered_png(b, ihdr, w, h, output_path) - logs.append(f"Image saved: {img_name}") + logs.append(f"Image saved: {img_name}") - # Append the formatted URL to our list - saved_img_urls.append("/image/" + str(Path(output_dir.name) / img_name)) + # Append the formatted URL to our list + saved_img_urls.append("/image/" + str(Path(output_dir.name) / img_name)) - else: - logs.append("Failure: No matching dimensions found in List or DB.") + else: + logs.append("Failure: No matching dimensions found in List or DB.") # 4. Final Data Update output_data = { diff --git a/aperisolve/analyzers/utils.py b/aperisolve/analyzers/utils.py index c539abd..f54042c 100644 --- a/aperisolve/analyzers/utils.py +++ b/aperisolve/analyzers/utils.py @@ -16,12 +16,6 @@ MAX_PENDING_TIME = int(os.getenv("MAX_PENDING_TIME", "600")) # 10 minutes by de # PATH CONFIGURATION # Prioritize /data for Docker persistence, fallback to local directory for bare metal -DB_NAME = "ihdr_crcs.db" -if os.path.exists("/data"): - DB_PATH = os.path.join("/data", DB_NAME) -else: - DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), DB_NAME) - def update_data( output_dir: Path, new_data: dict[Any, Any], json_filename: str = "results.json" @@ -59,90 +53,42 @@ def update_data( fcntl.flock(lock, fcntl.LOCK_UN) -def create_crc_db() -> None: - """Creates and populates CRC lookup database with CRC, Width and Heigth tuples""" - if os.path.exists(DB_PATH): - print(f"Database already exists at {DB_PATH}. Skipping generation.") - return - - # Use the global DB_PATH variable so it writes to /data/ in Docker - # and /analyzers/ in local dev - print(f"Generating database at: {DB_PATH}") - conn = sqlite3.connect(DB_PATH) - - cursor = conn.cursor() - - # Optimization: Turn off safety checks for bulk insertion speed - cursor.execute("PRAGMA synchronous = OFF") - cursor.execute("PRAGMA journal_mode = MEMORY") - - # Create table - # We store the CRC as an integer for faster indexing/storage - cursor.execute( - """ - CREATE TABLE IF NOT EXISTS ihdr ( - crc INTEGER, - width INTEGER, - height INTEGER - ) +def pack_ihdr(width: int, height: int, depth: int, color: int, interlace: int) -> int: """ - ) + Pack IHDR fields into a single integer. - # Drop index if exists (rebuilding it at the end is faster) - cursor.execute("DROP INDEX IF EXISTS idx_crc") + Layout: + width : 16 bits + height : 16 bits + depth : 3 bits + color : 3 bits + interlace : 1 bit + """ - max_width = 2000 - max_height = 2000 - w = range(1, max_width + 1) - h = range(1, max_height + 1) + if not (0 < width <= 0xFFFF): + raise ValueError("width out of range") + if not (0 < height <= 0xFFFF): + raise ValueError("height out of range") + if depth not in (1, 2, 4, 8, 16): + raise ValueError("invalid depth") + if color not in (0, 2, 3, 4, 6): + raise ValueError("invalid color") + if interlace not in (0, 1): + raise ValueError("invalid interlace") - # Standard PNG IHDR parameters - bit_depth = [1, 2, 4, 8, 16] - color_type = [0, 2, 3, 4, 6] - compression_method = [0] # This is always 0 according to the PNG spec - filter_method = [0] # This is always 0 according to the PNG spec - interlace_method = [0, 1] + return (width << 23) | (height << 7) | (depth << 4) | (color << 1) | interlace - # Create the generator - all_combinations = itertools.product( - w, h, bit_depth, color_type, compression_method, filter_method, interlace_method - ) - batch_size = 100000 - batch = [] - count = 0 +def unpack_ihdr(packed: int) -> tuple[int, int, int, int, int]: + """ + Unpack packed IHDR integer into components: + (width, height, depth, color, interlace) + """ - for w_val, h_val, bd, ct, comp, filt, inter in all_combinations: - # Construct IHDR chunk data - ihdr_data = ( - w_val.to_bytes(4, "big") - + h_val.to_bytes(4, "big") - + bytes([bd, ct, comp, filt, inter]) - ) + interlace = packed & 0b1 + color = (packed >> 1) & 0b111 + depth = (packed >> 4) & 0b111 + height = (packed >> 7) & 0xFFFF + width = (packed >> 23) & 0xFFFF - # Calculate CRC - crc = zlib.crc32(b"IHDR" + ihdr_data) & 0xFFFFFFFF - - # Add to batch - batch.append((crc, w_val, h_val)) - - if len(batch) >= batch_size: - cursor.executemany("INSERT INTO ihdr VALUES (?,?,?)", batch) - conn.commit() - batch = [] - count += batch_size - if count % 1000000 == 0: - print(f"Inserted {count:,} rows...") - - # Insert remaining records - if batch: - cursor.executemany("INSERT INTO ihdr VALUES (?,?,?)", batch) - conn.commit() - - print("Insertion complete. Creating Index for faster lookups...") - # Creating the index allows for faster lookups - cursor.execute("CREATE INDEX idx_crc ON ihdr(crc)") - conn.commit() - - conn.close() - print("Database ready.") + return width, height, depth, color, interlace diff --git a/aperisolve/config.py b/aperisolve/config.py index fe000d3..dc12255 100644 --- a/aperisolve/config.py +++ b/aperisolve/config.py @@ -6,3 +6,6 @@ IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff"] WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize"] RESULT_FOLDER = Path(__file__).parent.resolve() / "results" + +MAX_WIDTH = 2000 # Used for IHDR lookup table +MAX_HEIGHT = 2000 # Used for IHDR lookup table \ No newline at end of file diff --git a/aperisolve/models.py b/aperisolve/models.py index 730bc63..b44e73c 100644 --- a/aperisolve/models.py +++ b/aperisolve/models.py @@ -1,11 +1,16 @@ """This module defines the database models for the Aperisolve application.""" +import zlib +import itertools + from datetime import datetime, timezone from decimal import Decimal +from aperisolve.analyzers.utils import pack_ihdr +from aperisolve.config import MAX_HEIGHT, MAX_WIDTH from flask import Flask from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, BigInteger db: SQLAlchemy = SQLAlchemy() @@ -52,10 +57,65 @@ class Submission(db.Model): # type: ignore image_hash = Column(String, db.ForeignKey("image.hash"), nullable=False) +class IHDR(db.Model): # type: ignore + """IHDR CRC lookup table""" + iid : Column[int] = Column(Integer, primary_key=True, autoincrement=True, unique=True) + crc : Column[int] = Column(BigInteger) + packed : Column[int] = Column(Integer) + + +def create_crc_db() -> None: + w = range(1, MAX_WIDTH + 1) + h = range(1, MAX_HEIGHT + 1) + + # Standard PNG IHDR parameters + bit_depth = [1, 2, 4, 8, 16] + color_type = [0, 2, 3, 4, 6] + compression_method = [0] # This is always 0 according to the PNG spec + filter_method = [0] # This is always 0 according to the PNG spec + interlace_method = [0, 1] + + # Create the generator + all_combinations = itertools.product( + w, h, bit_depth, color_type, compression_method, filter_method, interlace_method + ) + + count = 0 + max_count = MAX_WIDTH * MAX_HEIGHT * len(bit_depth) * len(color_type) * len(interlace_method) + for w_val, h_val, bd, ct, comp, filt, inter in all_combinations: + count +=1 + # Construct IHDR chunk data + ihdr_data = ( + w_val.to_bytes(4, "big") + + h_val.to_bytes(4, "big") + + bytes([bd, ct, comp, filt, inter]) + ) + + # Calculate CRC + crc = zlib.crc32(b"IHDR" + ihdr_data) & 0xFFFFFFFF + + # Compute packed + packed = pack_ihdr(w_val, h_val, bd, ct, inter) + + # Create and add IHDR entry to database + ihdr_entry = IHDR(crc=crc, packed=packed) + db.session.add(ihdr_entry) + + if count % 10000 == 0: + print(f"Inserted {count}/{max_count} ({int((count*100)/max_count)}%) IHDR entries...") + db.session.commit() + + # Commit all entries + db.session.commit() + + def init_db(app: Flask) -> None: """Initialize the database with the given Flask app.""" with app.app_context(): if not db.engine.dialect.has_table(db.engine.connect(), "image"): print("Creating database...") db.create_all() - print("Database created successfully.") + print("Database structure created successfully.") + print("Filling IHDR lookup table...") + create_crc_db() + print("IHDR table filled successfully.") From db8fc2eda5bcb709a65ca04a022a67884000e3df Mon Sep 17 00:00:00 2001 From: Zeecka Date: Sun, 14 Dec 2025 23:02:48 +0100 Subject: [PATCH 12/14] WIP --- aperisolve/analyzers/image_resize.py | 61 ++++++++++++---------------- aperisolve/analyzers/utils.py | 13 +----- aperisolve/config.py | 4 +- aperisolve/models.py | 23 ++++++++--- 4 files changed, 46 insertions(+), 55 deletions(-) diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index c294ad9..e975aac 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -4,7 +4,9 @@ import zlib import sqlite3 from pathlib import Path from typing import List -from .utils import update_data, DB_PATH +from .utils import update_data, unpack_ihdr +from ..app import app +from ..models import IHDR def write_recovered_png( @@ -17,6 +19,7 @@ def write_recovered_png( """ Rebuilds a PNG by patching IHDR width/height and writes it to disk. """ + # TODO, use depth/color later if needed height_bytes = height.to_bytes(4, byteorder="big") width_bytes = width.to_bytes(4, byteorder="big") @@ -32,26 +35,18 @@ def write_recovered_png( out_f.write(full_png_data) -def calc_checksum( - header_chunk: bytearray, width_bytes: bytearray, height_bytes: bytearray -) -> bytearray: - """ - Calculates the CRC32 of the IHDR chunk with new dimensions. - header_chunk: The full IHDR chunk (Type + Data). - """ - # Reconstruct IHDR: [Type(4)] + [New Width(4)] + [New Height(4)] + [Rest of Data(5)] - new_header = header_chunk[:4] + width_bytes + height_bytes + header_chunk[12:] - - # Calculate CRC and return as 4 bytes (Big Endian) - return bytearray((zlib.crc32(new_header) & 0xFFFFFFFF).to_bytes(4, byteorder="big")) - - -def lookup_crc(crc_bytes: bytes) -> List[tuple[int, int]]: +def lookup_crc(crc_bytes: bytes) -> List[tuple[int, int, int, int, int]]: """ Queries the SQLite DB for the CRC and returns a list of (width, height) tuples. """ - # TODO query DB - return [] + with app.app_context(): + crc_b = int.from_bytes(crc_bytes, byteorder="big") + imgs = IHDR.query.filter_by(crc=crc_b).all() + results = [] + for img in imgs: + packed = img.packed + results.append(unpack_ihdr(packed)) + return results def analyze_image_resize(input_img: Path, output_dir: Path) -> None: @@ -91,30 +86,26 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: logs.append(f"Target CRC found: 0x{target_crc.hex()}") - candidates = [] saved_img_urls = [] - db_matches = lookup_crc(target_crc) if db_matches: - candidates.extend(db_matches) - if candidates: - # Create parent directories if they don't exist - output_dir.mkdir(parents=True, exist_ok=True) + # Create parent directories if they don't exist + output_dir.mkdir(parents=True, exist_ok=True) - # Iterate through ALL candidates found - for w, h in candidates: - img_name = f"recovered_{w}x{h}.png" - output_path = output_dir / img_name - write_recovered_png(b, ihdr, w, h, output_path) + # Iterate through ALL candidates found + for elt in db_matches: + w, h, _, _, _ = elt # TODO, use depth/color later if needed + img_name = f"recovered_{w}x{h}.png" + output_path = output_dir / img_name + write_recovered_png(b, ihdr, w, h, output_path) - logs.append(f"Image saved: {img_name}") + logs.append(f"Image saved: {img_name}") - # Append the formatted URL to our list - saved_img_urls.append("/image/" + str(Path(output_dir.name) / img_name)) - - else: - logs.append("Failure: No matching dimensions found in List or DB.") + # Append the formatted URL to our list + saved_img_urls.append("/image/" + str(Path(output_dir.name) / img_name)) + else: + logs.append("Failure: No matching dimensions found in List or DB.") # 4. Final Data Update output_data = { diff --git a/aperisolve/analyzers/utils.py b/aperisolve/analyzers/utils.py index f54042c..ff6e627 100644 --- a/aperisolve/analyzers/utils.py +++ b/aperisolve/analyzers/utils.py @@ -17,6 +17,7 @@ MAX_PENDING_TIME = int(os.getenv("MAX_PENDING_TIME", "600")) # 10 minutes by de # PATH CONFIGURATION # Prioritize /data for Docker persistence, fallback to local directory for bare metal + def update_data( output_dir: Path, new_data: dict[Any, Any], json_filename: str = "results.json" ) -> None: @@ -64,18 +65,6 @@ def pack_ihdr(width: int, height: int, depth: int, color: int, interlace: int) - color : 3 bits interlace : 1 bit """ - - if not (0 < width <= 0xFFFF): - raise ValueError("width out of range") - if not (0 < height <= 0xFFFF): - raise ValueError("height out of range") - if depth not in (1, 2, 4, 8, 16): - raise ValueError("invalid depth") - if color not in (0, 2, 3, 4, 6): - raise ValueError("invalid color") - if interlace not in (0, 1): - raise ValueError("invalid interlace") - return (width << 23) | (height << 7) | (depth << 4) | (color << 1) | interlace diff --git a/aperisolve/config.py b/aperisolve/config.py index dc12255..f3f0197 100644 --- a/aperisolve/config.py +++ b/aperisolve/config.py @@ -7,5 +7,5 @@ WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize"] RESULT_FOLDER = Path(__file__).parent.resolve() / "results" -MAX_WIDTH = 2000 # Used for IHDR lookup table -MAX_HEIGHT = 2000 # Used for IHDR lookup table \ No newline at end of file +MAX_WIDTH = 5 # Used for IHDR lookup table +MAX_HEIGHT = 5 # Used for IHDR lookup table diff --git a/aperisolve/models.py b/aperisolve/models.py index b44e73c..fc73a4f 100644 --- a/aperisolve/models.py +++ b/aperisolve/models.py @@ -59,9 +59,12 @@ class Submission(db.Model): # type: ignore class IHDR(db.Model): # type: ignore """IHDR CRC lookup table""" - iid : Column[int] = Column(Integer, primary_key=True, autoincrement=True, unique=True) - crc : Column[int] = Column(BigInteger) - packed : Column[int] = Column(Integer) + + iid: Column[int] = Column( + Integer, primary_key=True, autoincrement=True, unique=True + ) + crc: Column[int] = Column(BigInteger) + packed: Column[int] = Column(Integer) def create_crc_db() -> None: @@ -81,9 +84,15 @@ def create_crc_db() -> None: ) count = 0 - max_count = MAX_WIDTH * MAX_HEIGHT * len(bit_depth) * len(color_type) * len(interlace_method) + max_count = ( + MAX_WIDTH + * MAX_HEIGHT + * len(bit_depth) + * len(color_type) + * len(interlace_method) + ) for w_val, h_val, bd, ct, comp, filt, inter in all_combinations: - count +=1 + count += 1 # Construct IHDR chunk data ihdr_data = ( w_val.to_bytes(4, "big") @@ -102,7 +111,9 @@ def create_crc_db() -> None: db.session.add(ihdr_entry) if count % 10000 == 0: - print(f"Inserted {count}/{max_count} ({int((count*100)/max_count)}%) IHDR entries...") + print( + f"Inserted {count}/{max_count} ({int((count*100)/max_count)}%) IHDR entries..." + ) db.session.commit() # Commit all entries From 796de9c576fb420f5265a392beb0898c6d88f79f Mon Sep 17 00:00:00 2001 From: Zeecka Date: Fri, 26 Dec 2025 22:19:37 +0100 Subject: [PATCH 13/14] Image resize and linting --- aperisolve/analyzers/image_resize.py | 138 +++++++++++----- aperisolve/analyzers/utils.py | 239 ++++++++++++++++++++++++--- aperisolve/cleanup.py | 23 ++- aperisolve/config.py | 3 - aperisolve/models.py | 108 ++++++------ aperisolve/workers.py | 34 +++- 6 files changed, 421 insertions(+), 124 deletions(-) diff --git a/aperisolve/analyzers/image_resize.py b/aperisolve/analyzers/image_resize.py index e975aac..7fdfcb4 100644 --- a/aperisolve/analyzers/image_resize.py +++ b/aperisolve/analyzers/image_resize.py @@ -1,17 +1,15 @@ """Image size bruteforcer for common PNG sizes.""" -import zlib -import sqlite3 from pathlib import Path from typing import List -from .utils import update_data, unpack_ihdr + from ..app import app from ..models import IHDR +from .utils import PNG, update_data def write_recovered_png( png_bytes: bytearray, - ihdr_offset: int, width: int, height: int, output_path: Path, @@ -19,40 +17,102 @@ def write_recovered_png( """ Rebuilds a PNG by patching IHDR width/height and writes it to disk. """ - # TODO, use depth/color later if needed height_bytes = height.to_bytes(4, byteorder="big") width_bytes = width.to_bytes(4, byteorder="big") - # Splicing: [Start...IHDR+4] + [W] + [H] + [IHDR+12...End] full_png_data = ( - png_bytes[: ihdr_offset + 4] + png_bytes[:16] # Splicing: [Start...IHDR+4] + [W] + [H] + [IHDR+12...End] + width_bytes + height_bytes - + png_bytes[ihdr_offset + 12 :] + + png_bytes[24:] ) with output_path.open("wb") as out_f: out_f.write(full_png_data) -def lookup_crc(crc_bytes: bytes) -> List[tuple[int, int, int, int, int]]: +def lookup_crc(png: PNG) -> List[PNG]: """ - Queries the SQLite DB for the CRC and returns a list of (width, height) tuples. + Look up PNG images in the database by their CRC value. + + Args: + png (PNG): A PNG object containing the CRC value to search for. + + Returns: + List[PNG]: A list of PNG objects with matching CRC values, reconstructed from their packed + representations. """ with app.app_context(): - crc_b = int.from_bytes(crc_bytes, byteorder="big") - imgs = IHDR.query.filter_by(crc=crc_b).all() + imgs = IHDR.query.filter_by(crc=png.crc).all() results = [] for img in imgs: packed = img.packed - results.append(unpack_ihdr(packed)) + results.append(PNG.from_packed(packed, crc=png.crc)) + return results + + +def search_height_crc(png: PNG) -> List[PNG]: + """ + Search for PNG images with matching CRC by iterating through possible heights. + + This function attempts to find alternative PNG configurations that produce the same + CRC checksum as the input PNG by systematically varying the height parameter while + keeping all other properties constant. + + Args: + png (PNG): The reference PNG object whose CRC checksum will be matched. + + Returns: + List[PNG]: A list of PNG objects with matching CRC values. Returns an empty list + if no matches are found. + + Note: + The function iterates through heights from 100 to 3500 (inclusive). + This may be computationally expensive for large ranges. + """ + results = [] + for height in range(100, 3501): + candidate = PNG( + width=png.width, + height=height, + bit_depth=png.bit_depth, + color_type=png.color_type, + interlace=png.interlace, + ) + if candidate.crc == png.crc: + results.append(candidate) return results def analyze_image_resize(input_img: Path, output_dir: Path) -> None: """ - Analyze an image submission using python-based resize bruteforce. - Strategy: Check common sizes first, then fall back to DB lookup. + Analyze a PNG image and recover resized versions based on CRC matching. + + This function attempts to find matching image dimensions for a given PNG file + by looking up its CRC (Cyclic Redundancy Check) in a database. If matches are + found, it recovers and saves PNG images with the matched dimensions. + + Args: + input_img (Path): Path to the input PNG image file to analyze. + output_dir (Path): Path to the directory where recovered PNG images will be saved. + + Returns: + None + + Raises: + Handles exceptions gracefully by updating output data with error status. + + Side Effects: + - Creates output directory if it doesn't exist. + - Saves recovered PNG images to the output directory. + - Updates data in the output directory with analysis results via update_data(). + + Notes: + - If the PNG has invalid structure or IHDR chunk is not first, an error is logged. + - The function performs a two-step lookup: first by CRC, then by height/CRC if no matches + found. + - Recovered images are named as "recovered_{width}x{height}.png". + - Analysis results (logs, status, and image URLs) are persisted via update_data(). """ # 1. Setup Paths @@ -64,48 +124,48 @@ def analyze_image_resize(input_img: Path, output_dir: Path) -> None: try: with input_img.open("rb") as image: - f = image.read() + img_bytes = bytearray(image.read()) + png = PNG.from_bytearray(img_bytes) - b = bytearray(f) - - # Find IHDR start - ihdr = b.find(b"\x49\x48\x44\x52") - if ihdr == -1: - logs.append("Failure: PNG header (IHDR) not found.") + if png is None: + logs.append( + "Failure: IHDR chunk is not the first chunk, or PNG has invalid structure." + ) update_data( output_dir, - {"image_resize": {"status": "error", "error": "PNG header not found."}}, + { + "image_resize": { + "status": "error", + "error": "IHDR chunk is not the first chunk, or PNG has invalid structure.", + } + }, ) return - # Calculate Chunk Length (The 4 bytes BEFORE IHDR tag) - chunk_length = int.from_bytes((b[ihdr - 4 : ihdr]), byteorder="big") + 4 - - # Extract Target CRC (The 4 bytes AFTER the chunk data) - target_crc = b[ihdr + chunk_length : ihdr + chunk_length + 4] - - logs.append(f"Target CRC found: 0x{target_crc.hex()}") + logs.append(f"Target CRC found: 0x{png.crc:08x}") saved_img_urls = [] - db_matches = lookup_crc(target_crc) - if db_matches: + db_matches = lookup_crc(png) + if not db_matches: + db_matches = search_height_crc(png) + if not db_matches: + logs.append("Failure: No matching dimensions found.") + else: # Create parent directories if they don't exist output_dir.mkdir(parents=True, exist_ok=True) # Iterate through ALL candidates found - for elt in db_matches: - w, h, _, _, _ = elt # TODO, use depth/color later if needed - img_name = f"recovered_{w}x{h}.png" + for im_match in db_matches: + img_name = f"recovered_{im_match.width}x{im_match.height}.png" output_path = output_dir / img_name - write_recovered_png(b, ihdr, w, h, output_path) + write_recovered_png( + img_bytes, im_match.width, im_match.height, output_path + ) logs.append(f"Image saved: {img_name}") - # Append the formatted URL to our list saved_img_urls.append("/image/" + str(Path(output_dir.name) / img_name)) - else: - logs.append("Failure: No matching dimensions found in List or DB.") # 4. Final Data Update output_data = { diff --git a/aperisolve/analyzers/utils.py b/aperisolve/analyzers/utils.py index ff6e627..13d7196 100644 --- a/aperisolve/analyzers/utils.py +++ b/aperisolve/analyzers/utils.py @@ -2,13 +2,14 @@ import fcntl import json +import math import os +import struct import threading -from pathlib import Path -from typing import Any -import itertools import zlib -import sqlite3 +from functools import cached_property +from pathlib import Path +from typing import Any, Self _thread_lock = threading.Lock() @@ -54,30 +55,218 @@ def update_data( fcntl.flock(lock, fcntl.LOCK_UN) -def pack_ihdr(width: int, height: int, depth: int, color: int, interlace: int) -> int: - """ - Pack IHDR fields into a single integer. - - Layout: - width : 16 bits - height : 16 bits - depth : 3 bits - color : 3 bits - interlace : 1 bit - """ - return (width << 23) | (height << 7) | (depth << 4) | (color << 1) | interlace +def get_resolutions(): + BASE_WIDTHS = ( + list(range(16, 257, 16)) # 16 → 256 + + list(range(320, 1025, 32)) # 320 → 1024 + + list(range(1280, 2561, 64)) # 1280 → 2560 + + list(range(3000, 4097, 128)) # 3K–4K + + list(range(5120, 8193, 256)) # 5K–8K + + [10000] # upper bound + ) + ASPECT_RATIOS = [ + # Screens / digital + (1, 1), # square + (4, 3), + (3, 2), + (16, 10), + (16, 9), + (21, 9), + # Photography / print + (5, 4), # 8x10 + (7, 5), # 5x7 + (3, 2), # 4x6 (already included, kept for clarity) + (2, 3), # poster + # Paper standards + (1414, 1000), # ISO A-series (A4, A3, A2, ...) + (11, 85), # US Letter + (14, 85), # Legal + (17, 11), # Tabloid + ] + resolutions = set() + for w in BASE_WIDTHS: + for ar_w, ar_h in ASPECT_RATIOS: + h = math.floor(round(w * ar_h / ar_w)) + if 1 <= h <= 10000: + resolutions.add((w, h)) + resolutions.add((h, w)) # portrait / landscape + return sorted(resolutions) -def unpack_ihdr(packed: int) -> tuple[int, int, int, int, int]: +def get_valid_depth_color_pairs(): + VALID_COLOR_DEPTHS = { + 0: [1, 2, 4, 8, 16], + 2: [8, 16], + 3: [1, 2, 4, 8], + 4: [8, 16], + 6: [8, 16], + } + for color, depths in VALID_COLOR_DEPTHS.items(): + for depth in depths: + yield depth, color + + +class PNG: """ - Unpack packed IHDR integer into components: - (width, height, depth, color, interlace) + A class representing a PNG image header (IHDR chunk). + This class encapsulates the metadata from a PNG file's IHDR (Image Header) chunk, + including image dimensions, bit depth, color type, interlacing method, and CRC checksum. + It provides methods for parsing PNG files, packing/unpacking header data, and generating + PNG crc if omitted. """ - interlace = packed & 0b1 - color = (packed >> 1) & 0b111 - depth = (packed >> 4) & 0b111 - height = (packed >> 7) & 0xFFFF - width = (packed >> 23) & 0xFFFF + width: int + height: int + bit_depth: int + color_type: int + crc: int + interlace: int = 0 - return width, height, depth, color, interlace + def __init__( + self, + width: int, + height: int, + bit_depth: int, + color_type: int, + crc: int | None = None, + interlace: int = 0, + ): + """Initialize a PNG IHDR (Image Header) chunk. + + Args: + width (int): The width of the image in pixels. + height (int): The height of the image in pixels. + bit_depth (int): The number of bits per sample or per palette index (1, 2, 4, 8, or 16). + color_type (int): The color type of the image (0=Grayscale, 2=RGB, 3=Indexed, + 4=Grayscale+Alpha, 6=RGBA). + crc (int | None, optional): CRC (Cyclic Redundancy Check) checksum for the chunk. + If None, it will be calculated automatically. Defaults + to None. + interlace (int, optional): The interlace method (0=no interlace, 1=Adam7 interlace). + Defaults to 0. + """ + self.width = width + self.height = height + self.bit_depth = bit_depth + self.color_type = color_type + self.crc = ( + crc + or zlib.crc32( + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + + bytes((bit_depth, color_type, 0, 0, interlace)) + ) + & 0xFFFFFFFF + ) + self.interlace = interlace + + @classmethod + def from_packed(cls, packed: int, crc: int | None = None) -> Self: + """Create a PNG instance from a packed integer representation. + + Parse a packed integer containing width, height, bit depth, color type and interlace + bits and return a PNG instance. The CRC may be supplied; if omitted it will be + computed by the constructor. + + Args: + packed (int): Packed integer with fields laid out as described in the `packed` property. + crc (int | None): Optional CRC value for the IHDR chunk. + + Returns: + Self: PNG instance with parsed fields. + """ + interlace = packed & 0b1 + color = (packed >> 1) & 0b111 + depth = (packed >> 4) & 0b111 + height = (packed >> 7) & 0xFFFF + width = (packed >> 23) & 0xFFFF + return cls( + width=width, + height=height, + bit_depth=depth, + color_type=color, + crc=crc, + interlace=interlace, + ) + + @cached_property + def packed(self) -> int: + """Pack IHDR fields into a single integer. + + Returns: + int: Packed integer encoding the IHDR fields. + + Layout: + width : 16 bits + height : 16 bits + depth : 3 bits + color : 3 bits + interlace : 1 bit + """ + return ( + ((self.width & 0xFFFF) << 23) + | ((self.height & 0xFFFF) << 7) + | ((self.bit_depth & 0b111) << 4) + | ((self.color_type & 0b111) << 1) + | (self.interlace & 0b1) + ) + + @classmethod + def from_bytearray(cls, data: bytearray) -> Self: + """Create a PNG image instance from a bytearray containing PNG file data. + + Parses the PNG signature and IHDR (Image Header) chunk to extract image metadata + including dimensions, bit depth, color type, and interlacing information. + + Args: + data (bytearray): A bytearray containing the complete PNG file data, starting + with the PNG signature (8 bytes: \\x89PNG\\r\\n\\x1a\\n). + + Returns: + Self | None: A new instance of the PNG image class with parsed IHDR metadata + (width, height, bit_depth, color_type, crc, interlace) if the data is + valid and contains a proper IHDR chunk. Returns None if the PNG signature + is invalid or the IHDR chunk type is not found. + + Raises: + struct.error: If the data is too short or malformed for unpacking IHDR fields. + IndexError: If the bytearray is shorter than expected for the PNG structure. + """ + + PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + if data[:8] != PNG_SIGNATURE: + raise ValueError("Invalid PNG signature") + offset = 8 # Read IHDR chunk + length = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + chunk_type = data[offset : offset + 4] + offset += 4 + if chunk_type != b"IHDR": + raise ValueError("IHDR chunk not found at expected position") + + ihdr_data = data[offset : offset + length] + offset += length + + crc = struct.unpack(">I", data[offset : offset + 4])[0] + + # Unpack IHDR fields + width, height, depth, color, _, _, interlace = struct.unpack( + ">IIBBBBB", ihdr_data + ) + + return cls( + width=width, + height=height, + bit_depth=depth, + color_type=color, + crc=crc, + interlace=interlace, + ) + + def __repr__(self) -> str: + return ( + f"PNG(width={self.width}, height={self.height}, " + f"bit_depth={self.bit_depth}, color_type={self.color_type}, " + f"interlace={self.interlace}, crc=0x{self.crc:08x})" + ) diff --git a/aperisolve/cleanup.py b/aperisolve/cleanup.py index 130542d..0e59175 100644 --- a/aperisolve/cleanup.py +++ b/aperisolve/cleanup.py @@ -14,7 +14,28 @@ MAX_STORE_TIME = int(getenv("MAX_STORE_TIME", "259200")) # 3 days by default def cleanup_old_entries() -> None: - """Clean up old entries in the database and file system.""" + """ + Clean up old and incomplete entries from the database and file system. + + This function performs two main cleanup operations: + + 1. Submission cleanup: + - Deletes submissions with "pending" or "running" status that have exceeded + the maximum allowed processing time (MAX_PENDING_TIME). + - Deletes completed submissions ("done" status) that have missing or buggy + results (results.json file not found). + + 2. Image cleanup: + - Deletes images older than MAX_STORE_TIME along with all associated + submissions and their result folders. + - Deletes orphaned images (with no submissions) older than MAX_PENDING_TIME + and removes their associated result folders from the file system. + + All database changes are committed at the end of the operation. + + Returns: + None + """ now = time.time() for submission in Submission.query.all(): # type: ignore if ( diff --git a/aperisolve/config.py b/aperisolve/config.py index f3f0197..fe000d3 100644 --- a/aperisolve/config.py +++ b/aperisolve/config.py @@ -6,6 +6,3 @@ IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff"] WORKER_FILES = ["binwalk", "foremost", "steghide", "zsteg", "image_resize"] RESULT_FOLDER = Path(__file__).parent.resolve() / "results" - -MAX_WIDTH = 5 # Used for IHDR lookup table -MAX_HEIGHT = 5 # Used for IHDR lookup table diff --git a/aperisolve/models.py b/aperisolve/models.py index fc73a4f..1f2d897 100644 --- a/aperisolve/models.py +++ b/aperisolve/models.py @@ -1,16 +1,14 @@ -"""This module defines the database models for the Aperisolve application.""" +"""This module defines the database models for the Aperi'Solve application.""" -import zlib import itertools - from datetime import datetime, timezone from decimal import Decimal -from aperisolve.analyzers.utils import pack_ihdr -from aperisolve.config import MAX_HEIGHT, MAX_WIDTH from flask import Flask from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, BigInteger +from sqlalchemy import BigInteger, Boolean, Column, DateTime, Float, Integer, String + +from aperisolve.analyzers.utils import PNG, get_resolutions, get_valid_depth_color_pairs db: SQLAlchemy = SQLAlchemy() @@ -60,68 +58,76 @@ class Submission(db.Model): # type: ignore class IHDR(db.Model): # type: ignore """IHDR CRC lookup table""" - iid: Column[int] = Column( - Integer, primary_key=True, autoincrement=True, unique=True - ) - crc: Column[int] = Column(BigInteger) - packed: Column[int] = Column(Integer) + iid = Column(Integer, primary_key=True, autoincrement=True) + crc = Column(BigInteger, nullable=False) + packed = Column(BigInteger, nullable=False) def create_crc_db() -> None: - w = range(1, MAX_WIDTH + 1) - h = range(1, MAX_HEIGHT + 1) + """ + Creates a database of common PNG IHDR (Image Header) entries by generating + combinations of standard PNG parameters such as resolutions, bit depths, + color types, and interlace methods. The function checks for existing entries + in the database to avoid duplicates and inserts new entries in batches of + 5000, committing the changes to the database periodically. + Returns: + None + """ # Standard PNG IHDR parameters - bit_depth = [1, 2, 4, 8, 16] - color_type = [0, 2, 3, 4, 6] - compression_method = [0] # This is always 0 according to the PNG spec - filter_method = [0] # This is always 0 according to the PNG spec - interlace_method = [0, 1] + resolutions = get_resolutions() + bit_color_pairs = get_valid_depth_color_pairs() + interlace_methods = [0, 1] - # Create the generator - all_combinations = itertools.product( - w, h, bit_depth, color_type, compression_method, filter_method, interlace_method + combinations = itertools.product( + resolutions, + bit_color_pairs, + interlace_methods, ) count = 0 - max_count = ( - MAX_WIDTH - * MAX_HEIGHT - * len(bit_depth) - * len(color_type) - * len(interlace_method) - ) - for w_val, h_val, bd, ct, comp, filt, inter in all_combinations: - count += 1 - # Construct IHDR chunk data - ihdr_data = ( - w_val.to_bytes(4, "big") - + h_val.to_bytes(4, "big") - + bytes([bd, ct, comp, filt, inter]) - ) - # Calculate CRC - crc = zlib.crc32(b"IHDR" + ihdr_data) & 0xFFFFFFFF + with db.session.no_autoflush: # pylint: disable=no-member + for (w, h), (bd, ct), inter in combinations: + p = PNG(width=w, height=h, bit_depth=bd, color_type=ct, interlace=inter) - # Compute packed - packed = pack_ihdr(w_val, h_val, bd, ct, inter) + exists = db.session.execute( # pylint: disable=no-member + db.select(IHDR.iid).where(IHDR.packed == p.packed) + ).first() - # Create and add IHDR entry to database - ihdr_entry = IHDR(crc=crc, packed=packed) - db.session.add(ihdr_entry) + if exists: # Skip if already present + continue - if count % 10000 == 0: - print( - f"Inserted {count}/{max_count} ({int((count*100)/max_count)}%) IHDR entries..." - ) - db.session.commit() + db.session.add(IHDR(packed=p.packed, crc=p.crc)) # pylint: disable=no-member + count += 1 - # Commit all entries - db.session.commit() + if count % 5000 == 0: + print(f"Inserted {count} common IHDR entries...") + db.session.commit() + + db.session.commit() # pylint: disable=no-member + print(f"Precomputed {count} common IHDR entries.") def init_db(app: Flask) -> None: - """Initialize the database with the given Flask app.""" + """ + Initialize the database by creating tables and populating lookup data. + + This function creates the database schema if it doesn't already exist, + and populates the CRC lookup table for PNG IHDR validation. + + Args: + app (Flask): The Flask application instance used to establish the application context + for database operations. + + Returns: + None + + Side Effects: + - Creates all database tables if the "image" table doesn't exist + - Populates the IHDR CRC lookup table with initial data + - Prints status messages to console during initialization + """ with app.app_context(): if not db.engine.dialect.has_table(db.engine.connect(), "image"): print("Creating database...") diff --git a/aperisolve/workers.py b/aperisolve/workers.py index 956c328..acb6b8e 100644 --- a/aperisolve/workers.py +++ b/aperisolve/workers.py @@ -8,19 +8,43 @@ from .analyzers.binwalk import analyze_binwalk from .analyzers.decomposer import analyze_decomposer from .analyzers.exiftool import analyze_exiftool from .analyzers.foremost import analyze_foremost +from .analyzers.image_resize import analyze_image_resize from .analyzers.outguess import analyze_outguess +from .analyzers.pngcheck import analyze_pngcheck from .analyzers.steghide import analyze_steghide from .analyzers.strings import analyze_strings from .analyzers.zsteg import analyze_zsteg -from .analyzers.pngcheck import analyze_pngcheck -from .analyzers.image_resize import analyze_image_resize from .app import app, db from .config import RESULT_FOLDER from .models import Image, Submission def analyze_image(submission_hash: str) -> None: - """Analyze an image submission in separate threads.""" + """ + Analyze an image submission by running multiple analysis tools concurrently. + + This function retrieves a submission and its associated image from the database, + then executes various image analysis tools in parallel threads. The analysis tools + include binwalk, decomposer, exiftool, foremost, strings, pngcheck, steghide, + zsteg, and image resize. If deep analysis is enabled, additional tools like + outguess are included. + + Args: + submission_hash (str): The unique hash identifier of the submission to analyze. + + Returns: + None + + Raises: + Implicitly catches and handles exceptions during analysis, setting submission + status to "error" if an exception occurs. + + Side Effects: + - Queries the database for the submission and associated image + - Updates submission status in the database ("running" -> "completed" or "error") + - Creates result directories and generates analysis output files + - Modifies the database session and commits changes + """ with app.app_context(): submission: Submission = Submission.query.get(submission_hash) # type: ignore image = Image.query.get_or_404(submission.image_hash) # type: ignore @@ -29,7 +53,7 @@ def analyze_image(submission_hash: str) -> None: return submission.status = "running" # type: ignore - db.session.commit() + db.session.commit() # pylint: disable=no-member try: img_path: Path = Path(image.file) @@ -77,4 +101,4 @@ def analyze_image(submission_hash: str) -> None: except Exception: submission.status = "error" finally: - db.session.commit() + db.session.commit() # pylint: disable=no-member From 9fb4cd70b598c17f73f8684c253245191e50cb33 Mon Sep 17 00:00:00 2001 From: Zeecka Date: Fri, 26 Dec 2025 22:58:00 +0100 Subject: [PATCH 14/14] Lint, typo --- .env | 2 +- .gitignore | 1 - aperisolve/analyzers/foremost.py | 2 +- aperisolve/analyzers/outguess.py | 2 +- aperisolve/analyzers/pngcheck.py | 8 ++--- aperisolve/analyzers/steghide.py | 2 +- aperisolve/analyzers/utils.py | 54 +++++++++++++++++++++++++------- aperisolve/app.py | 14 ++++----- aperisolve/cleanup.py | 12 +++---- aperisolve/config.py | 2 +- aperisolve/models.py | 31 +++++++++--------- 11 files changed, 80 insertions(+), 50 deletions(-) diff --git a/.env b/.env index bdea06b..7d7e694 100644 --- a/.env +++ b/.env @@ -7,4 +7,4 @@ DB_URI=postgresql://aperiuser:aperipass@postgres:5432/aperisolve RQ_DASHBOARD_REDIS_URL=redis://redis:6379/0 POSTGRES_DB=aperisolve POSTGRES_USER=aperiuser -POSTGRES_PASSWORD=aperipass \ No newline at end of file +POSTGRES_PASSWORD=aperipass diff --git a/.gitignore b/.gitignore index 12546d8..646de31 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ __pycache__/ .mypy_cache/ results/ -ihdr_crcs.db \ No newline at end of file diff --git a/aperisolve/analyzers/foremost.py b/aperisolve/analyzers/foremost.py index 7e82452..5c55972 100644 --- a/aperisolve/analyzers/foremost.py +++ b/aperisolve/analyzers/foremost.py @@ -24,7 +24,7 @@ def analyze_foremost(input_img: Path, output_dir: Path) -> None: timeout=MAX_PENDING_TIME, ) # Note: foremost use stderr as standard output for results :o) - # So we can't use it as error dectection : + # So we can't use it as error detection : # stderr += data.stderr # Zip extracted files diff --git a/aperisolve/analyzers/outguess.py b/aperisolve/analyzers/outguess.py index 1d6587e..976979b 100644 --- a/aperisolve/analyzers/outguess.py +++ b/aperisolve/analyzers/outguess.py @@ -11,7 +11,7 @@ from .utils import MAX_PENDING_TIME, update_data def analyze_outguess( input_img: Path, output_dir: Path, password: Optional[str] = None ) -> None: - """Analyze an image submission using stehide.""" + """Analyze an image submission using outguess.""" image_name = input_img.name try: diff --git a/aperisolve/analyzers/pngcheck.py b/aperisolve/analyzers/pngcheck.py index fa32faf..5c9a945 100644 --- a/aperisolve/analyzers/pngcheck.py +++ b/aperisolve/analyzers/pngcheck.py @@ -7,7 +7,7 @@ from .utils import MAX_PENDING_TIME, update_data def analyze_pngcheck(input_img: Path, output_dir: Path) -> None: - """Analyze an image submission using strings.""" + """Analyze an image submission using pngcheck.""" try: data = subprocess.run( @@ -24,15 +24,15 @@ def analyze_pngcheck(input_img: Path, output_dir: Path) -> None: # ) # return - data_strings = data.stdout.split("\n") if data else [] - data_strings = [s for s in data_strings if s] + data_pngcheck = data.stdout.split("\n") if data else [] + data_pngcheck = [s for s in data_pngcheck if s] update_data( output_dir, { "pngcheck": { "status": "ok", - "output": data_strings, + "output": data_pngcheck, } }, ) diff --git a/aperisolve/analyzers/steghide.py b/aperisolve/analyzers/steghide.py index 8a12ce9..d74d077 100644 --- a/aperisolve/analyzers/steghide.py +++ b/aperisolve/analyzers/steghide.py @@ -12,7 +12,7 @@ from .utils import MAX_PENDING_TIME, update_data def analyze_steghide( input_img: Path, output_dir: Path, password: Optional[str] = None ) -> None: - """Analyze an image submission using stehide.""" + """Analyze an image submission using steghide.""" image_name = input_img.name try: diff --git a/aperisolve/analyzers/utils.py b/aperisolve/analyzers/utils.py index 13d7196..df7da95 100644 --- a/aperisolve/analyzers/utils.py +++ b/aperisolve/analyzers/utils.py @@ -15,9 +15,6 @@ _thread_lock = threading.Lock() MAX_PENDING_TIME = int(os.getenv("MAX_PENDING_TIME", "600")) # 10 minutes by default -# PATH CONFIGURATION -# Prioritize /data for Docker persistence, fallback to local directory for bare metal - def update_data( output_dir: Path, new_data: dict[Any, Any], json_filename: str = "results.json" @@ -56,7 +53,31 @@ def update_data( def get_resolutions(): - BASE_WIDTHS = ( + """ + Generate a sorted list of common display and image resolutions. + + Creates a comprehensive set of resolution tuples by combining various base widths + with standard aspect ratios used in screens, digital displays, photography, and + paper standards. Includes both portrait and landscape orientations. + + Base widths range from 16px to 10000px, covering: + - Low resolution: 16-256px (16px increments) + - Standard: 320-1024px (32px increments) + - HD/Full HD: 1280-2560px (64px increments) + - 4K: 3000-4096px (128px increments) + - 5K-8K: 5120-8192px (256px increments) + - Upper bound: 10000px + + Aspect ratios include common formats: + - Digital screens: 1:1, 4:3, 3:2, 16:10, 16:9, 21:9 + - Photography/print: 5:4, 7:5, 2:3 + - Paper standards: ISO A-series, US Letter, Legal, Tabloid + + Returns: + list[tuple[int, int]]: Sorted list of (width, height) resolution tuples + with heights constrained between 1 and 10000 pixels. + """ + base_widths = ( list(range(16, 257, 16)) # 16 → 256 + list(range(320, 1025, 32)) # 320 → 1024 + list(range(1280, 2561, 64)) # 1280 → 2560 @@ -64,7 +85,7 @@ def get_resolutions(): + list(range(5120, 8193, 256)) # 5K–8K + [10000] # upper bound ) - ASPECT_RATIOS = [ + aspect_ratios = [ # Screens / digital (1, 1), # square (4, 3), @@ -84,8 +105,8 @@ def get_resolutions(): (17, 11), # Tabloid ] resolutions = set() - for w in BASE_WIDTHS: - for ar_w, ar_h in ASPECT_RATIOS: + for w in base_widths: + for ar_w, ar_h in aspect_ratios: h = math.floor(round(w * ar_h / ar_w)) if 1 <= h <= 10000: resolutions.add((w, h)) @@ -94,14 +115,25 @@ def get_resolutions(): def get_valid_depth_color_pairs(): - VALID_COLOR_DEPTHS = { + """ + Generate valid combinations of color and bit depth values. + + Yields tuples of (depth, color) representing valid color mode and bit depth + combinations. Supports grayscale (0), RGB (2, 3, 4, 6) color modes with + appropriate bit depths (1, 2, 4, 8, 16 bits). + + Yields: + tuple: A tuple of (depth, color) where depth is an integer representing + bit depth and color is an integer representing the color mode. + """ + valid_color_depth = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16], } - for color, depths in VALID_COLOR_DEPTHS.items(): + for color, depths in valid_color_depth.items(): for depth in depths: yield depth, color @@ -234,8 +266,8 @@ class PNG: IndexError: If the bytearray is shorter than expected for the PNG structure. """ - PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" - if data[:8] != PNG_SIGNATURE: + png_signature = b"\x89PNG\r\n\x1a\n" + if data[:8] != png_signature: raise ValueError("Invalid PNG signature") offset = 8 # Read IHDR chunk length = struct.unpack(">I", data[offset : offset + 4])[0] diff --git a/aperisolve/app.py b/aperisolve/app.py index 5e92077..e81de53 100644 --- a/aperisolve/app.py +++ b/aperisolve/app.py @@ -1,4 +1,4 @@ -"""AperiSolve Flask application.""" +"""Aperi'Solve Flask application.""" import hashlib import json @@ -41,7 +41,7 @@ def too_large(_: Any) -> tuple[Response, int]: @app.errorhandler(404) def not_found(_: Any) -> str: """Error Handler for 404 not found.""" - return render_template("error.html", message="Ressource not found", statuscode=404) + return render_template("error.html", message="Resource not found", statuscode=404) @app.route("/") @@ -134,12 +134,12 @@ def upload_image() -> tuple[Response, int]: first_submission_date=datetime.now(timezone.utc), last_submission_date=datetime.now(timezone.utc), ) - db.session.add(new_img) - db.session.commit() + db.session.add(new_img) # pylint: disable=no-member + db.session.commit() # pylint: disable=no-member sub_img = Image.query.filter_by(hash=img_hash).first() # type: ignore sub_img.upload_count += 1 - db.session.commit() + db.session.commit() # pylint: disable=no-member # Create new Submission entry submission_path.mkdir(parents=True, exist_ok=True) @@ -152,9 +152,9 @@ def upload_image() -> tuple[Response, int]: date=time.time(), image_hash=sub_img.hash, # type: ignore ) - db.session.add(submission) + db.session.add(submission) # pylint: disable=no-member - db.session.commit() # Commit to save the new Image and Submission + db.session.commit() # pylint: disable=no-member # Start the analysis jobs queue.enqueue("aperisolve.workers.analyze_image", submission.hash, job_timeout=300) diff --git a/aperisolve/cleanup.py b/aperisolve/cleanup.py index 0e59175..402cd25 100644 --- a/aperisolve/cleanup.py +++ b/aperisolve/cleanup.py @@ -43,7 +43,7 @@ def cleanup_old_entries() -> None: and now - submission.date > MAX_PENDING_TIME # type: ignore ): # Processing took too long, delete - db.session.delete(submission) # type: ignore + db.session.delete(submission) # pylint: disable=no-member elif submission.status == "done": # type: ignore # Search for buggy results, delete result_path = ( @@ -51,7 +51,7 @@ def cleanup_old_entries() -> None: ) result_file = result_path / "results.json" if not result_file.exists(): - db.session.delete(submission) # type: ignore + db.session.delete(submission) # pylint: disable=no-member shutil.rmtree(result_path) # Delete "old" images @@ -64,15 +64,15 @@ def cleanup_old_entries() -> None: img_fold = RESULT_FOLDER / img.hash if delay.total_seconds() > MAX_STORE_TIME: for s in img.submissions: - db.session.delete(s) # type: ignore + db.session.delete(s) # pylint: disable=no-member if img_fold.exists(): shutil.rmtree(img_fold) # type: ignore - db.session.delete(img) + db.session.delete(img) # pylint: disable=no-member # Delete Images with missing submission if len(img.submissions) == 0 and delay.total_seconds() > MAX_PENDING_TIME: if img_fold.exists(): shutil.rmtree(img_fold) # type: ignore - db.session.delete(img) + db.session.delete(img) # pylint: disable=no-member - db.session.commit() + db.session.commit() # pylint: disable=no-member diff --git a/aperisolve/config.py b/aperisolve/config.py index fe000d3..117e852 100644 --- a/aperisolve/config.py +++ b/aperisolve/config.py @@ -1,4 +1,4 @@ -"""AperiSolve configuration variables.""" +"""Aperi'Solve configuration variables.""" from pathlib import Path diff --git a/aperisolve/models.py b/aperisolve/models.py index 1f2d897..d0f3d7e 100644 --- a/aperisolve/models.py +++ b/aperisolve/models.py @@ -2,7 +2,6 @@ import itertools from datetime import datetime, timezone -from decimal import Decimal from flask import Flask from flask_sqlalchemy import SQLAlchemy @@ -16,21 +15,21 @@ db: SQLAlchemy = SQLAlchemy() class Image(db.Model): # type: ignore """Model representing an image file in the database.""" - hash: Column[str] = Column( + hash: Column[String] = Column( String(64), primary_key=True, unique=True, nullable=False ) - file: Column[str] = Column(String(128), unique=True, nullable=False) - size: Column[int] = Column(Integer, nullable=False) - first_submission_date: Column[datetime] = Column( + file: Column[String] = Column(String(128), unique=True, nullable=False) + size: Column[Integer] = Column(Integer, nullable=False) + first_submission_date: Column[DateTime] = Column( DateTime, nullable=False, default=lambda: datetime.now(timezone.utc) ) - last_submission_date: Column[datetime] = Column( + last_submission_date: Column[DateTime] = Column( DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) - upload_count: Column[int] = Column(Integer) + upload_count: Column[Integer] = Column(Integer) submissions = db.relationship("Submission", backref="image", lazy=True) @@ -40,14 +39,14 @@ class Submission(db.Model): # type: ignore option. """ - hash: Column[str] = Column( + hash: Column[String] = Column( String(128), primary_key=True, unique=True, nullable=False ) - filename: Column[str] = Column(String(128), nullable=False) - password: Column[str] = Column(String(128)) - deep_analysis: Column[bool] = Column(Boolean, default=False) - status: Column[str] = Column(String(20), default="pending") - date: Column[Decimal] = Column( + filename: Column[String] = Column(String(128), nullable=False) + password: Column[String] = Column(String(128)) + deep_analysis: Column[Boolean] = Column(Boolean, default=False) + status: Column[String] = Column(String(20), default="pending") + date: Column[Float] = Column( Float, nullable=False, default=lambda: datetime.now(timezone.utc) ) @@ -58,9 +57,9 @@ class Submission(db.Model): # type: ignore class IHDR(db.Model): # type: ignore """IHDR CRC lookup table""" - iid = Column(Integer, primary_key=True, autoincrement=True) - crc = Column(BigInteger, nullable=False) - packed = Column(BigInteger, nullable=False) + iid: Column[Integer] = Column(Integer, primary_key=True, autoincrement=True) + crc: Column[BigInteger] = Column(BigInteger, nullable=False) + packed: Column[BigInteger] = Column(BigInteger, nullable=False) def create_crc_db() -> None: