Files
stegasoo/src/stegasoo/utils.py
Aaron D. Lee afa88bc73b Apply black formatter to all Python files
Reformatted 29 files for consistent code style and CI compliance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 17:44:41 -05:00

373 lines
10 KiB
Python

"""
Stegasoo Utilities
Secure deletion, filename generation, and other helpers.
"""
import io
import os
import random
import secrets
import shutil
from datetime import date
from pathlib import Path
from PIL import Image
from .constants import DAY_NAMES
from .debug import debug
def strip_image_metadata(image_data: bytes, output_format: str = "PNG") -> bytes:
"""
Remove all metadata (EXIF, ICC profiles, etc.) from an image.
Creates a fresh image with only pixel data - no EXIF, GPS coordinates,
camera info, timestamps, or other potentially sensitive metadata.
Args:
image_data: Raw image bytes
output_format: Output format ('PNG', 'BMP', 'TIFF')
Returns:
Clean image bytes with no metadata
Example:
>>> clean = strip_image_metadata(photo_bytes)
>>> # EXIF data is now removed
"""
debug.print(f"Stripping metadata, output format: {output_format}")
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if needed (handles RGBA, P, L, etc.)
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
# Create fresh image - this discards all metadata
clean = Image.new(img.mode, img.size)
clean.putdata(list(img.getdata()))
output = io.BytesIO()
clean.save(output, output_format.upper())
output.seek(0)
debug.print(f"Metadata stripped: {len(image_data)} -> {len(output.getvalue())} bytes")
return output.getvalue()
def generate_filename(date_str: str | None = None, prefix: str = "", extension: str = "png") -> str:
"""
Generate a filename for stego images.
Format: {prefix}{random}_{YYYYMMDD}.{extension}
Args:
date_str: Date string (YYYY-MM-DD), defaults to today
prefix: Optional prefix
extension: File extension without dot (default: 'png')
Returns:
Filename string
Example:
>>> generate_filename("2023-12-25", "secret_", "png")
"secret_a1b2c3d4_20231225.png"
"""
debug.validate(
bool(extension) and "." not in extension,
f"Extension must not contain dot, got '{extension}'",
)
if date_str is None:
date_str = date.today().isoformat()
date_compact = date_str.replace("-", "")
random_hex = secrets.token_hex(4)
# Ensure extension doesn't have a leading dot
extension = extension.lstrip(".")
filename = f"{prefix}{random_hex}_{date_compact}.{extension}"
debug.print(f"Generated filename: {filename}")
return filename
def parse_date_from_filename(filename: str) -> str | None:
"""
Extract date from a stego filename.
Looks for patterns like _20251227 or _2025-12-27
Args:
filename: Filename to parse
Returns:
Date string (YYYY-MM-DD) or None
Example:
>>> parse_date_from_filename("secret_a1b2c3d4_20231225.png")
"2023-12-25"
"""
import re
# Try YYYYMMDD format
match = re.search(r"_(\d{4})(\d{2})(\d{2})(?:\.|$)", filename)
if match:
year, month, day = match.groups()
date_str = f"{year}-{month}-{day}"
debug.print(f"Parsed date (compact): {date_str}")
return date_str
# Try YYYY-MM-DD format
match = re.search(r"_(\d{4})-(\d{2})-(\d{2})(?:\.|$)", filename)
if match:
year, month, day = match.groups()
date_str = f"{year}-{month}-{day}"
debug.print(f"Parsed date (dashed): {date_str}")
return date_str
debug.print(f"No date found in filename: {filename}")
return None
def get_day_from_date(date_str: str) -> str:
"""
Get day of week name from date string.
Args:
date_str: Date string (YYYY-MM-DD)
Returns:
Day name (e.g., "Monday")
Example:
>>> get_day_from_date("2023-12-25")
"Monday"
"""
debug.validate(
len(date_str) == 10 and date_str[4] == "-" and date_str[7] == "-",
f"Invalid date format: {date_str}, expected YYYY-MM-DD",
)
try:
year, month, day = map(int, date_str.split("-"))
d = date(year, month, day)
day_name = DAY_NAMES[d.weekday()]
debug.print(f"Date {date_str} is {day_name}")
return day_name
except Exception as e:
debug.exception(e, f"get_day_from_date for {date_str}")
return ""
def get_today_date() -> str:
"""
Get today's date as YYYY-MM-DD.
Returns:
Today's date string
Example:
>>> get_today_date()
"2023-12-25"
"""
today = date.today().isoformat()
debug.print(f"Today's date: {today}")
return today
def get_today_day() -> str:
"""
Get today's day name.
Returns:
Today's day name
Example:
>>> get_today_day()
"Monday"
"""
today_day = DAY_NAMES[date.today().weekday()]
debug.print(f"Today is {today_day}")
return today_day
class SecureDeleter:
"""
Securely delete files by overwriting with random data.
Implements multi-pass overwriting before deletion.
Example:
>>> deleter = SecureDeleter("secret.txt", passes=3)
>>> deleter.execute()
"""
def __init__(self, path: str | Path, passes: int = 7):
"""
Initialize secure deleter.
Args:
path: Path to file or directory
passes: Number of overwrite passes
"""
debug.validate(passes > 0, f"Passes must be positive, got {passes}")
self.path = Path(path)
self.passes = passes
debug.print(f"SecureDeleter initialized for {self.path} with {passes} passes")
def _overwrite_file(self, file_path: Path) -> None:
"""Overwrite file with random data multiple times."""
if not file_path.exists() or not file_path.is_file():
debug.print(f"File does not exist or is not a file: {file_path}")
return
length = file_path.stat().st_size
debug.print(f"Overwriting file {file_path} ({length} bytes)")
if length == 0:
debug.print("File is empty, nothing to overwrite")
return
patterns = [b"\x00", b"\xff", bytes([random.randint(0, 255)])]
for pass_num in range(self.passes):
debug.print(f"Overwrite pass {pass_num + 1}/{self.passes}")
with open(file_path, "r+b") as f:
for pattern_idx, pattern in enumerate(patterns):
f.seek(0)
# Write pattern in chunks for large files
chunk_size = 1024 * 1024 # 1MB chunks
for offset in range(0, length, chunk_size):
chunk = min(chunk_size, length - offset)
f.write(pattern * (chunk // len(pattern)))
f.write(pattern[: chunk % len(pattern)])
# Final pass with random data
f.seek(0)
f.write(os.urandom(length))
debug.print(f"Completed {self.passes} overwrite passes")
def delete_file(self) -> None:
"""Securely delete a single file."""
if self.path.is_file():
debug.print(f"Securely deleting file: {self.path}")
self._overwrite_file(self.path)
self.path.unlink()
debug.print(f"File deleted: {self.path}")
else:
debug.print(f"Not a file: {self.path}")
def delete_directory(self) -> None:
"""Securely delete a directory and all contents."""
if not self.path.is_dir():
debug.print(f"Not a directory: {self.path}")
return
debug.print(f"Securely deleting directory: {self.path}")
# First, securely overwrite all files
file_count = 0
for file_path in self.path.rglob("*"):
if file_path.is_file():
self._overwrite_file(file_path)
file_count += 1
debug.print(f"Overwrote {file_count} files")
# Then remove the directory tree
shutil.rmtree(self.path)
debug.print(f"Directory deleted: {self.path}")
def execute(self) -> None:
"""Securely delete the path (file or directory)."""
debug.print(f"Executing secure deletion: {self.path}")
if self.path.is_file():
self.delete_file()
elif self.path.is_dir():
self.delete_directory()
else:
debug.print(f"Path does not exist: {self.path}")
def secure_delete(path: str | Path, passes: int = 7) -> None:
"""
Convenience function for secure deletion.
Args:
path: Path to file or directory
passes: Number of overwrite passes
Example:
>>> secure_delete("secret.txt", passes=3)
"""
debug.print(f"secure_delete called: {path}, passes={passes}")
SecureDeleter(path, passes).execute()
def format_file_size(size_bytes: int) -> str:
"""
Format file size for display.
Args:
size_bytes: Size in bytes
Returns:
Human-readable string (e.g., "1.5 MB")
Example:
>>> format_file_size(1500000)
"1.5 MB"
"""
debug.validate(size_bytes >= 0, f"File size cannot be negative: {size_bytes}")
size: float = float(size_bytes)
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024:
if unit == "B":
return f"{int(size)} {unit}"
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
def format_number(n: int) -> str:
"""
Format number with commas.
Args:
n: Integer to format
Returns:
Formatted string
Example:
>>> format_number(1234567)
"1,234,567"
"""
debug.validate(isinstance(n, int), f"Input must be integer, got {type(n)}")
return f"{n:,}"
def clamp(value: int, min_val: int, max_val: int) -> int:
"""
Clamp value to range.
Args:
value: Value to clamp
min_val: Minimum allowed value
max_val: Maximum allowed value
Returns:
Clamped value
Example:
>>> clamp(15, 0, 10)
10
"""
debug.validate(min_val <= max_val, f"min_val ({min_val}) must be <= max_val ({max_val})")
return max(min_val, min(max_val, value))