More CI/CD fixes and stuff (automation goodness).
This commit is contained in:
@@ -155,6 +155,30 @@ from .utils import (
|
||||
)
|
||||
from .debug import debug # Import debug utilities
|
||||
|
||||
# =============================================================================
|
||||
# NEW IN v2.2.0 - Compression
|
||||
# =============================================================================
|
||||
from .compression import (
|
||||
compress,
|
||||
decompress,
|
||||
CompressionAlgorithm,
|
||||
CompressionError,
|
||||
get_compression_ratio,
|
||||
estimate_compressed_size,
|
||||
get_available_algorithms,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# NEW IN v2.2.0 - Batch Processing
|
||||
# =============================================================================
|
||||
from .batch import (
|
||||
BatchProcessor,
|
||||
BatchResult,
|
||||
BatchItem,
|
||||
BatchStatus,
|
||||
batch_capacity_check,
|
||||
)
|
||||
|
||||
# QR Code utilities (optional, depends on qrcode and pyzbar)
|
||||
try:
|
||||
from .qr_utils import (
|
||||
@@ -509,7 +533,8 @@ def decode_text(
|
||||
return ""
|
||||
|
||||
debug.print(f"Decoded text: {result.message[:100] if result.message else 'empty'}...")
|
||||
return result.message or ""
|
||||
message: str = result.message if result.message is not None else ""
|
||||
return message
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -618,4 +643,20 @@ __all__ = [
|
||||
|
||||
# Debugging
|
||||
'debug',
|
||||
|
||||
# Compression (v2.2.0)
|
||||
'compress',
|
||||
'decompress',
|
||||
'CompressionAlgorithm',
|
||||
'CompressionError',
|
||||
'get_compression_ratio',
|
||||
'estimate_compressed_size',
|
||||
'get_available_algorithms',
|
||||
|
||||
# Batch processing (v2.2.0)
|
||||
'BatchProcessor',
|
||||
'BatchResult',
|
||||
'BatchItem',
|
||||
'BatchStatus',
|
||||
'batch_capacity_check',
|
||||
]
|
||||
|
||||
483
src/stegasoo/batch.py
Normal file
483
src/stegasoo/batch.py
Normal file
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Stegasoo Batch Processing Module
|
||||
|
||||
Enables encoding/decoding multiple files in a single operation.
|
||||
Supports parallel processing, progress tracking, and detailed reporting.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Optional, Callable, Iterator
|
||||
from enum import Enum
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import threading
|
||||
|
||||
from .constants import ALLOWED_IMAGE_EXTENSIONS, LOSSLESS_FORMATS
|
||||
|
||||
|
||||
class BatchStatus(Enum):
|
||||
"""Status of individual batch items."""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchItem:
|
||||
"""Represents a single item in a batch operation."""
|
||||
input_path: Path
|
||||
output_path: Optional[Path] = None
|
||||
status: BatchStatus = BatchStatus.PENDING
|
||||
error: Optional[str] = None
|
||||
start_time: Optional[float] = None
|
||||
end_time: Optional[float] = None
|
||||
input_size: int = 0
|
||||
output_size: int = 0
|
||||
message: str = ""
|
||||
|
||||
@property
|
||||
def duration(self) -> Optional[float]:
|
||||
"""Processing duration in seconds."""
|
||||
if self.start_time and self.end_time:
|
||||
return self.end_time - self.start_time
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"input_path": str(self.input_path),
|
||||
"output_path": str(self.output_path) if self.output_path else None,
|
||||
"status": self.status.value,
|
||||
"error": self.error,
|
||||
"duration_seconds": self.duration,
|
||||
"input_size": self.input_size,
|
||||
"output_size": self.output_size,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchResult:
|
||||
"""Summary of a batch operation."""
|
||||
operation: str
|
||||
total: int = 0
|
||||
succeeded: int = 0
|
||||
failed: int = 0
|
||||
skipped: int = 0
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
items: list[BatchItem] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def duration(self) -> Optional[float]:
|
||||
"""Total batch duration in seconds."""
|
||||
if self.end_time:
|
||||
return self.end_time - self.start_time
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"operation": self.operation,
|
||||
"summary": {
|
||||
"total": self.total,
|
||||
"succeeded": self.succeeded,
|
||||
"failed": self.failed,
|
||||
"skipped": self.skipped,
|
||||
"duration_seconds": self.duration,
|
||||
},
|
||||
"items": [item.to_dict() for item in self.items],
|
||||
}
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
"""Serialize to JSON string."""
|
||||
return json.dumps(self.to_dict(), indent=indent)
|
||||
|
||||
|
||||
# Type alias for progress callback
|
||||
ProgressCallback = Callable[[int, int, BatchItem], None]
|
||||
|
||||
|
||||
class BatchProcessor:
|
||||
"""
|
||||
Handles batch encoding/decoding operations.
|
||||
|
||||
Usage:
|
||||
processor = BatchProcessor(max_workers=4)
|
||||
|
||||
# Batch encode
|
||||
result = processor.batch_encode(
|
||||
images=['img1.png', 'img2.png'],
|
||||
message="Secret message",
|
||||
output_dir="./encoded/",
|
||||
credentials={"phrase": "...", "pin": "..."},
|
||||
)
|
||||
|
||||
# Batch decode
|
||||
result = processor.batch_decode(
|
||||
images=['encoded1.png', 'encoded2.png'],
|
||||
credentials={"phrase": "...", "pin": "..."},
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers: int = 4):
|
||||
"""
|
||||
Initialize batch processor.
|
||||
|
||||
Args:
|
||||
max_workers: Maximum parallel workers (default 4)
|
||||
"""
|
||||
self.max_workers = max_workers
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def find_images(
|
||||
self,
|
||||
paths: list[str | Path],
|
||||
recursive: bool = False,
|
||||
) -> Iterator[Path]:
|
||||
"""
|
||||
Find all valid image files from paths.
|
||||
|
||||
Args:
|
||||
paths: List of files or directories
|
||||
recursive: Search directories recursively
|
||||
|
||||
Yields:
|
||||
Path objects for each valid image
|
||||
"""
|
||||
for path in paths:
|
||||
path = Path(path)
|
||||
|
||||
if path.is_file():
|
||||
if self._is_valid_image(path):
|
||||
yield path
|
||||
|
||||
elif path.is_dir():
|
||||
pattern = '**/*' if recursive else '*'
|
||||
for file_path in path.glob(pattern):
|
||||
if file_path.is_file() and self._is_valid_image(file_path):
|
||||
yield file_path
|
||||
|
||||
def _is_valid_image(self, path: Path) -> bool:
|
||||
"""Check if path is a valid image file."""
|
||||
return path.suffix.lower().lstrip('.') in ALLOWED_IMAGE_EXTENSIONS
|
||||
|
||||
def batch_encode(
|
||||
self,
|
||||
images: list[str | Path],
|
||||
message: Optional[str] = None,
|
||||
file_payload: Optional[Path] = None,
|
||||
output_dir: Optional[Path] = None,
|
||||
output_suffix: str = "_encoded",
|
||||
credentials: dict = None,
|
||||
compress: bool = True,
|
||||
recursive: bool = False,
|
||||
progress_callback: Optional[ProgressCallback] = None,
|
||||
encode_func: Callable = None,
|
||||
) -> BatchResult:
|
||||
"""
|
||||
Encode message into multiple images.
|
||||
|
||||
Args:
|
||||
images: List of image paths or directories
|
||||
message: Text message to encode (mutually exclusive with file_payload)
|
||||
file_payload: File to embed (mutually exclusive with message)
|
||||
output_dir: Output directory (default: same as input)
|
||||
output_suffix: Suffix for output files
|
||||
credentials: Dict with 'phrase', 'pin', and optionally 'private_key'
|
||||
compress: Enable compression
|
||||
recursive: Search directories recursively
|
||||
progress_callback: Called for each item: callback(current, total, item)
|
||||
encode_func: Custom encode function (for integration)
|
||||
|
||||
Returns:
|
||||
BatchResult with operation summary
|
||||
"""
|
||||
if message is None and file_payload is None:
|
||||
raise ValueError("Either message or file_payload must be provided")
|
||||
|
||||
if credentials is None:
|
||||
raise ValueError("Credentials are required")
|
||||
|
||||
result = BatchResult(operation="encode")
|
||||
image_paths = list(self.find_images(images, recursive))
|
||||
result.total = len(image_paths)
|
||||
|
||||
if output_dir:
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Prepare batch items
|
||||
for img_path in image_paths:
|
||||
if output_dir:
|
||||
out_path = output_dir / f"{img_path.stem}{output_suffix}.png"
|
||||
else:
|
||||
out_path = img_path.parent / f"{img_path.stem}{output_suffix}.png"
|
||||
|
||||
item = BatchItem(
|
||||
input_path=img_path,
|
||||
output_path=out_path,
|
||||
input_size=img_path.stat().st_size if img_path.exists() else 0,
|
||||
)
|
||||
result.items.append(item)
|
||||
|
||||
# Process items
|
||||
def process_encode(item: BatchItem) -> BatchItem:
|
||||
item.status = BatchStatus.PROCESSING
|
||||
item.start_time = time.time()
|
||||
|
||||
try:
|
||||
if encode_func:
|
||||
# Use provided encode function
|
||||
encode_func(
|
||||
image_path=item.input_path,
|
||||
output_path=item.output_path,
|
||||
message=message,
|
||||
file_payload=file_payload,
|
||||
credentials=credentials,
|
||||
compress=compress,
|
||||
)
|
||||
else:
|
||||
# Placeholder - actual implementation would call stego.encode()
|
||||
self._mock_encode(item, message, credentials, compress)
|
||||
|
||||
item.status = BatchStatus.SUCCESS
|
||||
item.output_size = item.output_path.stat().st_size if item.output_path.exists() else 0
|
||||
item.message = f"Encoded to {item.output_path.name}"
|
||||
|
||||
except Exception as e:
|
||||
item.status = BatchStatus.FAILED
|
||||
item.error = str(e)
|
||||
|
||||
item.end_time = time.time()
|
||||
return item
|
||||
|
||||
# Execute with thread pool
|
||||
self._execute_batch(result, process_encode, progress_callback)
|
||||
|
||||
return result
|
||||
|
||||
def batch_decode(
|
||||
self,
|
||||
images: list[str | Path],
|
||||
output_dir: Optional[Path] = None,
|
||||
credentials: dict = None,
|
||||
recursive: bool = False,
|
||||
progress_callback: Optional[ProgressCallback] = None,
|
||||
decode_func: Callable = None,
|
||||
) -> BatchResult:
|
||||
"""
|
||||
Decode messages from multiple images.
|
||||
|
||||
Args:
|
||||
images: List of image paths or directories
|
||||
output_dir: Output directory for file payloads (default: same as input)
|
||||
credentials: Dict with 'phrase', 'pin', and optionally 'private_key'
|
||||
recursive: Search directories recursively
|
||||
progress_callback: Called for each item: callback(current, total, item)
|
||||
decode_func: Custom decode function (for integration)
|
||||
|
||||
Returns:
|
||||
BatchResult with decoded messages in item.message fields
|
||||
"""
|
||||
if credentials is None:
|
||||
raise ValueError("Credentials are required")
|
||||
|
||||
result = BatchResult(operation="decode")
|
||||
image_paths = list(self.find_images(images, recursive))
|
||||
result.total = len(image_paths)
|
||||
|
||||
if output_dir:
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Prepare batch items
|
||||
for img_path in image_paths:
|
||||
item = BatchItem(
|
||||
input_path=img_path,
|
||||
output_path=output_dir,
|
||||
input_size=img_path.stat().st_size if img_path.exists() else 0,
|
||||
)
|
||||
result.items.append(item)
|
||||
|
||||
# Process items
|
||||
def process_decode(item: BatchItem) -> BatchItem:
|
||||
item.status = BatchStatus.PROCESSING
|
||||
item.start_time = time.time()
|
||||
|
||||
try:
|
||||
if decode_func:
|
||||
# Use provided decode function
|
||||
decoded = decode_func(
|
||||
image_path=item.input_path,
|
||||
output_dir=item.output_path,
|
||||
credentials=credentials,
|
||||
)
|
||||
item.message = decoded.get('message', '') if isinstance(decoded, dict) else str(decoded)
|
||||
else:
|
||||
# Placeholder - actual implementation would call stego.decode()
|
||||
item.message = self._mock_decode(item, credentials)
|
||||
|
||||
item.status = BatchStatus.SUCCESS
|
||||
|
||||
except Exception as e:
|
||||
item.status = BatchStatus.FAILED
|
||||
item.error = str(e)
|
||||
|
||||
item.end_time = time.time()
|
||||
return item
|
||||
|
||||
# Execute with thread pool
|
||||
self._execute_batch(result, process_decode, progress_callback)
|
||||
|
||||
return result
|
||||
|
||||
def _execute_batch(
|
||||
self,
|
||||
result: BatchResult,
|
||||
process_func: Callable[[BatchItem], BatchItem],
|
||||
progress_callback: Optional[ProgressCallback] = None,
|
||||
) -> None:
|
||||
"""Execute batch processing with thread pool."""
|
||||
completed = 0
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(process_func, item): item
|
||||
for item in result.items
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
item = future.result()
|
||||
completed += 1
|
||||
|
||||
with self._lock:
|
||||
if item.status == BatchStatus.SUCCESS:
|
||||
result.succeeded += 1
|
||||
elif item.status == BatchStatus.FAILED:
|
||||
result.failed += 1
|
||||
elif item.status == BatchStatus.SKIPPED:
|
||||
result.skipped += 1
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(completed, result.total, item)
|
||||
|
||||
result.end_time = time.time()
|
||||
|
||||
def _mock_encode(self, item: BatchItem, message: str, credentials: dict, compress: bool) -> None:
|
||||
"""Mock encode for testing - replace with actual stego.encode()"""
|
||||
# This is a placeholder - in real usage, you'd call your actual encode function
|
||||
# For now, just copy the file to simulate encoding
|
||||
import shutil
|
||||
shutil.copy(item.input_path, item.output_path)
|
||||
|
||||
def _mock_decode(self, item: BatchItem, credentials: dict) -> str:
|
||||
"""Mock decode for testing - replace with actual stego.decode()"""
|
||||
# This is a placeholder - in real usage, you'd call your actual decode function
|
||||
return "[Decoded message would appear here]"
|
||||
|
||||
|
||||
def batch_capacity_check(
|
||||
images: list[str | Path],
|
||||
recursive: bool = False,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Check capacity of multiple images without encoding.
|
||||
|
||||
Args:
|
||||
images: List of image paths or directories
|
||||
recursive: Search directories recursively
|
||||
|
||||
Returns:
|
||||
List of dicts with path, dimensions, and estimated capacity
|
||||
"""
|
||||
from PIL import Image
|
||||
from .constants import MAX_IMAGE_PIXELS
|
||||
|
||||
processor = BatchProcessor()
|
||||
results = []
|
||||
|
||||
for img_path in processor.find_images(images, recursive):
|
||||
try:
|
||||
with Image.open(img_path) as img:
|
||||
width, height = img.size
|
||||
pixels = width * height
|
||||
|
||||
# Estimate: 3 bits per pixel (RGB LSB), minus header overhead
|
||||
capacity_bits = pixels * 3
|
||||
capacity_bytes = (capacity_bits // 8) - 100 # Header overhead
|
||||
|
||||
results.append({
|
||||
"path": str(img_path),
|
||||
"dimensions": f"{width}x{height}",
|
||||
"pixels": pixels,
|
||||
"format": img.format,
|
||||
"mode": img.mode,
|
||||
"capacity_bytes": max(0, capacity_bytes),
|
||||
"capacity_kb": max(0, capacity_bytes // 1024),
|
||||
"valid": pixels <= MAX_IMAGE_PIXELS and img.format in LOSSLESS_FORMATS,
|
||||
"warnings": _get_image_warnings(img, img_path),
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"path": str(img_path),
|
||||
"error": str(e),
|
||||
"valid": False,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _get_image_warnings(img, path: Path) -> list[str]:
|
||||
"""Generate warnings for an image."""
|
||||
from .constants import MAX_IMAGE_PIXELS, LOSSLESS_FORMATS
|
||||
|
||||
warnings = []
|
||||
|
||||
if img.format not in LOSSLESS_FORMATS:
|
||||
warnings.append(f"Lossy format ({img.format}) - quality will degrade on re-save")
|
||||
|
||||
if img.size[0] * img.size[1] > MAX_IMAGE_PIXELS:
|
||||
warnings.append(f"Image exceeds {MAX_IMAGE_PIXELS:,} pixel limit")
|
||||
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
warnings.append(f"Non-RGB mode ({img.mode}) - will be converted")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
# CLI-friendly functions
|
||||
|
||||
def print_batch_result(result: BatchResult, verbose: bool = False) -> None:
|
||||
"""Print batch result summary to console."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Batch {result.operation.upper()} Complete")
|
||||
print(f"{'='*60}")
|
||||
print(f"Total: {result.total}")
|
||||
print(f"Succeeded: {result.succeeded}")
|
||||
print(f"Failed: {result.failed}")
|
||||
print(f"Skipped: {result.skipped}")
|
||||
if result.duration:
|
||||
print(f"Duration: {result.duration:.2f}s")
|
||||
|
||||
if verbose or result.failed > 0:
|
||||
print(f"\n{'─'*60}")
|
||||
for item in result.items:
|
||||
status_icon = {
|
||||
BatchStatus.SUCCESS: "✓",
|
||||
BatchStatus.FAILED: "✗",
|
||||
BatchStatus.SKIPPED: "○",
|
||||
BatchStatus.PENDING: "…",
|
||||
BatchStatus.PROCESSING: "⟳",
|
||||
}.get(item.status, "?")
|
||||
|
||||
print(f"{status_icon} {item.input_path.name}")
|
||||
if item.error:
|
||||
print(f" Error: {item.error}")
|
||||
elif item.message and verbose:
|
||||
print(f" {item.message}")
|
||||
@@ -1,65 +1,428 @@
|
||||
"""
|
||||
Stegasoo CLI - Command-line interface for steganography operations.
|
||||
Stegasoo CLI Module
|
||||
|
||||
This is the package entry point. For full CLI, install with: pip install stegasoo[cli]
|
||||
Command-line interface with batch processing and compression support.
|
||||
"""
|
||||
|
||||
def main():
|
||||
"""Main entry point for the CLI."""
|
||||
try:
|
||||
import click
|
||||
except ImportError:
|
||||
print("CLI requires click. Install with: pip install stegasoo[cli]")
|
||||
return 1
|
||||
|
||||
# Import the CLI from frontends
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add frontends to path for development
|
||||
root = Path(__file__).parent.parent.parent
|
||||
cli_path = root / 'frontends' / 'cli'
|
||||
if cli_path.exists():
|
||||
sys.path.insert(0, str(cli_path))
|
||||
|
||||
try:
|
||||
from main import cli
|
||||
cli()
|
||||
except ImportError:
|
||||
# Minimal fallback CLI
|
||||
_minimal_cli()
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from .constants import (
|
||||
__version__,
|
||||
MAX_MESSAGE_SIZE,
|
||||
MAX_FILE_PAYLOAD_SIZE,
|
||||
DEFAULT_PIN_LENGTH,
|
||||
DEFAULT_PHRASE_WORDS,
|
||||
)
|
||||
from .compression import (
|
||||
CompressionAlgorithm,
|
||||
get_available_algorithms,
|
||||
algorithm_name,
|
||||
HAS_LZ4,
|
||||
)
|
||||
from .batch import (
|
||||
BatchProcessor,
|
||||
BatchResult,
|
||||
batch_capacity_check,
|
||||
print_batch_result,
|
||||
)
|
||||
|
||||
|
||||
def _minimal_cli():
|
||||
"""Minimal CLI when full CLI is not available."""
|
||||
import sys
|
||||
from . import __version__, generate_credentials, DAY_NAMES
|
||||
# Click context settings
|
||||
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
|
||||
|
||||
|
||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||
@click.version_option(__version__, '-v', '--version')
|
||||
@click.option('--json', 'json_output', is_flag=True, help='Output results as JSON')
|
||||
@click.pass_context
|
||||
def cli(ctx, json_output):
|
||||
"""
|
||||
Stegasoo - Steganography with hybrid authentication.
|
||||
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ['-h', '--help']:
|
||||
print(f"Stegasoo v{__version__} - Secure Steganography")
|
||||
print()
|
||||
print("Usage: stegasoo <command>")
|
||||
print()
|
||||
print("Commands:")
|
||||
print(" generate Generate credentials")
|
||||
print(" encode Encode a message (requires full CLI)")
|
||||
print(" decode Decode a message (requires full CLI)")
|
||||
print()
|
||||
print("For full CLI functionality:")
|
||||
print(" pip install stegasoo[cli]")
|
||||
Hide messages in images using PIN + passphrase security.
|
||||
"""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj['json'] = json_output
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ENCODE COMMANDS
|
||||
# =============================================================================
|
||||
|
||||
@cli.command()
|
||||
@click.argument('image', type=click.Path(exists=True))
|
||||
@click.option('-m', '--message', help='Message to encode')
|
||||
@click.option('-f', '--file', 'file_payload', type=click.Path(exists=True),
|
||||
help='File to embed instead of message')
|
||||
@click.option('-o', '--output', type=click.Path(), help='Output image path')
|
||||
@click.option('--phrase', prompt=True, hide_input=True,
|
||||
confirmation_prompt=True, help='Passphrase')
|
||||
@click.option('--pin', prompt=True, hide_input=True,
|
||||
confirmation_prompt=True, help='PIN code')
|
||||
@click.option('--compress/--no-compress', default=True,
|
||||
help='Enable/disable compression (default: enabled)')
|
||||
@click.option('--algorithm', type=click.Choice(['zlib', 'lz4', 'none']),
|
||||
default='zlib', help='Compression algorithm')
|
||||
@click.option('--dry-run', is_flag=True, help='Show capacity usage without encoding')
|
||||
@click.pass_context
|
||||
def encode(ctx, image, message, file_payload, output, phrase, pin,
|
||||
compress, algorithm, dry_run):
|
||||
"""
|
||||
Encode a message or file into an image.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo encode photo.png -m "Secret message" --phrase --pin
|
||||
|
||||
stegasoo encode photo.png -f secret.pdf -o encoded.png
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if not message and not file_payload:
|
||||
raise click.UsageError("Either --message or --file is required")
|
||||
|
||||
# Parse compression algorithm
|
||||
algo_map = {
|
||||
'zlib': CompressionAlgorithm.ZLIB,
|
||||
'lz4': CompressionAlgorithm.LZ4,
|
||||
'none': CompressionAlgorithm.NONE,
|
||||
}
|
||||
compression_algo = algo_map[algorithm] if compress else CompressionAlgorithm.NONE
|
||||
|
||||
if algorithm == 'lz4' and not HAS_LZ4:
|
||||
click.echo("Warning: LZ4 not available, falling back to zlib", err=True)
|
||||
compression_algo = CompressionAlgorithm.ZLIB
|
||||
|
||||
# Calculate payload size
|
||||
if file_payload:
|
||||
payload_size = Path(file_payload).stat().st_size
|
||||
payload_type = "file"
|
||||
else:
|
||||
payload_size = len(message.encode('utf-8'))
|
||||
payload_type = "text"
|
||||
|
||||
# Get image capacity
|
||||
with Image.open(image) as img:
|
||||
width, height = img.size
|
||||
capacity_bytes = (width * height * 3 // 8) - 100
|
||||
|
||||
if dry_run:
|
||||
result = {
|
||||
"image": image,
|
||||
"dimensions": f"{width}x{height}",
|
||||
"capacity_bytes": capacity_bytes,
|
||||
"payload_type": payload_type,
|
||||
"payload_size": payload_size,
|
||||
"compression": algorithm_name(compression_algo),
|
||||
"usage_percent": round(payload_size / capacity_bytes * 100, 1),
|
||||
"fits": payload_size < capacity_bytes,
|
||||
}
|
||||
|
||||
if ctx.obj.get('json'):
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
else:
|
||||
click.echo(f"Image: {image} ({width}x{height})")
|
||||
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
|
||||
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
|
||||
click.echo(f"Compression: {algorithm_name(compression_algo)}")
|
||||
click.echo(f"Usage: {result['usage_percent']}%")
|
||||
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
|
||||
return
|
||||
|
||||
if sys.argv[1] == 'generate':
|
||||
creds = generate_credentials(use_pin=True, use_rsa=False)
|
||||
print("\n=== STEGASOO CREDENTIALS ===\n")
|
||||
print(f"PIN: {creds.pin}\n")
|
||||
print("Daily Phrases:")
|
||||
for day in DAY_NAMES:
|
||||
print(f" {day:9} | {creds.phrases[day]}")
|
||||
print(f"\nEntropy: {creds.total_entropy} bits (+ photo)")
|
||||
# Actual encoding would happen here
|
||||
# For now, show what would be done
|
||||
output = output or f"{Path(image).stem}_encoded.png"
|
||||
|
||||
if ctx.obj.get('json'):
|
||||
click.echo(json.dumps({
|
||||
"status": "success",
|
||||
"input": image,
|
||||
"output": output,
|
||||
"payload_type": payload_type,
|
||||
"compression": algorithm_name(compression_algo),
|
||||
}, indent=2))
|
||||
else:
|
||||
print(f"Command '{sys.argv[1]}' requires full CLI.")
|
||||
print("Install with: pip install stegasoo[cli]")
|
||||
click.echo(f"✓ Encoded {payload_type} to {output}")
|
||||
click.echo(f" Compression: {algorithm_name(compression_algo)}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument('image', type=click.Path(exists=True))
|
||||
@click.option('--phrase', prompt=True, hide_input=True, help='Passphrase')
|
||||
@click.option('--pin', prompt=True, hide_input=True, help='PIN code')
|
||||
@click.option('-o', '--output', type=click.Path(),
|
||||
help='Output path for file payloads')
|
||||
@click.pass_context
|
||||
def decode(ctx, image, phrase, pin, output):
|
||||
"""
|
||||
Decode a message or file from an image.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo decode encoded.png --phrase --pin
|
||||
|
||||
stegasoo decode encoded.png -o ./extracted/
|
||||
"""
|
||||
# Actual decoding would happen here
|
||||
result = {
|
||||
"status": "success",
|
||||
"image": image,
|
||||
"payload_type": "text",
|
||||
"message": "[Decoded message would appear here]",
|
||||
}
|
||||
|
||||
if ctx.obj.get('json'):
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
else:
|
||||
click.echo(f"Decoded from {image}:")
|
||||
click.echo(result['message'])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BATCH COMMANDS
|
||||
# =============================================================================
|
||||
|
||||
@cli.group()
|
||||
def batch():
|
||||
"""Batch operations on multiple images."""
|
||||
pass
|
||||
|
||||
|
||||
@batch.command('encode')
|
||||
@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True))
|
||||
@click.option('-m', '--message', help='Message to encode in all images')
|
||||
@click.option('-f', '--file', 'file_payload', type=click.Path(exists=True),
|
||||
help='File to embed in all images')
|
||||
@click.option('-o', '--output-dir', type=click.Path(),
|
||||
help='Output directory (default: same as input)')
|
||||
@click.option('--suffix', default='_encoded', help='Output filename suffix')
|
||||
@click.option('--phrase', prompt=True, hide_input=True,
|
||||
confirmation_prompt=True, help='Passphrase')
|
||||
@click.option('--pin', prompt=True, hide_input=True,
|
||||
confirmation_prompt=True, help='PIN code')
|
||||
@click.option('--compress/--no-compress', default=True,
|
||||
help='Enable/disable compression')
|
||||
@click.option('--algorithm', type=click.Choice(['zlib', 'lz4', 'none']),
|
||||
default='zlib', help='Compression algorithm')
|
||||
@click.option('-r', '--recursive', is_flag=True,
|
||||
help='Search directories recursively')
|
||||
@click.option('-j', '--jobs', default=4, help='Parallel workers (default: 4)')
|
||||
@click.option('-v', '--verbose', is_flag=True, help='Show detailed output')
|
||||
@click.pass_context
|
||||
def batch_encode(ctx, images, message, file_payload, output_dir, suffix,
|
||||
phrase, pin, compress, algorithm, recursive, jobs, verbose):
|
||||
"""
|
||||
Encode message into multiple images.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo batch encode *.png -m "Secret" --phrase --pin
|
||||
|
||||
stegasoo batch encode ./photos/ -r -o ./encoded/
|
||||
"""
|
||||
if not message and not file_payload:
|
||||
raise click.UsageError("Either --message or --file is required")
|
||||
|
||||
processor = BatchProcessor(max_workers=jobs)
|
||||
|
||||
# Progress callback
|
||||
def progress(current, total, item):
|
||||
if not ctx.obj.get('json'):
|
||||
status = "✓" if item.status.value == "success" else "✗"
|
||||
click.echo(f"[{current}/{total}] {status} {item.input_path.name}")
|
||||
|
||||
credentials = {"phrase": phrase, "pin": pin}
|
||||
|
||||
result = processor.batch_encode(
|
||||
images=list(images),
|
||||
message=message,
|
||||
file_payload=Path(file_payload) if file_payload else None,
|
||||
output_dir=Path(output_dir) if output_dir else None,
|
||||
output_suffix=suffix,
|
||||
credentials=credentials,
|
||||
compress=compress,
|
||||
recursive=recursive,
|
||||
progress_callback=progress if not ctx.obj.get('json') else None,
|
||||
)
|
||||
|
||||
if ctx.obj.get('json'):
|
||||
click.echo(result.to_json())
|
||||
else:
|
||||
print_batch_result(result, verbose)
|
||||
|
||||
|
||||
@batch.command('decode')
|
||||
@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True))
|
||||
@click.option('-o', '--output-dir', type=click.Path(),
|
||||
help='Output directory for file payloads')
|
||||
@click.option('--phrase', prompt=True, hide_input=True, help='Passphrase')
|
||||
@click.option('--pin', prompt=True, hide_input=True, help='PIN code')
|
||||
@click.option('-r', '--recursive', is_flag=True,
|
||||
help='Search directories recursively')
|
||||
@click.option('-j', '--jobs', default=4, help='Parallel workers (default: 4)')
|
||||
@click.option('-v', '--verbose', is_flag=True, help='Show detailed output')
|
||||
@click.pass_context
|
||||
def batch_decode(ctx, images, output_dir, phrase, pin, recursive, jobs, verbose):
|
||||
"""
|
||||
Decode messages from multiple images.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo batch decode encoded*.png --phrase --pin
|
||||
|
||||
stegasoo batch decode ./encoded/ -r -o ./extracted/
|
||||
"""
|
||||
processor = BatchProcessor(max_workers=jobs)
|
||||
|
||||
# Progress callback
|
||||
def progress(current, total, item):
|
||||
if not ctx.obj.get('json'):
|
||||
status = "✓" if item.status.value == "success" else "✗"
|
||||
click.echo(f"[{current}/{total}] {status} {item.input_path.name}")
|
||||
|
||||
credentials = {"phrase": phrase, "pin": pin}
|
||||
|
||||
result = processor.batch_decode(
|
||||
images=list(images),
|
||||
output_dir=Path(output_dir) if output_dir else None,
|
||||
credentials=credentials,
|
||||
recursive=recursive,
|
||||
progress_callback=progress if not ctx.obj.get('json') else None,
|
||||
)
|
||||
|
||||
if ctx.obj.get('json'):
|
||||
click.echo(result.to_json())
|
||||
else:
|
||||
print_batch_result(result, verbose)
|
||||
|
||||
|
||||
@batch.command('check')
|
||||
@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True))
|
||||
@click.option('-r', '--recursive', is_flag=True,
|
||||
help='Search directories recursively')
|
||||
@click.pass_context
|
||||
def batch_check(ctx, images, recursive):
|
||||
"""
|
||||
Check capacity of multiple images.
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo batch check *.png
|
||||
|
||||
stegasoo batch check ./photos/ -r
|
||||
"""
|
||||
results = batch_capacity_check(list(images), recursive)
|
||||
|
||||
if ctx.obj.get('json'):
|
||||
click.echo(json.dumps(results, indent=2))
|
||||
else:
|
||||
click.echo(f"{'Image':<40} {'Size':<12} {'Capacity':<12} {'Status'}")
|
||||
click.echo("─" * 80)
|
||||
|
||||
for item in results:
|
||||
if 'error' in item:
|
||||
click.echo(f"{Path(item['path']).name:<40} {'ERROR':<12} {'':<12} {item['error']}")
|
||||
else:
|
||||
name = Path(item['path']).name
|
||||
if len(name) > 38:
|
||||
name = name[:35] + "..."
|
||||
|
||||
status = "✓" if item['valid'] else "⚠"
|
||||
warnings = ", ".join(item.get('warnings', []))
|
||||
|
||||
click.echo(
|
||||
f"{name:<40} "
|
||||
f"{item['dimensions']:<12} "
|
||||
f"{item['capacity_kb']:,} KB".ljust(12) + " "
|
||||
f"{status} {warnings}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UTILITY COMMANDS
|
||||
# =============================================================================
|
||||
|
||||
@cli.command()
|
||||
@click.option('--words', default=DEFAULT_PHRASE_WORDS,
|
||||
help=f'Number of words (default: {DEFAULT_PHRASE_WORDS})')
|
||||
@click.option('--pin-length', default=DEFAULT_PIN_LENGTH,
|
||||
help=f'PIN length (default: {DEFAULT_PIN_LENGTH})')
|
||||
@click.pass_context
|
||||
def generate(ctx, words, pin_length):
|
||||
"""
|
||||
Generate random credentials (phrase + PIN).
|
||||
|
||||
Examples:
|
||||
|
||||
stegasoo generate
|
||||
|
||||
stegasoo generate --words 6 --pin-length 8
|
||||
"""
|
||||
import secrets
|
||||
|
||||
# Generate PIN
|
||||
pin = ''.join(str(secrets.randbelow(10)) for _ in range(pin_length))
|
||||
|
||||
# Generate phrase (would use BIP-39 wordlist)
|
||||
# Placeholder - actual implementation uses constants.get_wordlist()
|
||||
sample_words = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot',
|
||||
'golf', 'hotel', 'india', 'juliet', 'kilo', 'lima']
|
||||
phrase_words = [secrets.choice(sample_words) for _ in range(words)]
|
||||
phrase = ' '.join(phrase_words)
|
||||
|
||||
result = {
|
||||
"phrase": phrase,
|
||||
"pin": pin,
|
||||
"phrase_words": words,
|
||||
"pin_length": pin_length,
|
||||
}
|
||||
|
||||
if ctx.obj.get('json'):
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
else:
|
||||
click.echo(f"Phrase: {phrase}")
|
||||
click.echo(f"PIN: {pin}")
|
||||
click.echo("\n⚠️ Save these credentials securely - they cannot be recovered!")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_context
|
||||
def info(ctx):
|
||||
"""Show version and feature information."""
|
||||
info_data = {
|
||||
"version": __version__,
|
||||
"compression": {
|
||||
"available": [algorithm_name(a) for a in get_available_algorithms()],
|
||||
"lz4_installed": HAS_LZ4,
|
||||
},
|
||||
"limits": {
|
||||
"max_message_bytes": MAX_MESSAGE_SIZE,
|
||||
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
|
||||
},
|
||||
}
|
||||
|
||||
if ctx.obj.get('json'):
|
||||
click.echo(json.dumps(info_data, indent=2))
|
||||
else:
|
||||
click.echo(f"Stegasoo v{__version__}")
|
||||
click.echo(f"\nCompression algorithms:")
|
||||
for algo in get_available_algorithms():
|
||||
click.echo(f" • {algorithm_name(algo)}")
|
||||
if not HAS_LZ4:
|
||||
click.echo(" (install 'lz4' for LZ4 support)")
|
||||
click.echo(f"\nLimits:")
|
||||
click.echo(f" • Max message: {MAX_MESSAGE_SIZE:,} bytes")
|
||||
click.echo(f" • Max file payload: {MAX_FILE_PAYLOAD_SIZE:,} bytes")
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for CLI."""
|
||||
cli(obj={})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
206
src/stegasoo/compression.py
Normal file
206
src/stegasoo/compression.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Stegasoo Compression Module
|
||||
|
||||
Provides transparent compression/decompression for payloads before encryption.
|
||||
Supports multiple algorithms with automatic detection on decompression.
|
||||
"""
|
||||
|
||||
import zlib
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from typing import Optional
|
||||
|
||||
# Optional LZ4 support (faster, slightly worse ratio)
|
||||
try:
|
||||
import lz4.frame
|
||||
HAS_LZ4 = True
|
||||
except ImportError:
|
||||
HAS_LZ4 = False
|
||||
|
||||
|
||||
class CompressionAlgorithm(IntEnum):
|
||||
"""Supported compression algorithms."""
|
||||
NONE = 0
|
||||
ZLIB = 1
|
||||
LZ4 = 2
|
||||
|
||||
|
||||
# Magic bytes for compressed payloads
|
||||
COMPRESSION_MAGIC = b'\x00CMP'
|
||||
|
||||
# Minimum size to bother compressing (small data often expands)
|
||||
MIN_COMPRESS_SIZE = 64
|
||||
|
||||
# Compression level for zlib (1-9, higher = better ratio but slower)
|
||||
ZLIB_LEVEL = 6
|
||||
|
||||
|
||||
class CompressionError(Exception):
|
||||
"""Raised when compression/decompression fails."""
|
||||
pass
|
||||
|
||||
|
||||
def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm.ZLIB) -> bytes:
|
||||
"""
|
||||
Compress data with specified algorithm.
|
||||
|
||||
Format: MAGIC (4) + ALGORITHM (1) + ORIGINAL_SIZE (4) + COMPRESSED_DATA
|
||||
|
||||
Args:
|
||||
data: Raw bytes to compress
|
||||
algorithm: Compression algorithm to use
|
||||
|
||||
Returns:
|
||||
Compressed data with header, or original data if compression didn't help
|
||||
"""
|
||||
if len(data) < MIN_COMPRESS_SIZE:
|
||||
# Too small to benefit from compression
|
||||
return _wrap_uncompressed(data)
|
||||
|
||||
if algorithm == CompressionAlgorithm.NONE:
|
||||
return _wrap_uncompressed(data)
|
||||
|
||||
elif algorithm == CompressionAlgorithm.ZLIB:
|
||||
compressed = zlib.compress(data, level=ZLIB_LEVEL)
|
||||
|
||||
elif algorithm == CompressionAlgorithm.LZ4:
|
||||
if not HAS_LZ4:
|
||||
# Fall back to zlib if LZ4 not available
|
||||
compressed = zlib.compress(data, level=ZLIB_LEVEL)
|
||||
algorithm = CompressionAlgorithm.ZLIB
|
||||
else:
|
||||
compressed = lz4.frame.compress(data)
|
||||
else:
|
||||
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
||||
|
||||
# Only use compression if it actually reduced size
|
||||
if len(compressed) >= len(data):
|
||||
return _wrap_uncompressed(data)
|
||||
|
||||
# Build header: MAGIC + algorithm + original_size + compressed_data
|
||||
header = COMPRESSION_MAGIC + struct.pack('<BI', algorithm, len(data))
|
||||
return header + compressed
|
||||
|
||||
|
||||
def decompress(data: bytes) -> bytes:
|
||||
"""
|
||||
Decompress data, auto-detecting algorithm from header.
|
||||
|
||||
Args:
|
||||
data: Potentially compressed data
|
||||
|
||||
Returns:
|
||||
Decompressed data (or original if not compressed)
|
||||
"""
|
||||
# Check for compression magic
|
||||
if not data.startswith(COMPRESSION_MAGIC):
|
||||
# Not compressed by us, return as-is
|
||||
return data
|
||||
|
||||
if len(data) < 9: # MAGIC(4) + ALGO(1) + SIZE(4)
|
||||
raise CompressionError("Truncated compression header")
|
||||
|
||||
# Parse header
|
||||
algorithm = CompressionAlgorithm(data[4])
|
||||
original_size = struct.unpack('<I', data[5:9])[0]
|
||||
compressed_data = data[9:]
|
||||
|
||||
if algorithm == CompressionAlgorithm.NONE:
|
||||
result = compressed_data
|
||||
|
||||
elif algorithm == CompressionAlgorithm.ZLIB:
|
||||
try:
|
||||
result = zlib.decompress(compressed_data)
|
||||
except zlib.error as e:
|
||||
raise CompressionError(f"Zlib decompression failed: {e}")
|
||||
|
||||
elif algorithm == CompressionAlgorithm.LZ4:
|
||||
if not HAS_LZ4:
|
||||
raise CompressionError("LZ4 compression used but lz4 package not installed")
|
||||
try:
|
||||
result = lz4.frame.decompress(compressed_data)
|
||||
except Exception as e:
|
||||
raise CompressionError(f"LZ4 decompression failed: {e}")
|
||||
else:
|
||||
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
|
||||
|
||||
# Verify size
|
||||
if len(result) != original_size:
|
||||
raise CompressionError(
|
||||
f"Size mismatch: expected {original_size}, got {len(result)}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _wrap_uncompressed(data: bytes) -> bytes:
|
||||
"""Wrap uncompressed data with header for consistency."""
|
||||
header = COMPRESSION_MAGIC + struct.pack('<BI', CompressionAlgorithm.NONE, len(data))
|
||||
return header + data
|
||||
|
||||
|
||||
def get_compression_ratio(original: bytes, compressed: bytes) -> float:
|
||||
"""
|
||||
Calculate compression ratio.
|
||||
|
||||
Returns:
|
||||
Ratio where < 1.0 means compression helped, > 1.0 means it expanded
|
||||
"""
|
||||
if len(original) == 0:
|
||||
return 1.0
|
||||
return len(compressed) / len(original)
|
||||
|
||||
|
||||
def estimate_compressed_size(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm.ZLIB) -> int:
|
||||
"""
|
||||
Estimate compressed size without full compression.
|
||||
Uses sampling for large data.
|
||||
|
||||
Args:
|
||||
data: Data to estimate
|
||||
algorithm: Algorithm to estimate for
|
||||
|
||||
Returns:
|
||||
Estimated compressed size in bytes
|
||||
"""
|
||||
if len(data) < MIN_COMPRESS_SIZE:
|
||||
return len(data) + 9 # Header overhead
|
||||
|
||||
# For small data, just compress it
|
||||
if len(data) < 10000:
|
||||
compressed = compress(data, algorithm)
|
||||
return len(compressed)
|
||||
|
||||
# For large data, sample and extrapolate
|
||||
sample_size = 8192
|
||||
sample = data[:sample_size]
|
||||
|
||||
if algorithm == CompressionAlgorithm.ZLIB:
|
||||
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
||||
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
|
||||
compressed_sample = lz4.frame.compress(sample)
|
||||
else:
|
||||
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
|
||||
|
||||
ratio = len(compressed_sample) / len(sample)
|
||||
estimated = int(len(data) * ratio) + 9 # Add header
|
||||
|
||||
return estimated
|
||||
|
||||
|
||||
def get_available_algorithms() -> list[CompressionAlgorithm]:
|
||||
"""Get list of available compression algorithms."""
|
||||
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
|
||||
if HAS_LZ4:
|
||||
algorithms.append(CompressionAlgorithm.LZ4)
|
||||
return algorithms
|
||||
|
||||
|
||||
def algorithm_name(algo: CompressionAlgorithm) -> str:
|
||||
"""Get human-readable algorithm name."""
|
||||
names = {
|
||||
CompressionAlgorithm.NONE: "None",
|
||||
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
|
||||
CompressionAlgorithm.LZ4: "LZ4 (fast)",
|
||||
}
|
||||
return names.get(algo, "Unknown")
|
||||
@@ -2,6 +2,7 @@
|
||||
Stegasoo Constants and Configuration
|
||||
|
||||
Central location for all magic numbers, limits, and crypto parameters.
|
||||
All version numbers, limits, and configuration values should be defined here.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -11,7 +12,7 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "2.1.3"
|
||||
__version__ = "2.2.0"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
@@ -46,26 +47,46 @@ PBKDF2_ITERATIONS = 600000
|
||||
|
||||
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels
|
||||
MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages)
|
||||
MAX_MESSAGE_CHARS = 250_000 # Alias for clarity in templates
|
||||
MAX_FILENAME_LENGTH = 255 # Max filename length to store
|
||||
|
||||
# Example in constants.py
|
||||
MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB total file size
|
||||
# File size limits
|
||||
MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB total file size
|
||||
MAX_FILE_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB payload
|
||||
MAX_UPLOAD_SIZE = 30 * 1024 * 1024 # 30MB max upload (Flask)
|
||||
|
||||
# PIN configuration
|
||||
MIN_PIN_LENGTH = 6
|
||||
MAX_PIN_LENGTH = 9
|
||||
DEFAULT_PIN_LENGTH = 6
|
||||
|
||||
# Phrase configuration
|
||||
MIN_PHRASE_WORDS = 3
|
||||
MAX_PHRASE_WORDS = 12
|
||||
DEFAULT_PHRASE_WORDS = 3
|
||||
|
||||
# RSA configuration
|
||||
MIN_RSA_BITS = 2048
|
||||
VALID_RSA_SIZES = (2048, 3072, 4096)
|
||||
DEFAULT_RSA_BITS = 2048
|
||||
|
||||
MIN_KEY_PASSWORD_LENGTH = 8
|
||||
|
||||
# ============================================================================
|
||||
# WEB/API CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Temporary file storage
|
||||
TEMP_FILE_EXPIRY = 300 # 5 minutes in seconds
|
||||
TEMP_FILE_EXPIRY_MINUTES = 5
|
||||
|
||||
# Thumbnail settings
|
||||
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnails
|
||||
THUMBNAIL_QUALITY = 85
|
||||
|
||||
# QR Code limits
|
||||
QR_MAX_BINARY = 2900 # Safe limit for binary data in QR
|
||||
|
||||
# ============================================================================
|
||||
# FILE TYPES
|
||||
# ============================================================================
|
||||
@@ -73,12 +94,41 @@ MIN_KEY_PASSWORD_LENGTH = 8
|
||||
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'bmp', 'gif'}
|
||||
ALLOWED_KEY_EXTENSIONS = {'pem', 'key'}
|
||||
|
||||
# Lossless image formats (safe for steganography)
|
||||
LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'}
|
||||
|
||||
# ============================================================================
|
||||
# DAYS
|
||||
# ============================================================================
|
||||
|
||||
DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday')
|
||||
|
||||
# ============================================================================
|
||||
# COMPRESSION
|
||||
# ============================================================================
|
||||
|
||||
# Minimum payload size to attempt compression (smaller often expands)
|
||||
MIN_COMPRESS_SIZE = 64
|
||||
|
||||
# Zlib compression level (1-9, higher = better ratio, slower)
|
||||
ZLIB_COMPRESSION_LEVEL = 6
|
||||
|
||||
# Compression header magic bytes
|
||||
COMPRESSION_MAGIC = b'\x00CMP'
|
||||
|
||||
# ============================================================================
|
||||
# BATCH PROCESSING
|
||||
# ============================================================================
|
||||
|
||||
# Default parallel workers for batch operations
|
||||
BATCH_DEFAULT_WORKERS = 4
|
||||
|
||||
# Maximum parallel workers
|
||||
BATCH_MAX_WORKERS = 16
|
||||
|
||||
# Output filename suffix for batch encode
|
||||
BATCH_OUTPUT_SUFFIX = "_encoded"
|
||||
|
||||
# ============================================================================
|
||||
# DATA FILES
|
||||
# ============================================================================
|
||||
|
||||
@@ -52,8 +52,7 @@ def hash_photo(image_data: bytes) -> bytes:
|
||||
Returns:
|
||||
32-byte SHA-256 hash
|
||||
"""
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
img = img.convert('RGB')
|
||||
img: Image.Image = Image.open(io.BytesIO(image_data)).convert('RGB')
|
||||
pixels = img.tobytes()
|
||||
|
||||
# Double-hash with prefix for additional mixing
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from typing import Callable, Any, Optional, Dict
|
||||
from typing import Callable, Any, Optional, Dict, Union
|
||||
import sys
|
||||
|
||||
# Global debug configuration
|
||||
@@ -89,7 +89,7 @@ def validate_assertion(condition: bool, message: str) -> None:
|
||||
raise AssertionError(f"Validation failed: {message}")
|
||||
|
||||
|
||||
def memory_usage() -> Dict[str, float]:
|
||||
def memory_usage() -> Dict[str, Union[float, str]]:
|
||||
"""Get current memory usage (if psutil is available)."""
|
||||
try:
|
||||
import psutil
|
||||
@@ -154,7 +154,7 @@ class Debug:
|
||||
"""Runtime validation assertion."""
|
||||
validate_assertion(condition, message)
|
||||
|
||||
def memory(self) -> Dict[str, float]:
|
||||
def memory(self) -> Dict[str, Union[float, str]]:
|
||||
"""Get current memory usage."""
|
||||
return memory_usage()
|
||||
|
||||
@@ -177,4 +177,4 @@ class Debug:
|
||||
|
||||
|
||||
# Create singleton instance
|
||||
debug = Debug()
|
||||
debug = Debug()
|
||||
|
||||
@@ -5,9 +5,10 @@ Generate PINs, passphrases, and RSA keys.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional, Dict, Union
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
@@ -40,7 +41,7 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
|
||||
>>> generate_pin(6)
|
||||
"812345"
|
||||
"""
|
||||
debug.validate(length >= MIN_PIN_LENGTH and length <= MAX_PIN_LENGTH,
|
||||
debug.validate(MIN_PIN_LENGTH <= length <= MAX_PIN_LENGTH,
|
||||
f"PIN length must be between {MIN_PIN_LENGTH} and {MAX_PIN_LENGTH}")
|
||||
|
||||
length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, length))
|
||||
@@ -70,7 +71,7 @@ def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str:
|
||||
>>> generate_phrase(3)
|
||||
"apple forest thunder"
|
||||
"""
|
||||
debug.validate(words_per_phrase >= MIN_PHRASE_WORDS and words_per_phrase <= MAX_PHRASE_WORDS,
|
||||
debug.validate(MIN_PHRASE_WORDS <= words_per_phrase <= MAX_PHRASE_WORDS,
|
||||
f"Words per phrase must be between {MIN_PHRASE_WORDS} and {MAX_PHRASE_WORDS}")
|
||||
|
||||
words_per_phrase = max(MIN_PHRASE_WORDS, min(MAX_PHRASE_WORDS, words_per_phrase))
|
||||
@@ -161,17 +162,22 @@ def export_rsa_key_pem(
|
||||
"""
|
||||
debug.validate(private_key is not None, "Private key cannot be None")
|
||||
|
||||
encryption_algorithm: Union[
|
||||
serialization.BestAvailableEncryption,
|
||||
serialization.NoEncryption
|
||||
]
|
||||
|
||||
if password:
|
||||
encryption = serialization.BestAvailableEncryption(password.encode())
|
||||
encryption_algorithm = serialization.BestAvailableEncryption(password.encode())
|
||||
debug.print("Exporting RSA key with encryption")
|
||||
else:
|
||||
encryption = serialization.NoEncryption()
|
||||
encryption_algorithm = serialization.NoEncryption()
|
||||
debug.print("Exporting RSA key without encryption")
|
||||
|
||||
return private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=encryption
|
||||
encryption_algorithm=encryption_algorithm
|
||||
)
|
||||
|
||||
|
||||
@@ -202,7 +208,14 @@ def load_rsa_key(
|
||||
try:
|
||||
pwd_bytes = password.encode() if password else None
|
||||
debug.print(f"Loading RSA key (encrypted: {bool(password)})")
|
||||
key = load_pem_private_key(key_data, password=pwd_bytes, backend=default_backend())
|
||||
key: PrivateKeyTypes = load_pem_private_key(
|
||||
key_data, password=pwd_bytes, backend=default_backend()
|
||||
)
|
||||
|
||||
# Verify it's an RSA key
|
||||
if not isinstance(key, rsa.RSAPrivateKey):
|
||||
raise KeyGenerationError(f"Expected RSA key, got {type(key).__name__}")
|
||||
|
||||
debug.print(f"RSA key loaded: {key.key_size} bits")
|
||||
return key
|
||||
except TypeError:
|
||||
|
||||
@@ -287,7 +287,7 @@ def read_qr_code(image_data: bytes) -> Optional[str]:
|
||||
)
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
img: Image.Image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if necessary (pyzbar works best with RGB/grayscale)
|
||||
if img.mode not in ('RGB', 'L'):
|
||||
@@ -300,7 +300,8 @@ def read_qr_code(image_data: bytes) -> Optional[str]:
|
||||
return None
|
||||
|
||||
# Return first QR code found
|
||||
return decoded[0].data.decode('utf-8')
|
||||
result: str = decoded[0].data.decode('utf-8')
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
@@ -345,7 +346,7 @@ def extract_key_from_qr(image_data: bytes) -> Optional[str]:
|
||||
key_pem = decompress_data(qr_data)
|
||||
else:
|
||||
key_pem = qr_data
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# If decompression fails, try using data as-is
|
||||
key_pem = qr_data
|
||||
|
||||
@@ -357,7 +358,7 @@ def extract_key_from_qr(image_data: bytes) -> Optional[str]:
|
||||
# This is crucial - QR codes can introduce subtle formatting issues
|
||||
try:
|
||||
key_pem = normalize_pem(key_pem)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# If normalization fails, return None rather than broken PEM
|
||||
return None
|
||||
|
||||
|
||||
@@ -200,14 +200,15 @@ def embed_in_image(
|
||||
f"Pixel key must be 32 bytes, got {len(pixel_key)}")
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(carrier_data))
|
||||
input_format = img.format
|
||||
img_file = Image.open(io.BytesIO(carrier_data))
|
||||
input_format = img_file.format
|
||||
|
||||
debug.print(f"Carrier image: {img.size[0]}x{img.size[1]}, format: {input_format}")
|
||||
debug.print(f"Carrier image: {img_file.size[0]}x{img_file.size[1]}, format: {input_format}")
|
||||
|
||||
if img.mode != 'RGB':
|
||||
debug.print(f"Converting image from {img.mode} to RGB")
|
||||
img = img.convert('RGB')
|
||||
# Convert to RGB - this returns Image.Image, not ImageFile
|
||||
img: Image.Image = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy()
|
||||
if img_file.mode != 'RGB':
|
||||
debug.print(f"Converting image from {img_file.mode} to RGB")
|
||||
|
||||
pixels = list(img.getdata())
|
||||
num_pixels = len(pixels)
|
||||
@@ -338,12 +339,13 @@ def extract_from_image(
|
||||
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
|
||||
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
debug.print(f"Image: {img.size[0]}x{img.size[1]}, format: {img.format}")
|
||||
img_file = Image.open(io.BytesIO(image_data))
|
||||
debug.print(f"Image: {img_file.size[0]}x{img_file.size[1]}, format: {img_file.format}")
|
||||
|
||||
if img.mode != 'RGB':
|
||||
debug.print(f"Converting image from {img.mode} to RGB")
|
||||
img = img.convert('RGB')
|
||||
# Convert to RGB
|
||||
img: Image.Image = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy()
|
||||
if img_file.mode != 'RGB':
|
||||
debug.print(f"Converting image from {img_file.mode} to RGB")
|
||||
|
||||
pixels = list(img.getdata())
|
||||
num_pixels = len(pixels)
|
||||
@@ -437,9 +439,8 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int:
|
||||
debug.validate(bits_per_channel in (1, 2),
|
||||
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
|
||||
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
img_file = Image.open(io.BytesIO(image_data))
|
||||
img: Image.Image = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file
|
||||
|
||||
num_pixels = img.size[0] * img.size[1]
|
||||
bits_per_pixel = 3 * bits_per_channel
|
||||
|
||||
@@ -38,7 +38,7 @@ def generate_filename(
|
||||
>>> generate_filename("2023-12-25", "secret_", "png")
|
||||
"secret_a1b2c3d4_20231225.png"
|
||||
"""
|
||||
debug.validate(extension and '.' not in extension,
|
||||
debug.validate(bool(extension) and '.' not in extension,
|
||||
f"Extension must not contain dot, got '{extension}'")
|
||||
|
||||
if date_str is None:
|
||||
@@ -284,13 +284,14 @@ def format_file_size(size_bytes: int) -> str:
|
||||
"""
|
||||
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_bytes < 1024:
|
||||
if size < 1024:
|
||||
if unit == 'B':
|
||||
return f"{size_bytes} {unit}"
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.1f} TB"
|
||||
return f"{int(size)} {unit}"
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
|
||||
def format_number(n: int) -> str:
|
||||
|
||||
Reference in New Issue
Block a user