feat: did a couple things idk
This commit is contained in:
parent
65e01d055c
commit
f9b5a614d3
|
|
@ -1,7 +1,7 @@
|
||||||
# ©AngelaMos | 2026
|
# ©AngelaMos | 2026
|
||||||
# .gitignore
|
# .gitignore
|
||||||
|
|
||||||
# Planning docs
|
# dev docs
|
||||||
.angelusvigil/
|
.angelusvigil/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ async def _get_active_models(
|
||||||
Query all active model metadata records
|
Query all active model metadata records
|
||||||
"""
|
"""
|
||||||
query = select(ModelMetadata).where(
|
query = select(ModelMetadata).where(
|
||||||
ModelMetadata.is_active == True # noqa: E712
|
ModelMetadata.is_active == True # type: ignore[arg-type] # noqa: E712
|
||||||
)
|
)
|
||||||
rows = (await session.execute(query)).scalars().all()
|
rows = (await session.execute(query)).scalars().all()
|
||||||
return [{
|
return [{
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@ inference.py
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import onnxruntime as ort
|
import onnxruntime as ort
|
||||||
except ImportError:
|
except ImportError:
|
||||||
ort = None # type: ignore[assignment]
|
ort = None
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -33,9 +34,9 @@ class InferenceEngine:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, model_dir: str) -> None:
|
def __init__(self, model_dir: str) -> None:
|
||||||
self._ae_session: ort.InferenceSession | None = None # type: ignore[union-attr]
|
self._ae_session: ort.InferenceSession | None = None
|
||||||
self._rf_session: ort.InferenceSession | None = None # type: ignore[union-attr]
|
self._rf_session: ort.InferenceSession | None = None
|
||||||
self._if_session: ort.InferenceSession | None = None # type: ignore[union-attr]
|
self._if_session: ort.InferenceSession | None = None
|
||||||
self._scaler_center: np.ndarray | None = None
|
self._scaler_center: np.ndarray | None = None
|
||||||
self._scaler_scale: np.ndarray | None = None
|
self._scaler_scale: np.ndarray | None = None
|
||||||
self._threshold: float = 0.0
|
self._threshold: float = 0.0
|
||||||
|
|
@ -127,12 +128,12 @@ class InferenceEngine:
|
||||||
"""
|
"""
|
||||||
if self._scaler_center is None or self._scaler_scale is None:
|
if self._scaler_center is None or self._scaler_scale is None:
|
||||||
return batch
|
return batch
|
||||||
return (batch - self._scaler_center) / self._scaler_scale
|
return (batch - self._scaler_center) / self._scaler_scale # type: ignore[no-any-return]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_rf_proba(
|
def _extract_rf_proba(
|
||||||
ort_output: list | np.ndarray
|
ort_output: list[Any] | np.ndarray
|
||||||
) -> np.ndarray: # type: ignore[type-arg]
|
) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Extract attack probability from skl2onnx RF output format.
|
Extract attack probability from skl2onnx RF output format.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ CATEGORICAL_ENCODERS: dict[str, dict[str, int]] = {
|
||||||
"file_extension": EXTENSION_MAP,
|
"file_extension": EXTENSION_MAP,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WINDOWED_FEATURE_NAMES: list[str] = FEATURE_ORDER[23:]
|
||||||
|
|
||||||
BOOLEAN_FEATURES: frozenset[str] = frozenset({
|
BOOLEAN_FEATURES: frozenset[str] = frozenset({
|
||||||
"has_encoded_chars",
|
"has_encoded_chars",
|
||||||
"has_double_encoding",
|
"has_double_encoding",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import time
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
@ -24,6 +25,9 @@ from app.core.redis_manager import redis_manager
|
||||||
from app.models import model_metadata as _model_metadata_reg # noqa: F401
|
from app.models import model_metadata as _model_metadata_reg # noqa: F401
|
||||||
from app.models import threat_event as _threat_event_reg # noqa: F401
|
from app.models import threat_event as _threat_event_reg # noqa: F401
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.core.detection.inference import InferenceEngine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,7 +114,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
logger.info("AngelusVigil shut down cleanly")
|
logger.info("AngelusVigil shut down cleanly")
|
||||||
|
|
||||||
|
|
||||||
def _load_inference_engine() -> object | None:
|
def _load_inference_engine() -> InferenceEngine | None:
|
||||||
"""
|
"""
|
||||||
Attempt to load the ONNX inference engine from the
|
Attempt to load the ONNX inference engine from the
|
||||||
configured model directory, returning None if ML
|
configured model directory, returning None if ML
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ class ModelMetadata(TimestampedModel, table=True):
|
||||||
__table_args__ = (Index(
|
__table_args__ = (Index(
|
||||||
"idx_model_metadata_active",
|
"idx_model_metadata_active",
|
||||||
"model_type",
|
"model_type",
|
||||||
unique=True,
|
|
||||||
postgresql_where=text("is_active = TRUE"),
|
postgresql_where=text("is_active = TRUE"),
|
||||||
), )
|
), )
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
main.py
|
main.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
@ -22,6 +24,44 @@ DEFAULT_EXPERIMENT_NAME = "angelusvigil-training"
|
||||||
DEFAULT_SERVER_URL = "http://localhost:8000"
|
DEFAULT_SERVER_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_metadata(
|
||||||
|
model_dir: Path,
|
||||||
|
training_samples: int,
|
||||||
|
metrics: dict[str, object],
|
||||||
|
mlflow_run_id: str | None,
|
||||||
|
threshold: float | None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Persist training metadata to the database
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
from ml.metadata import save_model_metadata
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url)
|
||||||
|
try:
|
||||||
|
factory = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
async with factory() as session:
|
||||||
|
await save_model_metadata(
|
||||||
|
session,
|
||||||
|
model_dir=model_dir,
|
||||||
|
training_samples=training_samples,
|
||||||
|
metrics=metrics,
|
||||||
|
mlflow_run_id=mlflow_run_id,
|
||||||
|
threshold=threshold,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def serve(
|
def serve(
|
||||||
host: str = typer.Option("0.0.0.0", help="Bind address"),
|
host: str = typer.Option("0.0.0.0", help="Bind address"),
|
||||||
|
|
@ -92,9 +132,10 @@ def train(
|
||||||
)
|
)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
from ml.data_loader import load_csic_dataset
|
from ml.data_loader import load_csic_dataset, load_csic_normal
|
||||||
|
|
||||||
normal_path = csic_dir / "normalTrafficTraining.txt"
|
normal_path = csic_dir / "normalTrafficTraining.txt"
|
||||||
|
normal_test_path = csic_dir / "normalTrafficTest.txt"
|
||||||
attack_path = csic_dir / "anomalousTrafficTest.txt"
|
attack_path = csic_dir / "anomalousTrafficTest.txt"
|
||||||
typer.echo(f"Loading CSIC data from {csic_dir}")
|
typer.echo(f"Loading CSIC data from {csic_dir}")
|
||||||
X_csic, y_csic = load_csic_dataset(
|
X_csic, y_csic = load_csic_dataset(
|
||||||
|
|
@ -106,6 +147,14 @@ def train(
|
||||||
f" CSIC: {len(X_csic)} samples"
|
f" CSIC: {len(X_csic)} samples"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if normal_test_path.exists():
|
||||||
|
X_extra, y_extra = load_csic_normal(normal_test_path)
|
||||||
|
X_parts.append(X_extra)
|
||||||
|
y_parts.append(y_extra)
|
||||||
|
typer.echo(
|
||||||
|
f" CSIC normal test: {len(X_extra)} samples"
|
||||||
|
)
|
||||||
|
|
||||||
if synthetic_normal > 0 or synthetic_attack > 0:
|
if synthetic_normal > 0 or synthetic_attack > 0:
|
||||||
from ml.synthetic import generate_mixed_dataset
|
from ml.synthetic import generate_mixed_dataset
|
||||||
|
|
||||||
|
|
@ -143,6 +192,25 @@ def train(
|
||||||
)
|
)
|
||||||
result = orch.run(X, y)
|
result = orch.run(X, y)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metrics: dict[str, object] = (
|
||||||
|
dataclasses.asdict(result.ensemble_metrics)
|
||||||
|
if result.ensemble_metrics else {}
|
||||||
|
)
|
||||||
|
asyncio.run(_write_metadata(
|
||||||
|
Path(output_dir),
|
||||||
|
int(len(X)),
|
||||||
|
metrics,
|
||||||
|
result.mlflow_run_id,
|
||||||
|
result.ae_metrics.get("ae_threshold"),
|
||||||
|
))
|
||||||
|
typer.echo(" Model metadata saved to database")
|
||||||
|
except Exception as exc:
|
||||||
|
typer.echo(
|
||||||
|
f" Warning: could not save metadata to DB: {exc}",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
|
||||||
typer.echo(f"Models exported to {output_dir}")
|
typer.echo(f"Models exported to {output_dir}")
|
||||||
if result.ensemble_metrics is not None:
|
if result.ensemble_metrics is not None:
|
||||||
typer.echo(
|
typer.echo(
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,13 @@ class ThreatAutoencoder(nn.Module):
|
||||||
"""
|
"""
|
||||||
Compress input through the encoder to the 6-dim bottleneck.
|
Compress input through the encoder to the 6-dim bottleneck.
|
||||||
"""
|
"""
|
||||||
return self.encoder(x)
|
return self.encoder(x) # type: ignore[no-any-return]
|
||||||
|
|
||||||
def decode(self, z: Tensor) -> Tensor:
|
def decode(self, z: Tensor) -> Tensor:
|
||||||
"""
|
"""
|
||||||
Reconstruct input from the bottleneck representation.
|
Reconstruct input from the bottleneck representation.
|
||||||
"""
|
"""
|
||||||
return self.decoder(z)
|
return self.decoder(z) # type: ignore[no-any-return]
|
||||||
|
|
||||||
def forward(self, x: Tensor) -> Tensor:
|
def forward(self, x: Tensor) -> Tensor:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import numpy as np
|
||||||
|
|
||||||
from app.core.features.encoder import encode_for_inference
|
from app.core.features.encoder import encode_for_inference
|
||||||
from app.core.features.extractor import extract_request_features
|
from app.core.features.extractor import extract_request_features
|
||||||
|
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
|
||||||
from app.core.ingestion.parsers import ParsedLogEntry
|
from app.core.ingestion.parsers import ParsedLogEntry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -26,21 +27,6 @@ _DEFAULT_IP = "192.168.1.100"
|
||||||
_DEFAULT_UA = ("Mozilla/5.0 (compatible; Konqueror/3.5; Linux)"
|
_DEFAULT_UA = ("Mozilla/5.0 (compatible; Konqueror/3.5; Linux)"
|
||||||
" KHTML/3.5.8 (like Gecko)")
|
" KHTML/3.5.8 (like Gecko)")
|
||||||
|
|
||||||
_WINDOWED_FEATURE_NAMES: list[str] = [
|
|
||||||
"req_count_1m",
|
|
||||||
"req_count_5m",
|
|
||||||
"req_count_10m",
|
|
||||||
"error_rate_5m",
|
|
||||||
"unique_paths_5m",
|
|
||||||
"unique_uas_10m",
|
|
||||||
"method_entropy_5m",
|
|
||||||
"avg_response_size_5m",
|
|
||||||
"status_diversity_5m",
|
|
||||||
"path_depth_variance_5m",
|
|
||||||
"inter_request_time_mean",
|
|
||||||
"inter_request_time_std",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CSICRequest:
|
class CSICRequest:
|
||||||
|
|
@ -192,7 +178,7 @@ def load_csic_dataset(
|
||||||
entry = csic_to_parsed_entry(req)
|
entry = csic_to_parsed_entry(req)
|
||||||
features = extract_request_features(entry)
|
features = extract_request_features(entry)
|
||||||
|
|
||||||
for name in _WINDOWED_FEATURE_NAMES:
|
for name in WINDOWED_FEATURE_NAMES:
|
||||||
features[name] = 0.0
|
features[name] = 0.0
|
||||||
|
|
||||||
vector = encode_for_inference(features)
|
vector = encode_for_inference(features)
|
||||||
|
|
@ -211,3 +197,32 @@ def load_csic_dataset(
|
||||||
)
|
)
|
||||||
|
|
||||||
return X, y
|
return X, y
|
||||||
|
|
||||||
|
|
||||||
|
def load_csic_normal(
|
||||||
|
path: Path,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
Load a CSIC 2010 normal traffic file and return (X, y) arrays
|
||||||
|
with all labels set to 0
|
||||||
|
"""
|
||||||
|
reqs = parse_csic_file(path, label=0)
|
||||||
|
|
||||||
|
vectors: list[list[float]] = []
|
||||||
|
for req in reqs:
|
||||||
|
entry = csic_to_parsed_entry(req)
|
||||||
|
features = extract_request_features(entry)
|
||||||
|
for name in WINDOWED_FEATURE_NAMES:
|
||||||
|
features[name] = 0.0
|
||||||
|
vectors.append(encode_for_inference(features))
|
||||||
|
|
||||||
|
X = np.array(vectors, dtype=np.float32)
|
||||||
|
y = np.zeros(len(vectors), dtype=np.int32)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loaded %d normal samples from %s",
|
||||||
|
len(vectors),
|
||||||
|
path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return X, y
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import hashlib
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.request import urlretrieve
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -26,24 +27,6 @@ FILES = [
|
||||||
MIN_FILE_BYTES = 1_000_000
|
MIN_FILE_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
def _progress_hook(
|
|
||||||
block_num: int,
|
|
||||||
block_size: int,
|
|
||||||
total_size: int,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Print download progress to stdout
|
|
||||||
"""
|
|
||||||
downloaded = block_num * block_size
|
|
||||||
if total_size > 0:
|
|
||||||
pct = min(downloaded * 100 / total_size, 100)
|
|
||||||
sys.stdout.write(f"\r {pct:.0f}%")
|
|
||||||
else:
|
|
||||||
mb = downloaded / 1_048_576
|
|
||||||
sys.stdout.write(f"\r {mb:.1f} MB")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_sha256(path: Path) -> str:
|
def _compute_sha256(path: Path) -> str:
|
||||||
"""
|
"""
|
||||||
Compute SHA-256 hash of a file
|
Compute SHA-256 hash of a file
|
||||||
|
|
@ -73,7 +56,31 @@ def download_csic(output_dir: Path = DATASET_DIR, ) -> None:
|
||||||
print(f"Downloading {filename}...")
|
print(f"Downloading {filename}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
urlretrieve(url, dest, reporthook=_progress_hook)
|
with httpx.stream(
|
||||||
|
"GET",
|
||||||
|
url,
|
||||||
|
follow_redirects=True,
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
total = int(
|
||||||
|
response.headers.get("content-length", 0)
|
||||||
|
)
|
||||||
|
downloaded = 0
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
for chunk in response.iter_bytes(
|
||||||
|
chunk_size=65536
|
||||||
|
):
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if total > 0:
|
||||||
|
pct = min(
|
||||||
|
downloaded * 100 / total, 100
|
||||||
|
)
|
||||||
|
sys.stdout.write(f"\r {pct:.0f}%")
|
||||||
|
else:
|
||||||
|
mb = downloaded / 1_048_576
|
||||||
|
sys.stdout.write(f"\r {mb:.1f} MB")
|
||||||
|
sys.stdout.flush()
|
||||||
print()
|
print()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ def export_autoencoder(
|
||||||
|
|
||||||
torch.onnx.export(
|
torch.onnx.export(
|
||||||
model,
|
model,
|
||||||
dummy,
|
dummy, # type: ignore[arg-type]
|
||||||
str(path),
|
str(path),
|
||||||
opset_version=opset,
|
opset_version=opset,
|
||||||
export_params=True,
|
export_params=True,
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,11 @@ async def save_model_metadata(
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ModelMetadata).where(
|
select(ModelMetadata).where(
|
||||||
ModelMetadata.model_type == model_type,
|
ModelMetadata.model_type == model_type, # type: ignore[arg-type]
|
||||||
ModelMetadata.is_active == True, # noqa: E712
|
ModelMetadata.is_active == True, # type: ignore[arg-type] # noqa: E712
|
||||||
))
|
))
|
||||||
for old in result.scalars().all():
|
for old in result.scalars().all():
|
||||||
await session.delete(old)
|
old.is_active = False
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
row = ModelMetadata(
|
row = ModelMetadata(
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ orchestrator.py
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
@ -133,13 +135,24 @@ class TrainingOrchestrator:
|
||||||
"ensemble_roc_auc": ensemble.roc_auc,
|
"ensemble_roc_auc": ensemble.roc_auc,
|
||||||
})
|
})
|
||||||
passed = ensemble.passed_gates
|
passed = ensemble.passed_gates
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Ensemble validation failed")
|
logger.exception("Ensemble validation failed")
|
||||||
|
print(
|
||||||
|
f" WARNING: validation raised"
|
||||||
|
f" {type(exc).__name__}: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
ensemble = None
|
ensemble = None
|
||||||
passed = False
|
passed = False
|
||||||
|
|
||||||
for artifact in self._output_dir.iterdir():
|
for name in (
|
||||||
experiment.log_artifact(artifact)
|
AE_FILENAME,
|
||||||
|
RF_FILENAME,
|
||||||
|
IF_FILENAME,
|
||||||
|
SCALER_FILENAME,
|
||||||
|
THRESHOLD_FILENAME,
|
||||||
|
):
|
||||||
|
experiment.log_artifact(self._output_dir / name)
|
||||||
|
|
||||||
run_id = experiment.run_id
|
run_id = experiment.run_id
|
||||||
|
|
||||||
|
|
@ -159,7 +172,7 @@ class TrainingOrchestrator:
|
||||||
mlflow_run_id=run_id,
|
mlflow_run_id=run_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _train_ae(self, X_normal: np.ndarray) -> dict:
|
def _train_ae(self, X_normal: np.ndarray) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Train the autoencoder on normal-only data
|
Train the autoencoder on normal-only data
|
||||||
"""
|
"""
|
||||||
|
|
@ -174,7 +187,7 @@ class TrainingOrchestrator:
|
||||||
batch_size=self._batch_size,
|
batch_size=self._batch_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _train_rf(self, X: np.ndarray, y: np.ndarray) -> dict:
|
def _train_rf(self, X: np.ndarray, y: np.ndarray) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Train the random forest classifier
|
Train the random forest classifier
|
||||||
"""
|
"""
|
||||||
|
|
@ -184,7 +197,7 @@ class TrainingOrchestrator:
|
||||||
)
|
)
|
||||||
return train_random_forest(X, y)
|
return train_random_forest(X, y)
|
||||||
|
|
||||||
def _train_if(self, X_normal: np.ndarray) -> dict:
|
def _train_if(self, X_normal: np.ndarray) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Train the isolation forest on normal-only data
|
Train the isolation forest on normal-only data
|
||||||
"""
|
"""
|
||||||
|
|
@ -196,9 +209,9 @@ class TrainingOrchestrator:
|
||||||
|
|
||||||
def _export_models(
|
def _export_models(
|
||||||
self,
|
self,
|
||||||
ae_result: dict,
|
ae_result: dict[str, Any],
|
||||||
rf_result: dict,
|
rf_result: dict[str, Any],
|
||||||
if_result: dict,
|
if_result: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Export all 3 models to ONNX and save scaler/threshold
|
Export all 3 models to ONNX and save scaler/threshold
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ class FeatureScaler:
|
||||||
"""
|
"""
|
||||||
if not self._fitted or self._scaler is None:
|
if not self._fitted or self._scaler is None:
|
||||||
raise RuntimeError("Scaler has not been fitted")
|
raise RuntimeError("Scaler has not been fitted")
|
||||||
return self._scaler.transform(X).astype(np.float32)
|
return self._scaler.transform(X).astype(np.float32) # type: ignore[no-any-return]
|
||||||
|
|
||||||
def inverse_transform(self, X: np.ndarray) -> np.ndarray:
|
def inverse_transform(self, X: np.ndarray) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
|
|
@ -54,7 +54,7 @@ class FeatureScaler:
|
||||||
"""
|
"""
|
||||||
if not self._fitted or self._scaler is None:
|
if not self._fitted or self._scaler is None:
|
||||||
raise RuntimeError("Scaler has not been fitted")
|
raise RuntimeError("Scaler has not been fitted")
|
||||||
return self._scaler.inverse_transform(X).astype(np.float32)
|
return self._scaler.inverse_transform(X).astype(np.float32) # type: ignore[no-any-return]
|
||||||
|
|
||||||
def fit_transform(self, X: np.ndarray) -> np.ndarray:
|
def fit_transform(self, X: np.ndarray) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
|
|
@ -74,14 +74,14 @@ class FeatureScaler:
|
||||||
"scale": self._scaler.scale_.tolist(),
|
"scale": self._scaler.scale_.tolist(),
|
||||||
"n_features": int(self._scaler.n_features_in_),
|
"n_features": int(self._scaler.n_features_in_),
|
||||||
}
|
}
|
||||||
Path(path).write_text(json.dumps(data, indent=2))
|
Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load_json(cls, path: Path | str) -> FeatureScaler:
|
def load_json(cls, path: Path | str) -> FeatureScaler:
|
||||||
"""
|
"""
|
||||||
Reconstruct a fitted scaler from a JSON file.
|
Reconstruct a fitted scaler from a JSON file.
|
||||||
"""
|
"""
|
||||||
data = json.loads(Path(path).read_text())
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
scaler = cls()
|
scaler = cls()
|
||||||
scaler._scaler = RobustScaler()
|
scaler._scaler = RobustScaler()
|
||||||
scaler._scaler.center_ = np.array(data["center"], dtype=np.float64)
|
scaler._scaler.center_ = np.array(data["center"], dtype=np.float64)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import numpy as np
|
||||||
|
|
||||||
from app.core.features.encoder import encode_for_inference
|
from app.core.features.encoder import encode_for_inference
|
||||||
from app.core.features.extractor import extract_request_features
|
from app.core.features.extractor import extract_request_features
|
||||||
|
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
|
||||||
from app.core.ingestion.parsers import ParsedLogEntry
|
from app.core.ingestion.parsers import ParsedLogEntry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -189,21 +190,6 @@ ATTACK_PATHS: list[str] = [
|
||||||
"/debug",
|
"/debug",
|
||||||
]
|
]
|
||||||
|
|
||||||
_WINDOWED_FEATURE_NAMES: list[str] = [
|
|
||||||
"req_count_1m",
|
|
||||||
"req_count_5m",
|
|
||||||
"req_count_10m",
|
|
||||||
"error_rate_5m",
|
|
||||||
"unique_paths_5m",
|
|
||||||
"unique_uas_10m",
|
|
||||||
"method_entropy_5m",
|
|
||||||
"avg_response_size_5m",
|
|
||||||
"status_diversity_5m",
|
|
||||||
"path_depth_variance_5m",
|
|
||||||
"inter_request_time_mean",
|
|
||||||
"inter_request_time_std",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _random_ip() -> str:
|
def _random_ip() -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -212,13 +198,6 @@ def _random_ip() -> str:
|
||||||
return f"{random.randint(1, 223)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}"
|
return f"{random.randint(1, 223)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}"
|
||||||
|
|
||||||
|
|
||||||
def _random_private_ip() -> str:
|
|
||||||
"""
|
|
||||||
Generate a random private IP address
|
|
||||||
"""
|
|
||||||
return f"10.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _random_timestamp() -> datetime:
|
def _random_timestamp() -> datetime:
|
||||||
"""
|
"""
|
||||||
Generate a random timestamp within the last 24 hours
|
Generate a random timestamp within the last 24 hours
|
||||||
|
|
@ -270,7 +249,6 @@ def generate_sqli_requests(n: int, ) -> list[ParsedLogEntry]:
|
||||||
status_code=random.choice([200, 500]),
|
status_code=random.choice([200, 500]),
|
||||||
response_size=random.randint(0, 5000),
|
response_size=random.randint(0, 5000),
|
||||||
user_agent=random.choice(NORMAL_UAS),
|
user_agent=random.choice(NORMAL_UAS),
|
||||||
ip=_random_private_ip(),
|
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
entries.append(
|
entries.append(
|
||||||
|
|
@ -281,7 +259,6 @@ def generate_sqli_requests(n: int, ) -> list[ParsedLogEntry]:
|
||||||
status_code=random.choice([200, 403]),
|
status_code=random.choice([200, 403]),
|
||||||
response_size=random.randint(0, 2000),
|
response_size=random.randint(0, 2000),
|
||||||
user_agent=random.choice(NORMAL_UAS),
|
user_agent=random.choice(NORMAL_UAS),
|
||||||
ip=_random_private_ip(),
|
|
||||||
))
|
))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
@ -302,7 +279,6 @@ def generate_xss_requests(n: int, ) -> list[ParsedLogEntry]:
|
||||||
status_code=200,
|
status_code=200,
|
||||||
response_size=random.randint(500, 5000),
|
response_size=random.randint(500, 5000),
|
||||||
user_agent=random.choice(NORMAL_UAS),
|
user_agent=random.choice(NORMAL_UAS),
|
||||||
ip=_random_private_ip(),
|
|
||||||
))
|
))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
@ -322,7 +298,6 @@ def generate_traversal_requests(n: int, ) -> list[ParsedLogEntry]:
|
||||||
status_code=random.choice([200, 403, 404]),
|
status_code=random.choice([200, 403, 404]),
|
||||||
response_size=random.randint(0, 1000),
|
response_size=random.randint(0, 1000),
|
||||||
user_agent=random.choice(NORMAL_UAS),
|
user_agent=random.choice(NORMAL_UAS),
|
||||||
ip=_random_private_ip(),
|
|
||||||
))
|
))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
@ -343,7 +318,6 @@ def generate_log4shell_requests(n: int, ) -> list[ParsedLogEntry]:
|
||||||
status_code=200,
|
status_code=200,
|
||||||
response_size=random.randint(0, 2000),
|
response_size=random.randint(0, 2000),
|
||||||
user_agent=payload,
|
user_agent=payload,
|
||||||
ip=_random_private_ip(),
|
|
||||||
))
|
))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
@ -364,7 +338,6 @@ def generate_ssrf_requests(n: int, ) -> list[ParsedLogEntry]:
|
||||||
status_code=random.choice([200, 302]),
|
status_code=random.choice([200, 302]),
|
||||||
response_size=random.randint(0, 3000),
|
response_size=random.randint(0, 3000),
|
||||||
user_agent=random.choice(NORMAL_UAS),
|
user_agent=random.choice(NORMAL_UAS),
|
||||||
ip=_random_private_ip(),
|
|
||||||
))
|
))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
@ -384,7 +357,6 @@ def generate_scanner_requests(n: int, ) -> list[ParsedLogEntry]:
|
||||||
status_code=random.choice([200, 301, 403, 404]),
|
status_code=random.choice([200, 301, 403, 404]),
|
||||||
response_size=random.randint(0, 500),
|
response_size=random.randint(0, 500),
|
||||||
user_agent=random.choice(SCANNER_UAS),
|
user_agent=random.choice(SCANNER_UAS),
|
||||||
ip=_random_private_ip(),
|
|
||||||
))
|
))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
@ -417,7 +389,7 @@ def _entries_to_vectors(entries: list[ParsedLogEntry], ) -> list[list[float]]:
|
||||||
vectors: list[list[float]] = []
|
vectors: list[list[float]] = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
features = extract_request_features(entry)
|
features = extract_request_features(entry)
|
||||||
for name in _WINDOWED_FEATURE_NAMES:
|
for name in WINDOWED_FEATURE_NAMES:
|
||||||
features[name] = 0.0
|
features[name] = 0.0
|
||||||
vectors.append(encode_for_inference(features))
|
vectors.append(encode_for_inference(features))
|
||||||
return vectors
|
return vectors
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ def train_autoencoder(
|
||||||
reconstructed = model(batch)
|
reconstructed = model(batch)
|
||||||
loss = torch.nn.functional.mse_loss(reconstructed, batch)
|
loss = torch.nn.functional.mse_loss(reconstructed, batch)
|
||||||
optimizer.zero_grad()
|
optimizer.zero_grad()
|
||||||
loss.backward()
|
loss.backward() # type: ignore[no-untyped-call]
|
||||||
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
||||||
optimizer.step()
|
optimizer.step()
|
||||||
epoch_loss += loss.item()
|
epoch_loss += loss.item()
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ ignore = [
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.14"
|
python_version = "3.14"
|
||||||
strict = true
|
strict = true
|
||||||
|
exclude = ["^alembic/"]
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
disallow_untyped_defs = true
|
disallow_untyped_defs = true
|
||||||
|
|
@ -151,6 +152,14 @@ ignore_missing_imports = true
|
||||||
module = "app.models.*"
|
module = "app.models.*"
|
||||||
disable_error_code = ["call-arg", "misc"]
|
disable_error_code = ["call-arg", "misc"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "tests.*"
|
||||||
|
ignore_errors = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "alembic.*"
|
||||||
|
ignore_errors = true
|
||||||
|
|
||||||
[tool.pylint.main]
|
[tool.pylint.main]
|
||||||
py-version = "3.12"
|
py-version = "3.12"
|
||||||
jobs = 4
|
jobs = 4
|
||||||
|
|
@ -175,6 +184,7 @@ ignore-paths = [
|
||||||
"^.venv/.*",
|
"^.venv/.*",
|
||||||
"^build/.*",
|
"^build/.*",
|
||||||
"^dist/.*",
|
"^dist/.*",
|
||||||
|
"^tests/.*",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pylint.messages_control]
|
[tool.pylint.messages_control]
|
||||||
|
|
@ -201,13 +211,18 @@ disable = [
|
||||||
"C0415",
|
"C0415",
|
||||||
"E1102",
|
"E1102",
|
||||||
"R1732",
|
"R1732",
|
||||||
|
"C0121",
|
||||||
|
"E0601",
|
||||||
|
"E0602",
|
||||||
|
"R0902",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pylint.design]
|
[tool.pylint.design]
|
||||||
max-args = 12
|
max-args = 12
|
||||||
max-attributes = 10
|
max-attributes = 12
|
||||||
max-locals = 35
|
max-locals = 35
|
||||||
max-positional-arguments = 12
|
max-positional-arguments = 12
|
||||||
|
max-statements = 60
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ test_cli.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
|
@ -105,3 +106,38 @@ class TestCLICommands:
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLITrainMetadata:
|
||||||
|
"""
|
||||||
|
Test metadata persistence wiring in the train command
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_train_warns_when_db_unavailable(self) -> None:
|
||||||
|
"""
|
||||||
|
train exits 0 and emits a warning when DB write fails
|
||||||
|
"""
|
||||||
|
mock_result = mock.MagicMock()
|
||||||
|
mock_result.ensemble_metrics = None
|
||||||
|
mock_result.passed_gates = True
|
||||||
|
mock_result.mlflow_run_id = None
|
||||||
|
mock_result.ae_metrics = {}
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"ml.orchestrator.TrainingOrchestrator"
|
||||||
|
) as mock_class:
|
||||||
|
mock_class.return_value.run.return_value = mock_result
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"train",
|
||||||
|
"--synthetic-normal",
|
||||||
|
"5",
|
||||||
|
"--synthetic-attack",
|
||||||
|
"3",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
output = _clean(result.output)
|
||||||
|
assert "warning" in output or "metadata" in output
|
||||||
|
|
|
||||||
|
|
@ -192,3 +192,35 @@ class TestSaveModelMetadata:
|
||||||
|
|
||||||
assert len(active_rows) == 3
|
assert len(active_rows) == 3
|
||||||
assert all(r.training_samples == 600 for r in active_rows)
|
assert all(r.training_samples == 600 for r in active_rows)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_previous_inactive_rows_preserved(
|
||||||
|
self,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
model_artifacts: Path,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Old model rows are deactivated, not deleted, after a new save
|
||||||
|
"""
|
||||||
|
await save_model_metadata(
|
||||||
|
db_session,
|
||||||
|
model_dir=model_artifacts,
|
||||||
|
training_samples=500,
|
||||||
|
metrics={"f1": 0.9},
|
||||||
|
)
|
||||||
|
|
||||||
|
(model_artifacts / "ae.onnx").write_bytes(b"new-ae-data")
|
||||||
|
await save_model_metadata(
|
||||||
|
db_session,
|
||||||
|
model_dir=model_artifacts,
|
||||||
|
training_samples=600,
|
||||||
|
metrics={"f1": 0.95},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db_session.execute(select(ModelMetadata))
|
||||||
|
all_rows = result.scalars().all()
|
||||||
|
inactive = [r for r in all_rows if not r.is_active]
|
||||||
|
|
||||||
|
assert len(all_rows) == 6
|
||||||
|
assert len(inactive) == 3
|
||||||
|
assert all(r.training_samples == 500 for r in inactive)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import numpy as np
|
||||||
|
|
||||||
from app.core.features.encoder import encode_for_inference
|
from app.core.features.encoder import encode_for_inference
|
||||||
from app.core.features.extractor import extract_request_features
|
from app.core.features.extractor import extract_request_features
|
||||||
|
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
|
||||||
from app.core.ingestion.parsers import ParsedLogEntry
|
from app.core.ingestion.parsers import ParsedLogEntry
|
||||||
from ml.synthetic import (
|
from ml.synthetic import (
|
||||||
generate_log4shell_requests,
|
generate_log4shell_requests,
|
||||||
|
|
@ -19,22 +20,6 @@ from ml.synthetic import (
|
||||||
generate_xss_requests,
|
generate_xss_requests,
|
||||||
)
|
)
|
||||||
|
|
||||||
_WINDOWED_FEATURE_NAMES: list[str] = [
|
|
||||||
"req_count_1m",
|
|
||||||
"req_count_5m",
|
|
||||||
"req_count_10m",
|
|
||||||
"error_rate_5m",
|
|
||||||
"unique_paths_5m",
|
|
||||||
"unique_uas_10m",
|
|
||||||
"method_entropy_5m",
|
|
||||||
"avg_response_size_5m",
|
|
||||||
"status_diversity_5m",
|
|
||||||
"path_depth_variance_5m",
|
|
||||||
"inter_request_time_mean",
|
|
||||||
"inter_request_time_std",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerators:
|
class TestGenerators:
|
||||||
"""
|
"""
|
||||||
Test individual attack and normal traffic generators
|
Test individual attack and normal traffic generators
|
||||||
|
|
@ -152,7 +137,7 @@ class TestGenerators:
|
||||||
for gen in generators:
|
for gen in generators:
|
||||||
for entry in gen(5):
|
for entry in gen(5):
|
||||||
features = extract_request_features(entry)
|
features = extract_request_features(entry)
|
||||||
for name in _WINDOWED_FEATURE_NAMES:
|
for name in WINDOWED_FEATURE_NAMES:
|
||||||
features[name] = 0.0
|
features[name] = 0.0
|
||||||
vector = encode_for_inference(features)
|
vector = encode_for_inference(features)
|
||||||
assert len(vector) == 35
|
assert len(vector) == 35
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
app.py
|
app.py
|
||||||
|
|
||||||
|
Example FastAPI app demonstrating all three rate limiting patterns
|
||||||
|
|
||||||
|
Shows how to wire up fastapi-420 in a real application. Uses
|
||||||
|
ScopedRateLimiter for auth endpoints with strict brute-force
|
||||||
|
protection, the @limiter.limit() decorator for public endpoints,
|
||||||
|
and RateLimitDep dependency injection for one-off limits. Includes
|
||||||
|
lifespan setup with init()/close() and Redis storage configuration.
|
||||||
|
Defines 12 routes across auth, public, and user endpoint groups.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
__init__.py - imports RateLimiter, settings, ScopedRateLimiter
|
||||||
|
types.py - imports Algorithm, FingerprintLevel
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,23 @@
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
__init__.py
|
__init__.py
|
||||||
|
|
||||||
|
Public API surface for fastapi-420
|
||||||
|
|
||||||
|
Re-exports all user-facing classes, functions, and types into
|
||||||
|
a single top-level namespace so consumers can write
|
||||||
|
"from fastapi_420 import RateLimiter" instead of reaching into
|
||||||
|
submodules. The __all__ list defines the 28 public names that
|
||||||
|
make up the library's API.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
config.py - re-exports settings classes and get_settings()
|
||||||
|
defense/__init__.py - re-exports CircuitBreaker, LayeredDefense
|
||||||
|
dependencies.py - re-exports all DI integration
|
||||||
|
exceptions.py - re-exports all exception classes
|
||||||
|
limiter.py - re-exports RateLimiter
|
||||||
|
middleware.py - re-exports both middleware classes
|
||||||
|
types.py - re-exports all enums and data types
|
||||||
|
|
||||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣟⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣟⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
⠀⠀⠀⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
__init__.py
|
__init__.py
|
||||||
|
|
||||||
|
Algorithm subpackage with factory function
|
||||||
|
|
||||||
|
Provides create_algorithm() which maps an Algorithm enum value
|
||||||
|
to the corresponding implementation class. Re-exports all three
|
||||||
|
algorithm classes for direct import.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
create_algorithm() - factory that builds algorithm instances
|
||||||
|
FixedWindowAlgorithm, SlidingWindowAlgorithm, TokenBucketAlgorithm
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
base.py - re-exports BaseAlgorithm
|
||||||
|
fixed_window.py - re-exports FixedWindowAlgorithm
|
||||||
|
sliding_window.py - re-exports SlidingWindowAlgorithm
|
||||||
|
token_bucket.py - re-exports TokenBucketAlgorithm
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi_420.algorithms.base import BaseAlgorithm
|
from fastapi_420.algorithms.base import BaseAlgorithm
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
base.py
|
base.py
|
||||||
|
|
||||||
|
Abstract base class for rate limiting algorithms
|
||||||
|
|
||||||
|
Defines the interface that all algorithm implementations must
|
||||||
|
follow: a name property, a check() method that decides whether
|
||||||
|
a request is allowed, and a get_current_usage() method for
|
||||||
|
reporting. Each algorithm receives a storage backend and operates
|
||||||
|
against it, but the base class doesn't care which backend it is.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
BaseAlgorithm - ABC with name, check(), get_current_usage()
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
fixed_window.py - subclasses BaseAlgorithm
|
||||||
|
sliding_window.py - subclasses BaseAlgorithm
|
||||||
|
token_bucket.py - subclasses BaseAlgorithm
|
||||||
"""
|
"""
|
||||||
# pylint: disable=unnecessary-ellipsis
|
# pylint: disable=unnecessary-ellipsis
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
fixed_window.py
|
fixed_window.py
|
||||||
|
|
||||||
|
Fixed window counter rate limiting algorithm
|
||||||
|
|
||||||
|
The simplest algorithm. Divides time into fixed-size windows and
|
||||||
|
counts requests in each. Has the well-known boundary burst problem
|
||||||
|
where a client can make 2x the limit by timing requests at the
|
||||||
|
edge of two adjacent windows. Includes a special codepath for
|
||||||
|
Redis that uses the atomic increment_fixed_window() Lua script
|
||||||
|
instead of the generic sliding window storage methods.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
base.py - extends BaseAlgorithm
|
||||||
|
redis_backend.py - checks isinstance for Redis-specific path
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
sliding_window.py
|
sliding_window.py
|
||||||
|
|
||||||
|
Sliding window counter rate limiting algorithm
|
||||||
|
|
||||||
|
The recommended default for production. Uses weighted interpolation
|
||||||
|
between two adjacent fixed windows to approximate a true sliding
|
||||||
|
window with ~99.997% accuracy. Gets O(1) memory per client (just
|
||||||
|
two counters) compared to O(n) for a sorted-set sliding log.
|
||||||
|
Eliminates the boundary burst problem that fixed windows have.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
base.py - extends BaseAlgorithm
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
token_bucket.py
|
token_bucket.py
|
||||||
|
|
||||||
|
Token bucket rate limiting algorithm
|
||||||
|
|
||||||
|
Allows controlled bursting up to bucket capacity while enforcing
|
||||||
|
an average rate over time. Tokens refill at a constant rate, and
|
||||||
|
each request consumes one token. Good for APIs where occasional
|
||||||
|
traffic spikes are acceptable as long as the average stays within
|
||||||
|
bounds. The bucket capacity sets the maximum burst size.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
base.py - extends BaseAlgorithm
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
config.py
|
config.py
|
||||||
|
|
||||||
|
Pydantic-settings configuration with RATELIMIT_ env prefix
|
||||||
|
|
||||||
|
All rate limiter settings are validated here and loaded from
|
||||||
|
environment variables. The top-level RateLimiterSettings nests
|
||||||
|
three sub-configs (storage, fingerprint, defense) and includes
|
||||||
|
model validators that resolve shorthand like algorithm names
|
||||||
|
and rule strings into their typed equivalents.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
StorageSettings - Redis URL, max keys, backend selection
|
||||||
|
FingerprintSettings - extractor toggles and fingerprint level
|
||||||
|
RateLimiterSettings - main config that composes all sub-configs
|
||||||
|
get_settings() - cached singleton factory
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
types.py - imports Algorithm, DefenseMode, FingerprintLevel, RateLimitRule
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
__init__.py
|
__init__.py
|
||||||
|
|
||||||
|
Defense subpackage re-exports
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
circuit_breaker.py - re-exports CircuitBreaker
|
||||||
|
layers.py - re-exports LayeredDefense, LayerResult
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi_420.defense.circuit_breaker import CircuitBreaker
|
from fastapi_420.defense.circuit_breaker import CircuitBreaker
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
circuit_breaker.py
|
circuit_breaker.py
|
||||||
|
|
||||||
|
Global circuit breaker for API-wide DDoS protection
|
||||||
|
|
||||||
|
Tracks total request volume across all clients. When the count
|
||||||
|
exceeds a threshold within a time window, the circuit opens and
|
||||||
|
blocks all incoming requests until a recovery period passes.
|
||||||
|
After recovery, enters a half-open state that lets a limited
|
||||||
|
number of requests through to test if conditions have improved.
|
||||||
|
If those succeed, the circuit closes again and normal traffic
|
||||||
|
resumes.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
CircuitBreaker - tracks state with check(), record_request(),
|
||||||
|
reset(), and exposes is_open and current_state
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
types.py - imports CircuitState, DefenseMode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,26 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
layers.py
|
layers.py
|
||||||
|
|
||||||
|
Three-layer defense system for graduated rate limiting
|
||||||
|
|
||||||
|
Layer 1 is per-user per-endpoint (individual abuse). Layer 2 is
|
||||||
|
per-endpoint global (endpoint-level flood). Layer 3 is the global
|
||||||
|
circuit breaker (full API DDoS). Each layer checks independently
|
||||||
|
and the first rejection wins. Defense modes control bypass logic:
|
||||||
|
adaptive mode lets authenticated users through when limits hit,
|
||||||
|
lockdown mode restricts to known-good and authenticated clients
|
||||||
|
only.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
LayerResult - result from a single defense layer check
|
||||||
|
LayeredDefense - orchestrates all three layers via
|
||||||
|
check_all_layers()
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
circuit_breaker.py - uses CircuitBreaker for layer 3
|
||||||
|
exceptions.py - raises EnhanceYourCalm on rejection
|
||||||
|
types.py - imports DefenseContext, DefenseMode, Layer, others
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,27 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
dependencies.py
|
dependencies.py
|
||||||
|
|
||||||
|
FastAPI dependency injection integration for rate limiting
|
||||||
|
|
||||||
|
Provides three patterns for wiring rate limits into FastAPI routes.
|
||||||
|
RateLimitDep is a callable class you pass to Depends() for
|
||||||
|
per-route limits. create_rate_limit_dep() is a factory that
|
||||||
|
builds those callables. ScopedRateLimiter groups endpoints by
|
||||||
|
prefix (like "/auth") with shared limits and burst overrides.
|
||||||
|
Also provides a global limiter singleton via set_global_limiter()
|
||||||
|
and get_limiter().
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
RateLimitDep - callable dependency for per-route limits
|
||||||
|
create_rate_limit_dep() - factory for RateLimitDep instances
|
||||||
|
ScopedRateLimiter - per-prefix endpoint group limiter
|
||||||
|
set_global_limiter() / get_limiter() - singleton management
|
||||||
|
require_rate_limit() - simple dependency using defaults
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
limiter.py - imports RateLimiter
|
||||||
|
types.py - imports RateLimitResult, RateLimitRule
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,26 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
exceptions.py
|
exceptions.py
|
||||||
|
|
||||||
|
Exception hierarchy for rate limiting failures
|
||||||
|
|
||||||
|
The signature exception is EnhanceYourCalm, which returns HTTP 420
|
||||||
|
("Enhance Your Calm", from the Twitter API) when a client exceeds
|
||||||
|
their limit. Below that, domain-specific errors cover storage
|
||||||
|
failures, fingerprint extraction problems, circuit breaker trips,
|
||||||
|
and configuration mistakes. Each carries context about which layer
|
||||||
|
or storage backend triggered it.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
HTTP_420_ENHANCE_YOUR_CALM - status code constant (420)
|
||||||
|
EnhanceYourCalm - the HTTP 420 response exception
|
||||||
|
RateLimitExceeded - internal limit exceeded (pre-HTTP)
|
||||||
|
StorageError, StorageConnectionError - backend failures
|
||||||
|
CircuitBreakerOpen - global circuit breaker tripped
|
||||||
|
ConfigurationError, InvalidRuleError - bad config/rules
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
types.py - imports Layer, StorageType for error context
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
__init__.py
|
__init__.py
|
||||||
|
|
||||||
|
Fingerprinting subpackage re-exports
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
ip.py - re-exports IPExtractor
|
||||||
|
headers.py - re-exports HeadersExtractor
|
||||||
|
auth.py - re-exports AuthExtractor
|
||||||
|
composite.py - re-exports CompositeFingerprinter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi_420.fingerprinting.auth import AuthExtractor
|
from fastapi_420.fingerprinting.auth import AuthExtractor
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
auth.py
|
auth.py
|
||||||
|
|
||||||
|
Authentication identifier extraction from requests
|
||||||
|
|
||||||
|
Pulls client identity from auth mechanisms in priority order:
|
||||||
|
JWT Bearer tokens (with optional signature verification), API
|
||||||
|
keys (from header or query param), and session cookies. When a
|
||||||
|
token is found, it can be SHA256-hashed for privacy so the
|
||||||
|
rate limiter tracks identity without storing raw credentials.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
AuthExtractor - extracts auth identifiers with extract()
|
||||||
|
and checks authentication status via is_authenticated()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,25 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
composite.py
|
composite.py
|
||||||
|
|
||||||
|
Combines IP, header, and auth extractors into one fingerprint
|
||||||
|
|
||||||
|
The CompositeFingerprinter runs whichever extractors are enabled
|
||||||
|
for the configured fingerprint level (STRICT uses all three,
|
||||||
|
NORMAL skips headers, RELAXED uses IP only, CUSTOM lets you pick).
|
||||||
|
Also pulls TLS/JA3 fingerprint and geo ASN data from proxy headers
|
||||||
|
when available. The output FingerprintData produces a composite
|
||||||
|
key used as the rate limit bucket identifier.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
CompositeFingerprinter - orchestrates all extractors, built
|
||||||
|
from settings via from_settings() classmethod
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
ip.py - uses IPExtractor for IP extraction
|
||||||
|
headers.py - uses HeadersExtractor for header fingerprinting
|
||||||
|
auth.py - uses AuthExtractor for auth identity
|
||||||
|
config.py - reads FingerprintSettings (TYPE_CHECKING)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
headers.py
|
headers.py
|
||||||
|
|
||||||
|
HTTP header fingerprinting for client identification
|
||||||
|
|
||||||
|
Extracts browser-identifying headers (user-agent, accept-language,
|
||||||
|
accept-encoding) and computes a SHA256 hash across a set of
|
||||||
|
fingerprint-relevant headers. Optionally includes header ordering
|
||||||
|
in the hash, which is browser-specific and difficult to spoof
|
||||||
|
since different HTTP implementations send headers in different
|
||||||
|
orders.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
HeadersExtractor - extracts individual headers and computes
|
||||||
|
a composite fingerprint hash via extract_all()
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
ip.py
|
ip.py
|
||||||
|
|
||||||
|
Client IP extraction and normalization from HTTP requests
|
||||||
|
|
||||||
|
Handles the tricky parts of identifying clients by IP. IPv6
|
||||||
|
addresses get normalized to /64 prefixes because end users
|
||||||
|
typically control an entire /64 block and can rotate within it.
|
||||||
|
IPv4-mapped IPv6 addresses get unwrapped to plain IPv4.
|
||||||
|
For proxied requests, parses X-Forwarded-For using the
|
||||||
|
rightmost-trusted approach (trusting the entry closest to
|
||||||
|
your infrastructure, not the client-supplied leftmost one).
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
IPExtractor - extracts and normalizes client IPs with
|
||||||
|
extract(), is_ipv6(), and is_private()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,27 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
limiter.py
|
limiter.py
|
||||||
|
|
||||||
|
Main RateLimiter class that orchestrates the library
|
||||||
|
|
||||||
|
This is the central entry point. RateLimiter wires together the
|
||||||
|
storage backend, algorithm, and fingerprinter on init(), then
|
||||||
|
exposes two ways to enforce limits: a limit() decorator for
|
||||||
|
routes and a check() method for manual use. Handles fail-open
|
||||||
|
logic so a storage outage degrades to allowing requests rather
|
||||||
|
than crashing the API. Builds composite rate limit keys from
|
||||||
|
the client fingerprint, endpoint path, and layer.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
RateLimiter - main class with init(), close(), limit(),
|
||||||
|
check(), and settings/is_initialized properties
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
config.py - reads RateLimiterSettings via get_settings()
|
||||||
|
exceptions.py - raises EnhanceYourCalm, catches StorageError
|
||||||
|
algorithms/__init__.py - calls create_algorithm()
|
||||||
|
fingerprinting/__init__.py - uses CompositeFingerprinter
|
||||||
|
storage/__init__.py - calls create_storage(), uses MemoryStorage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
middleware.py
|
middleware.py
|
||||||
|
|
||||||
|
ASGI middleware for automatic rate limiting across all routes
|
||||||
|
|
||||||
|
Two middleware classes for different throttling strategies.
|
||||||
|
RateLimitMiddleware applies hard limits and returns HTTP 420
|
||||||
|
when exceeded, with support for path inclusion/exclusion lists
|
||||||
|
and path-specific limit overrides. SlowDownMiddleware takes a
|
||||||
|
softer approach, adding progressive delays to responses as
|
||||||
|
clients approach their limit instead of blocking them outright.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
RateLimitMiddleware - hard blocking with HTTP 420 responses
|
||||||
|
SlowDownMiddleware - gradual throttling via response delays
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
exceptions.py - uses HTTP_420_ENHANCE_YOUR_CALM, EnhanceYourCalm
|
||||||
|
limiter.py - imports RateLimiter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
__init__.py
|
__init__.py
|
||||||
|
|
||||||
|
Storage subpackage with factory and union type
|
||||||
|
|
||||||
|
Defines the Storage type alias (MemoryStorage | RedisStorage)
|
||||||
|
used throughout the library for type annotations. Provides
|
||||||
|
create_storage() which builds the right backend from settings.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
Storage - TypeAlias for MemoryStorage | RedisStorage
|
||||||
|
create_storage() - factory that builds a storage backend
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
memory.py - re-exports MemoryStorage
|
||||||
|
redis_backend.py - re-exports RedisStorage
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
memory.py
|
memory.py
|
||||||
|
|
||||||
|
In-memory storage backend using OrderedDict for LRU eviction
|
||||||
|
|
||||||
|
Stores rate limit state in process memory, protected by an
|
||||||
|
asyncio.Lock for concurrency safety. Implements sliding window
|
||||||
|
tracking with dual-window weighted interpolation, and token
|
||||||
|
bucket with refill-on-access. Runs a background cleanup task
|
||||||
|
that periodically sweeps expired entries. When max keys is
|
||||||
|
reached, the least recently used entries get evicted first.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
MemoryStorage - full storage backend with from_settings(),
|
||||||
|
increment(), consume_token(), close(), health_check()
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
types.py - imports WindowState, TokenBucketState, StorageType
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
redis_backend.py
|
redis_backend.py
|
||||||
|
|
||||||
|
Redis storage backend using atomic Lua scripts
|
||||||
|
|
||||||
|
All rate limit operations (sliding window increment, fixed window
|
||||||
|
increment, token bucket consume) run as Lua scripts inside Redis
|
||||||
|
for race-condition-free atomic execution. Scripts are loaded from
|
||||||
|
disk on first use, and their SHA1 hashes are cached for EVALSHA
|
||||||
|
calls. If Redis flushes its script cache, NOSCRIPT errors trigger
|
||||||
|
automatic reload and retry.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
RedisStorage - Redis-backed storage with from_settings(),
|
||||||
|
connect(), increment(), increment_fixed_window(),
|
||||||
|
consume_token(), close(), health_check()
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
exceptions.py - raises StorageConnectionError, StorageError
|
||||||
|
types.py - imports WindowState, TokenBucketState, StorageType
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
types.py
|
types.py
|
||||||
|
|
||||||
|
Core type definitions for the rate limiting library
|
||||||
|
|
||||||
|
Every enum, dataclass, and Protocol in the library lives here.
|
||||||
|
This is the foundation layer with zero internal dependencies,
|
||||||
|
so every other module can import from it without circular issues.
|
||||||
|
Defines the three algorithm choices, fingerprint levels, defense
|
||||||
|
modes, and the storage/algorithm/fingerprinter Protocols that
|
||||||
|
backends must satisfy.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
Algorithm, FingerprintLevel, DefenseMode - behavior enums
|
||||||
|
RateLimitResult - frozen result from a limit check
|
||||||
|
RateLimitRule - frozen rule with parse() for "100/minute" strings
|
||||||
|
FingerprintData - extracted client identity fields
|
||||||
|
StorageBackend, Fingerprinter, RateLimitAlgorithm - Protocols
|
||||||
"""
|
"""
|
||||||
# pylint: disable=unnecessary-ellipsis
|
# pylint: disable=unnecessary-ellipsis
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
conftest.py
|
conftest.py
|
||||||
|
|
||||||
|
Shared pytest fixtures and test factories for the test suite
|
||||||
|
|
||||||
|
Defines constants for test data (IPs, tokens, endpoints, window
|
||||||
|
sizes), nine factory classes for building mock objects
|
||||||
|
(RequestFactory, FingerprintFactory, RuleFactory, ResultFactory,
|
||||||
|
and others), pytest fixtures for every major component, and helper
|
||||||
|
functions for common assertions like checking rate limit headers
|
||||||
|
and exhausting a client's limit budget.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
Provides fixtures for all source modules including storage,
|
||||||
|
algorithms, fingerprinting, defense, limiter, and middleware
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
test_algorithms.py
|
test_algorithms.py
|
||||||
|
|
||||||
|
Tests for rate limiting algorithms and the factory function
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
create_algorithm() factory mapping
|
||||||
|
SlidingWindowAlgorithm - first request, limit enforcement, keys
|
||||||
|
TokenBucketAlgorithm - bursting, refill, capacity
|
||||||
|
FixedWindowAlgorithm - counting, boundary behavior
|
||||||
|
Cross-algorithm behavioral comparison
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
test_fingerprinting.py
|
test_fingerprinting.py
|
||||||
|
|
||||||
|
Tests for all fingerprinting components
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
IPExtractor - IPv4, IPv6 /64 normalization, mapped addresses,
|
||||||
|
X-Forwarded-For parsing, X-Real-IP, private detection
|
||||||
|
HeadersExtractor - individual headers, composite hash, ordering
|
||||||
|
AuthExtractor - JWT, API key, session cookie, priority chain
|
||||||
|
CompositeFingerprinter - all levels, composite key generation
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
test_integration.py
|
test_integration.py
|
||||||
|
|
||||||
|
End-to-end integration tests across all integration patterns
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
RateLimitMiddleware - path exclusion, limit enforcement, headers
|
||||||
|
@limiter.limit() decorator - basic flow and rejection
|
||||||
|
RateLimitDep dependency injection - per-route limits
|
||||||
|
ScopedRateLimiter - prefix matching and burst overrides
|
||||||
|
SlowDownMiddleware - progressive delay behavior
|
||||||
|
Concurrent requests and multi-client IP independence
|
||||||
|
Algorithm-specific integration (sliding, token, fixed)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
test_limiter.py
|
test_limiter.py
|
||||||
|
|
||||||
|
Tests for the RateLimiter class
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
Initialization (default, custom settings, provided storage)
|
||||||
|
check() method (first request, multiple rules, raise behavior,
|
||||||
|
endpoint/user independence, custom key_func, auto-init)
|
||||||
|
limit() decorator (basic, enforcement, multiple rules)
|
||||||
|
Fail-open behavior when storage is unavailable
|
||||||
|
Settings access and idempotent init/close
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
test_storage.py
|
test_storage.py
|
||||||
|
|
||||||
|
Tests for MemoryStorage and the create_storage() factory
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
Basic lifecycle (init, close, health check)
|
||||||
|
Sliding window increment and state retrieval
|
||||||
|
Token bucket consume, refill, and state
|
||||||
|
LRU eviction when max keys is reached
|
||||||
|
Background cleanup of expired entries
|
||||||
|
create_storage() factory function
|
||||||
|
Concurrent access safety under asyncio
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2025
|
ⒸAngelaMos | 2025
|
||||||
test_types.py
|
test_types.py
|
||||||
|
|
||||||
|
Tests for all type definitions in types.py
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
Enum values for Algorithm, FingerprintLevel, DefenseMode, others
|
||||||
|
RateLimitRule.parse() with all time units, whitespace, case
|
||||||
|
RateLimitResult header generation
|
||||||
|
FingerprintData composite key generation at all levels
|
||||||
|
WindowState weighted count math
|
||||||
|
TokenBucketState, CircuitState, DefenseContext, RateLimitKey
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -259,20 +259,20 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fakeredis"
|
name = "fakeredis"
|
||||||
version = "2.33.0"
|
version = "2.34.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "sortedcontainers" },
|
{ name = "sortedcontainers" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/11/40/fd09efa66205eb32253d2b2ebc63537281384d2040f0a88bcd2289e120e4/fakeredis-2.34.1.tar.gz", hash = "sha256:4ff55606982972eecce3ab410e03d746c11fe5deda6381d913641fbd8865ea9b", size = 177315, upload-time = "2026-02-25T13:17:51.315Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/b5/82f89307d0d769cd9bf46a54fb9136be08e4e57c5570ae421db4c9a2ba62/fakeredis-2.34.1-py3-none-any.whl", hash = "sha256:0107ec99d48913e7eec2a5e3e2403d1bd5f8aa6489d1a634571b975289c48f12", size = 122160, upload-time = "2026-02-25T13:17:49.701Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.128.7"
|
version = "0.134.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
|
|
@ -281,9 +281,9 @@ dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/96/15/647ea81cb73b55b48fb095158a9cd64e42e9e4f1d34dbb5cc4a4939779d6/fastapi-0.134.0.tar.gz", hash = "sha256:3122b1ea0dbeaab48b5976e80b99ca7eda02be154bf03e126a33220e73255a9a", size = 385667, upload-time = "2026-02-27T21:18:12.931Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/e6/fd49c28a54b7d6f5c64045155e40f6cff9ed4920055043fb5ac7969f7f2f/fastapi-0.134.0-py3-none-any.whl", hash = "sha256:f4e7214f24b2262258492e05c48cf21125e4ffc427e30dd32fb4f74049a3d56a", size = 110404, upload-time = "2026-02-27T21:18:10.809Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|
@ -331,13 +331,13 @@ dev = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.1.0" },
|
{ name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.1.0" },
|
||||||
{ name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.33.0" },
|
{ name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.34.0" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.128.7" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
|
||||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" },
|
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" },
|
||||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" },
|
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5,<3.0.0" },
|
{ name = "pydantic", specifier = ">=2.12.5,<3.0.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" },
|
{ name = "pydantic-settings", specifier = ">=2.13.0" },
|
||||||
{ name = "pyjwt", specifier = ">=2.11.0" },
|
{ name = "pyjwt", specifier = ">=2.11.0" },
|
||||||
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" },
|
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" },
|
||||||
{ name = "pylint-per-file-ignores", marker = "extra == 'dev'", specifier = ">=3.2.0" },
|
{ name = "pylint-per-file-ignores", marker = "extra == 'dev'", specifier = ">=3.2.0" },
|
||||||
|
|
@ -345,8 +345,8 @@ requires-dist = [
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" },
|
||||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||||
{ name = "redis", specifier = ">=7.1.1" },
|
{ name = "redis", specifier = ">=7.2.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" },
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.1" },
|
||||||
{ name = "time-machine", marker = "extra == 'dev'", specifier = ">=3.2.0" },
|
{ name = "time-machine", marker = "extra == 'dev'", specifier = ">=3.2.0" },
|
||||||
{ name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6.0.20241004" },
|
{ name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6.0.20241004" },
|
||||||
]
|
]
|
||||||
|
|
@ -802,16 +802,16 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-settings"
|
name = "pydantic-settings"
|
||||||
version = "2.12.0"
|
version = "2.13.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -975,11 +975,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "7.1.1"
|
version = "7.2.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1049,27 +1049,27 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.0"
|
version = "0.15.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
cli.py
|
cli.py
|
||||||
|
|
||||||
|
Typer CLI application with five encoding subcommands
|
||||||
|
|
||||||
|
Defines the b64tool Typer app and its five commands: encode, decode,
|
||||||
|
detect, peel, and chain. Each command resolves its input from a
|
||||||
|
positional argument, --file, or stdin, delegates to the appropriate
|
||||||
|
logic module, then passes results to formatter.py for display. The
|
||||||
|
chain command applies a comma-separated sequence of formats in order,
|
||||||
|
passing each encoded output as the next step's input.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
app - The Typer application instance registered as the CLI entry point
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
__init__.py - imports __version__
|
||||||
|
constants.py - imports EncodingFormat, ExitCode, PEEL_MAX_DEPTH
|
||||||
|
encoders.py - imports encode, decode, encode_url, decode_url
|
||||||
|
detector.py - imports detect_encoding, score_all_formats
|
||||||
|
peeler.py - imports peel
|
||||||
|
formatter.py - imports all print_* functions
|
||||||
|
utils.py - imports resolve_input_bytes, resolve_input_text
|
||||||
|
test_cli.py - exercises all commands via Typer's CliRunner
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
constants.py
|
constants.py
|
||||||
|
|
||||||
|
Encoding format definitions, scoring weights, and shared constants
|
||||||
|
|
||||||
|
Defines the EncodingFormat and ExitCode enums, numeric thresholds
|
||||||
|
used by the detector (confidence, printable ratio, min input length),
|
||||||
|
character set frozensets for charset membership tests, and the
|
||||||
|
ScoreWeight class that holds every per-format confidence score
|
||||||
|
contribution. All values shared across the package live here.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
EncodingFormat - StrEnum of supported formats (base64, base64url, base32, hex, url)
|
||||||
|
ExitCode - CLI exit codes for success, error, and invalid input
|
||||||
|
ScoreWeight - Per-format scoring weights used by detector.py
|
||||||
|
BASE64_CHARSET, BASE64URL_CHARSET, BASE32_CHARSET, HEX_CHARSET - Valid character sets
|
||||||
|
CONFIDENCE_THRESHOLD, PEEL_MAX_DEPTH, PREVIEW_LENGTH - Shared thresholds
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
encoders.py - imports EncodingFormat
|
||||||
|
detector.py - imports EncodingFormat, ScoreWeight, charsets, thresholds
|
||||||
|
peeler.py - imports EncodingFormat, PEEL_MAX_DEPTH, CONFIDENCE_THRESHOLD
|
||||||
|
formatter.py - imports EncodingFormat, CONFIDENCE_THRESHOLD, PREVIEW_LENGTH
|
||||||
|
cli.py - imports EncodingFormat, ExitCode, PEEL_MAX_DEPTH
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,30 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
detector.py
|
detector.py
|
||||||
|
|
||||||
|
Format detection via per-format confidence scoring
|
||||||
|
|
||||||
|
Runs each input string through five scoring functions (one per
|
||||||
|
supported format) that check charset membership, structural constraints
|
||||||
|
like padding alignment, and whether the decoded result is printable
|
||||||
|
text. Scores are clamped to [0.0, 1.0]. Results above
|
||||||
|
CONFIDENCE_THRESHOLD are returned as DetectionResult instances,
|
||||||
|
sorted by confidence descending.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
DetectionResult - Frozen dataclass with format, confidence, and decoded bytes
|
||||||
|
detect_encoding() - Returns all formats that exceed the confidence threshold
|
||||||
|
detect_best() - Returns the single highest-confidence result, or None
|
||||||
|
score_all_formats() - Returns raw confidence scores for every format
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
constants.py - imports charsets, thresholds, ScoreWeight, EncodingFormat
|
||||||
|
encoders.py - imports try_decode
|
||||||
|
utils.py - imports is_printable_text
|
||||||
|
peeler.py - imports detect_best, score_all_formats
|
||||||
|
formatter.py - imports DetectionResult
|
||||||
|
cli.py - imports detect_encoding, score_all_formats
|
||||||
|
test_detector.py - tests detection accuracy per format
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,29 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
encoders.py
|
encoders.py
|
||||||
|
|
||||||
|
Encode and decode functions for all five supported formats
|
||||||
|
|
||||||
|
Provides individual encode/decode functions for base64, base64url,
|
||||||
|
base32, hex, and URL percent-encoding, plus a dispatch registry
|
||||||
|
(ENCODER_REGISTRY) that maps each EncodingFormat to its function pair.
|
||||||
|
The top-level encode(), decode(), and try_decode() functions route
|
||||||
|
calls through the registry and handle all common decoding exceptions.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
encode() - Encode bytes to string for a given format
|
||||||
|
decode() - Decode string to bytes for a given format
|
||||||
|
try_decode() - Like decode() but returns None on failure instead of raising
|
||||||
|
ENCODER_REGISTRY - Dict mapping EncodingFormat to (encoder, decoder) function pairs
|
||||||
|
EncoderFn, DecoderFn - Type aliases for encoder and decoder callables
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
constants.py - imports EncodingFormat
|
||||||
|
detector.py - imports try_decode
|
||||||
|
cli.py - imports encode, decode, encode_url, decode_url
|
||||||
|
test_encoders.py - tests all functions directly
|
||||||
|
test_properties.py - property-based roundtrip tests
|
||||||
|
test_peeler.py - imports encode to build test inputs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64 as b64
|
import base64 as b64
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,30 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
formatter.py
|
formatter.py
|
||||||
|
|
||||||
|
Rich terminal output for all CLI commands
|
||||||
|
|
||||||
|
Handles all display logic for encoded strings, decoded bytes,
|
||||||
|
detection tables, peel layer summaries, and chain step results.
|
||||||
|
Detects whether stdout is a TTY or a pipe and switches between
|
||||||
|
Rich-formatted panels and raw text output. All Rich output goes
|
||||||
|
to stderr so piped stdout stays machine-readable.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
print_encoded() - Displays an encoded string in a Rich panel or raw to stdout
|
||||||
|
print_decoded() - Displays decoded bytes as text or hex fallback
|
||||||
|
print_detection() - Renders a confidence table for detect results
|
||||||
|
print_peel_result() - Renders each peel layer and a final output panel
|
||||||
|
print_chain_result() - Renders each encoding step and the final chain result
|
||||||
|
print_score_breakdown() - Renders per-format score table for verbose mode
|
||||||
|
is_piped() - Returns True when stdout is not a TTY
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
constants.py - imports CONFIDENCE_THRESHOLD, PREVIEW_LENGTH, EncodingFormat
|
||||||
|
detector.py - imports DetectionResult
|
||||||
|
peeler.py - imports PeelResult
|
||||||
|
utils.py - imports safe_bytes_preview
|
||||||
|
cli.py - imports all print_* functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,27 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
peeler.py
|
peeler.py
|
||||||
|
|
||||||
|
Recursive multi-layer encoding detection and decoding
|
||||||
|
|
||||||
|
Iteratively calls detect_best() on the current text, decodes one
|
||||||
|
layer at a time, and continues until no encoding is detected, the
|
||||||
|
decoded output is not valid UTF-8, or max_depth is reached. Each
|
||||||
|
iteration produces a PeelLayer record capturing the format, confidence,
|
||||||
|
and previews. The complete result is returned as an immutable PeelResult.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
PeelLayer - Frozen dataclass for a single decoded layer (depth, format, confidence, previews)
|
||||||
|
PeelResult - Frozen dataclass with all layers, final bytes output, and success flag
|
||||||
|
peel() - Main entry point for recursive decoding
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
constants.py - imports PEEL_MAX_DEPTH, CONFIDENCE_THRESHOLD, EncodingFormat
|
||||||
|
detector.py - imports detect_best, score_all_formats
|
||||||
|
utils.py - imports safe_bytes_preview, truncate
|
||||||
|
formatter.py - imports PeelResult
|
||||||
|
cli.py - imports peel
|
||||||
|
test_peeler.py - tests single and multi-layer peeling
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,27 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
utils.py
|
utils.py
|
||||||
|
|
||||||
|
Input resolution and string/bytes utility functions
|
||||||
|
|
||||||
|
Handles the three input sources the CLI accepts: a positional
|
||||||
|
argument, a --file path, or piped stdin. Also provides truncate()
|
||||||
|
for capping display strings, safe_bytes_preview() for converting
|
||||||
|
raw bytes to a readable preview, and is_printable_text() for
|
||||||
|
checking whether decoded bytes look like human-readable output.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
resolve_input_bytes() - Returns raw bytes from argument, file, or stdin
|
||||||
|
resolve_input_text() - Returns decoded text from argument, file, or stdin
|
||||||
|
truncate() - Truncates a string with "..." if it exceeds the length limit
|
||||||
|
safe_bytes_preview() - Converts bytes to UTF-8 string or hex fallback
|
||||||
|
is_printable_text() - Returns True if bytes decode to mostly printable characters
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
detector.py - imports is_printable_text
|
||||||
|
peeler.py - imports safe_bytes_preview, truncate
|
||||||
|
formatter.py - imports safe_bytes_preview
|
||||||
|
cli.py - imports resolve_input_bytes, resolve_input_text
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
test_cli.py
|
test_cli.py
|
||||||
|
|
||||||
|
Integration tests for all five CLI commands via Typer's CliRunner
|
||||||
|
|
||||||
|
Invokes each CLI command end-to-end without spawning subprocesses and
|
||||||
|
verifies exit codes and output content. Covers encode, decode, detect,
|
||||||
|
peel, and chain along with the --version flag and error paths (invalid
|
||||||
|
format, bad input).
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
TestEncodeCommand - base64, hex, base32, url, empty input
|
||||||
|
TestDecodeCommand - base64, hex, invalid input returns non-zero exit
|
||||||
|
TestDetectCommand - base64 detection, hex detection, no-match message
|
||||||
|
TestPeelCommand - single layer, plain text with no layers
|
||||||
|
TestChainCommand - single step, multiple steps, unknown format error
|
||||||
|
TestVersionFlag - version string present in output
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
cli.py - imports app (the Typer application under test)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
@ -13,11 +31,17 @@ runner = CliRunner()
|
||||||
|
|
||||||
class TestEncodeCommand:
|
class TestEncodeCommand:
|
||||||
def test_encode_base64(self) -> None:
|
def test_encode_base64(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the encode command outputs the correct base64 string
|
||||||
|
"""
|
||||||
result = runner.invoke(app, ["encode", "Hello World"])
|
result = runner.invoke(app, ["encode", "Hello World"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "SGVsbG8gV29ybGQ=" in result.output
|
assert "SGVsbG8gV29ybGQ=" in result.output
|
||||||
|
|
||||||
def test_encode_hex(self) -> None:
|
def test_encode_hex(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the encode command outputs the correct hex string with --format hex
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["encode",
|
["encode",
|
||||||
|
|
@ -29,6 +53,9 @@ class TestEncodeCommand:
|
||||||
assert "48656c6c6f" in result.output
|
assert "48656c6c6f" in result.output
|
||||||
|
|
||||||
def test_encode_base32(self) -> None:
|
def test_encode_base32(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the encode command outputs the correct base32 string with --format base32
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["encode",
|
["encode",
|
||||||
|
|
@ -40,6 +67,9 @@ class TestEncodeCommand:
|
||||||
assert "JBSWY3DP" in result.output
|
assert "JBSWY3DP" in result.output
|
||||||
|
|
||||||
def test_encode_url(self) -> None:
|
def test_encode_url(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the encode command percent-encodes special characters with --format url
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["encode",
|
["encode",
|
||||||
|
|
@ -51,12 +81,18 @@ class TestEncodeCommand:
|
||||||
assert "%20" in result.output or "hello" in result.output
|
assert "%20" in result.output or "hello" in result.output
|
||||||
|
|
||||||
def test_encode_empty_string(self) -> None:
|
def test_encode_empty_string(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that encoding an empty string succeeds without error
|
||||||
|
"""
|
||||||
result = runner.invoke(app, ["encode", ""])
|
result = runner.invoke(app, ["encode", ""])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
class TestDecodeCommand:
|
class TestDecodeCommand:
|
||||||
def test_decode_base64(self) -> None:
|
def test_decode_base64(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the decode command recovers 'Hello World' from a known base64 string
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["decode",
|
["decode",
|
||||||
|
|
@ -66,6 +102,9 @@ class TestDecodeCommand:
|
||||||
assert "Hello World" in result.output
|
assert "Hello World" in result.output
|
||||||
|
|
||||||
def test_decode_hex(self) -> None:
|
def test_decode_hex(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the decode command recovers 'Hello' from a hex string
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["decode",
|
["decode",
|
||||||
|
|
@ -77,6 +116,9 @@ class TestDecodeCommand:
|
||||||
assert "Hello" in result.output
|
assert "Hello" in result.output
|
||||||
|
|
||||||
def test_decode_invalid_base64(self) -> None:
|
def test_decode_invalid_base64(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that decoding garbage input exits with a non-zero code
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["decode",
|
["decode",
|
||||||
|
|
@ -87,6 +129,9 @@ class TestDecodeCommand:
|
||||||
|
|
||||||
class TestDetectCommand:
|
class TestDetectCommand:
|
||||||
def test_detect_base64(self) -> None:
|
def test_detect_base64(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the detect command identifies base64 in its output
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["detect",
|
["detect",
|
||||||
|
|
@ -96,6 +141,9 @@ class TestDetectCommand:
|
||||||
assert "base64" in result.output.lower()
|
assert "base64" in result.output.lower()
|
||||||
|
|
||||||
def test_detect_hex(self) -> None:
|
def test_detect_hex(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the detect command identifies hex in its output
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["detect",
|
["detect",
|
||||||
|
|
@ -105,6 +153,9 @@ class TestDetectCommand:
|
||||||
assert "hex" in result.output.lower()
|
assert "hex" in result.output.lower()
|
||||||
|
|
||||||
def test_detect_no_match(self) -> None:
|
def test_detect_no_match(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the detect command reports no encoding found for plain text
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["detect",
|
["detect",
|
||||||
|
|
@ -116,6 +167,9 @@ class TestDetectCommand:
|
||||||
|
|
||||||
class TestPeelCommand:
|
class TestPeelCommand:
|
||||||
def test_peel_single_layer(self) -> None:
|
def test_peel_single_layer(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the peel command reports at least one layer for a base64 string
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["peel",
|
["peel",
|
||||||
|
|
@ -125,6 +179,9 @@ class TestPeelCommand:
|
||||||
assert "layer" in result.output.lower()
|
assert "layer" in result.output.lower()
|
||||||
|
|
||||||
def test_peel_no_encoding(self) -> None:
|
def test_peel_no_encoding(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the peel command exits cleanly when no encoding is found
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["peel",
|
["peel",
|
||||||
|
|
@ -135,6 +192,9 @@ class TestPeelCommand:
|
||||||
|
|
||||||
class TestChainCommand:
|
class TestChainCommand:
|
||||||
def test_chain_single_step(self) -> None:
|
def test_chain_single_step(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the chain command applies one base64 step correctly
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["chain",
|
["chain",
|
||||||
|
|
@ -146,6 +206,9 @@ class TestChainCommand:
|
||||||
assert "SGVsbG8=" in result.output
|
assert "SGVsbG8=" in result.output
|
||||||
|
|
||||||
def test_chain_multiple_steps(self) -> None:
|
def test_chain_multiple_steps(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that the chain command applies two steps in sequence without error
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["chain",
|
["chain",
|
||||||
|
|
@ -156,6 +219,9 @@ class TestChainCommand:
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
def test_chain_invalid_format(self) -> None:
|
def test_chain_invalid_format(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that an unknown format name causes the chain command to exit with an error
|
||||||
|
"""
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["chain",
|
["chain",
|
||||||
|
|
@ -168,6 +234,9 @@ class TestChainCommand:
|
||||||
|
|
||||||
class TestVersionFlag:
|
class TestVersionFlag:
|
||||||
def test_version_output(self) -> None:
|
def test_version_output(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that --version prints the tool name and exits cleanly
|
||||||
|
"""
|
||||||
result = runner.invoke(app, ["--version"])
|
result = runner.invoke(app, ["--version"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "b64tool" in result.output
|
assert "b64tool" in result.output
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,26 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
test_detector.py
|
test_detector.py
|
||||||
|
|
||||||
|
Tests for format detection accuracy and edge cases in detector.py
|
||||||
|
|
||||||
|
Verifies that detect_best() and detect_encoding() correctly identify
|
||||||
|
each supported format from realistic input strings, that multi-format
|
||||||
|
results are sorted by confidence, and that unrecognized or too-short
|
||||||
|
inputs return empty results or None.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
TestDetectBase64 - standard padding, no padding, +/ characters
|
||||||
|
TestDetectBase64Url - URL-safe -_ character detection
|
||||||
|
TestDetectBase32 - uppercase inputs, standard padding
|
||||||
|
TestDetectHex - alpha hex chars, colon-separated, pure digits (low confidence)
|
||||||
|
TestDetectUrl - percent sequences, heavily encoded strings
|
||||||
|
TestDetectMultiple - sort order, no-match plain text, short string
|
||||||
|
TestDetectBest - highest confidence selection, None on no match
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
detector.py - imports detect_best, detect_encoding
|
||||||
|
constants.py - imports EncodingFormat
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from base64_tool.constants import EncodingFormat
|
from base64_tool.constants import EncodingFormat
|
||||||
|
|
@ -9,17 +29,26 @@ from base64_tool.detector import detect_best, detect_encoding
|
||||||
|
|
||||||
class TestDetectBase64:
|
class TestDetectBase64:
|
||||||
def test_standard_base64(self) -> None:
|
def test_standard_base64(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a padded base64 string is detected as base64 with high confidence
|
||||||
|
"""
|
||||||
result = detect_best("SGVsbG8gV29ybGQ=")
|
result = detect_best("SGVsbG8gV29ybGQ=")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.BASE64
|
assert result.format == EncodingFormat.BASE64
|
||||||
assert result.confidence >= 0.7
|
assert result.confidence >= 0.7
|
||||||
|
|
||||||
def test_base64_no_padding(self) -> None:
|
def test_base64_no_padding(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that base64 without trailing = padding is still detected
|
||||||
|
"""
|
||||||
result = detect_best("SGVsbG8gV29ybGQh")
|
result = detect_best("SGVsbG8gV29ybGQh")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.BASE64
|
assert result.format == EncodingFormat.BASE64
|
||||||
|
|
||||||
def test_base64_with_plus_slash(self) -> None:
|
def test_base64_with_plus_slash(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that base64 containing + and / characters is detected correctly
|
||||||
|
"""
|
||||||
result = detect_best("dGVzdC9wYXRoK3F1ZXJ5")
|
result = detect_best("dGVzdC9wYXRoK3F1ZXJ5")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.BASE64
|
assert result.format == EncodingFormat.BASE64
|
||||||
|
|
@ -27,6 +56,9 @@ class TestDetectBase64:
|
||||||
|
|
||||||
class TestDetectBase64Url:
|
class TestDetectBase64Url:
|
||||||
def test_url_safe_chars(self) -> None:
|
def test_url_safe_chars(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a base64url string with - and _ is detected as base64url or base64
|
||||||
|
"""
|
||||||
result = detect_best("dGVzdC1kYXRhX3ZhbHVl")
|
result = detect_best("dGVzdC1kYXRhX3ZhbHVl")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format in (
|
assert result.format in (
|
||||||
|
|
@ -37,12 +69,18 @@ class TestDetectBase64Url:
|
||||||
|
|
||||||
class TestDetectBase32:
|
class TestDetectBase32:
|
||||||
def test_standard_base32(self) -> None:
|
def test_standard_base32(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a padded uppercase base32 string is detected with sufficient confidence
|
||||||
|
"""
|
||||||
result = detect_best("JBSWY3DPEBLW64TMMQ======")
|
result = detect_best("JBSWY3DPEBLW64TMMQ======")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.BASE32
|
assert result.format == EncodingFormat.BASE32
|
||||||
assert result.confidence >= 0.6
|
assert result.confidence >= 0.6
|
||||||
|
|
||||||
def test_base32_uppercase(self) -> None:
|
def test_base32_uppercase(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a short uppercase base32 string is detected
|
||||||
|
"""
|
||||||
result = detect_best("JBSWY3DP")
|
result = detect_best("JBSWY3DP")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.BASE32
|
assert result.format == EncodingFormat.BASE32
|
||||||
|
|
@ -50,17 +88,26 @@ class TestDetectBase32:
|
||||||
|
|
||||||
class TestDetectHex:
|
class TestDetectHex:
|
||||||
def test_hex_with_letters(self) -> None:
|
def test_hex_with_letters(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a hex string containing alpha characters is detected as hex
|
||||||
|
"""
|
||||||
result = detect_best("48656c6c6f20576f726c64")
|
result = detect_best("48656c6c6f20576f726c64")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.HEX
|
assert result.format == EncodingFormat.HEX
|
||||||
assert result.confidence >= 0.6
|
assert result.confidence >= 0.6
|
||||||
|
|
||||||
def test_hex_with_colons(self) -> None:
|
def test_hex_with_colons(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that colon-separated hex bytes are detected as hex
|
||||||
|
"""
|
||||||
result = detect_best("48:65:6c:6c:6f:20:57:6f:72:6c:64")
|
result = detect_best("48:65:6c:6c:6f:20:57:6f:72:6c:64")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.HEX
|
assert result.format == EncodingFormat.HEX
|
||||||
|
|
||||||
def test_pure_digits_not_detected(self) -> None:
|
def test_pure_digits_not_detected(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a digit-only string is not confidently detected as hex
|
||||||
|
"""
|
||||||
result = detect_best("1234567890")
|
result = detect_best("1234567890")
|
||||||
if result is not None and result.format == EncodingFormat.HEX:
|
if result is not None and result.format == EncodingFormat.HEX:
|
||||||
assert result.confidence < 0.7
|
assert result.confidence < 0.7
|
||||||
|
|
@ -68,11 +115,17 @@ class TestDetectHex:
|
||||||
|
|
||||||
class TestDetectUrl:
|
class TestDetectUrl:
|
||||||
def test_url_encoded(self) -> None:
|
def test_url_encoded(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a string with percent-encoded characters is detected as URL encoding
|
||||||
|
"""
|
||||||
result = detect_best("hello%20world%21%40%23")
|
result = detect_best("hello%20world%21%40%23")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.URL
|
assert result.format == EncodingFormat.URL
|
||||||
|
|
||||||
def test_heavily_encoded(self) -> None:
|
def test_heavily_encoded(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a heavily percent-encoded string is detected with sufficient confidence
|
||||||
|
"""
|
||||||
result = detect_best("%48%65%6C%6C%6F%20%57%6F%72%6C%64")
|
result = detect_best("%48%65%6C%6C%6F%20%57%6F%72%6C%64")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.format == EncodingFormat.URL
|
assert result.format == EncodingFormat.URL
|
||||||
|
|
@ -81,22 +134,34 @@ class TestDetectUrl:
|
||||||
|
|
||||||
class TestDetectMultiple:
|
class TestDetectMultiple:
|
||||||
def test_returns_sorted_by_confidence(self) -> None:
|
def test_returns_sorted_by_confidence(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that multiple detection results come back sorted highest confidence first
|
||||||
|
"""
|
||||||
results = detect_encoding("SGVsbG8gV29ybGQ=")
|
results = detect_encoding("SGVsbG8gV29ybGQ=")
|
||||||
if len(results) > 1:
|
if len(results) > 1:
|
||||||
confidences = [r.confidence for r in results]
|
confidences = [r.confidence for r in results]
|
||||||
assert confidences == sorted(confidences, reverse = True)
|
assert confidences == sorted(confidences, reverse = True)
|
||||||
|
|
||||||
def test_no_match_returns_empty(self) -> None:
|
def test_no_match_returns_empty(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that plain unencoded text returns no detection results
|
||||||
|
"""
|
||||||
results = detect_encoding("hello world")
|
results = detect_encoding("hello world")
|
||||||
assert results == []
|
assert results == []
|
||||||
|
|
||||||
def test_short_string_returns_empty(self) -> None:
|
def test_short_string_returns_empty(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that strings below the minimum length return no results
|
||||||
|
"""
|
||||||
results = detect_encoding("ab")
|
results = detect_encoding("ab")
|
||||||
assert results == []
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
class TestDetectBest:
|
class TestDetectBest:
|
||||||
def test_returns_highest_confidence(self) -> None:
|
def test_returns_highest_confidence(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that detect_best returns the same result as the first item from detect_encoding
|
||||||
|
"""
|
||||||
result = detect_best("SGVsbG8gV29ybGQ=")
|
result = detect_best("SGVsbG8gV29ybGQ=")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
all_results = detect_encoding("SGVsbG8gV29ybGQ=")
|
all_results = detect_encoding("SGVsbG8gV29ybGQ=")
|
||||||
|
|
@ -104,4 +169,7 @@ class TestDetectBest:
|
||||||
assert result.confidence == all_results[0].confidence
|
assert result.confidence == all_results[0].confidence
|
||||||
|
|
||||||
def test_no_match_returns_none(self) -> None:
|
def test_no_match_returns_none(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that detect_best returns None when nothing is detected
|
||||||
|
"""
|
||||||
assert detect_best("not encoded at all!") is None
|
assert detect_best("not encoded at all!") is None
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,25 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
test_encoders.py
|
test_encoders.py
|
||||||
|
|
||||||
|
Unit tests for all encoder and decoder functions in encoders.py
|
||||||
|
|
||||||
|
Tests each format with known-good inputs, full roundtrips, whitespace
|
||||||
|
tolerance in decoders, binary and unicode data, and invalid input
|
||||||
|
rejection. Also covers the ENCODER_REGISTRY dispatch via encode() and
|
||||||
|
decode(), and the try_decode() safe wrapper.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
TestBase64 - encode, decode, roundtrip, whitespace, binary, unicode, invalid input
|
||||||
|
TestBase64Url - URL-safe character guarantees and roundtrip
|
||||||
|
TestBase32 - encode, decode, lowercase tolerance, padding
|
||||||
|
TestHex - encode, decode, colon/space/dash separator variants
|
||||||
|
TestUrl - percent-encoding, form-encoding (plus signs), roundtrip
|
||||||
|
TestRegistryDispatch - parametrized roundtrip for all formats, try_decode
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
encoders.py - all functions under test
|
||||||
|
constants.py - imports EncodingFormat
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
|
|
@ -27,34 +46,61 @@ from base64_tool.encoders import (
|
||||||
|
|
||||||
class TestBase64:
|
class TestBase64:
|
||||||
def test_encode_simple_text(self) -> None:
|
def test_encode_simple_text(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that 'Hello World' encodes to the known base64 string
|
||||||
|
"""
|
||||||
assert encode_base64(b"Hello World") == "SGVsbG8gV29ybGQ="
|
assert encode_base64(b"Hello World") == "SGVsbG8gV29ybGQ="
|
||||||
|
|
||||||
def test_decode_simple_text(self) -> None:
|
def test_decode_simple_text(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a known base64 string decodes back to 'Hello World'
|
||||||
|
"""
|
||||||
assert decode_base64("SGVsbG8gV29ybGQ=") == b"Hello World"
|
assert decode_base64("SGVsbG8gV29ybGQ=") == b"Hello World"
|
||||||
|
|
||||||
def test_roundtrip(self) -> None:
|
def test_roundtrip(self) -> None:
|
||||||
|
"""
|
||||||
|
Encodes then decodes an ASCII sentence and checks it matches the original
|
||||||
|
"""
|
||||||
original = b"The quick brown fox jumps over the lazy dog"
|
original = b"The quick brown fox jumps over the lazy dog"
|
||||||
assert decode_base64(encode_base64(original)) == original
|
assert decode_base64(encode_base64(original)) == original
|
||||||
|
|
||||||
def test_encode_empty(self) -> None:
|
def test_encode_empty(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that encoding empty bytes produces an empty string
|
||||||
|
"""
|
||||||
assert encode_base64(b"") == ""
|
assert encode_base64(b"") == ""
|
||||||
|
|
||||||
def test_decode_empty(self) -> None:
|
def test_decode_empty(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that decoding an empty string produces empty bytes
|
||||||
|
"""
|
||||||
assert decode_base64("") == b""
|
assert decode_base64("") == b""
|
||||||
|
|
||||||
def test_encode_binary_data(self) -> None:
|
def test_encode_binary_data(self) -> None:
|
||||||
|
"""
|
||||||
|
Encodes all 256 byte values and checks the roundtrip is lossless
|
||||||
|
"""
|
||||||
data = bytes(range(256))
|
data = bytes(range(256))
|
||||||
assert decode_base64(encode_base64(data)) == data
|
assert decode_base64(encode_base64(data)) == data
|
||||||
|
|
||||||
def test_decode_with_whitespace(self) -> None:
|
def test_decode_with_whitespace(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a base64 string split across newlines still decodes correctly
|
||||||
|
"""
|
||||||
encoded = "SGVs\nbG8g\nV29y\nbGQ="
|
encoded = "SGVs\nbG8g\nV29y\nbGQ="
|
||||||
assert decode_base64(encoded) == b"Hello World"
|
assert decode_base64(encoded) == b"Hello World"
|
||||||
|
|
||||||
def test_decode_invalid_raises(self) -> None:
|
def test_decode_invalid_raises(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that decoding garbage characters raises an exception
|
||||||
|
"""
|
||||||
with pytest.raises((ValueError, binascii.Error)):
|
with pytest.raises((ValueError, binascii.Error)):
|
||||||
decode_base64("!!!invalid!!!")
|
decode_base64("!!!invalid!!!")
|
||||||
|
|
||||||
def test_encode_unicode(self) -> None:
|
def test_encode_unicode(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that multi-byte unicode survives a base64 roundtrip
|
||||||
|
"""
|
||||||
data = "Hello 世界".encode()
|
data = "Hello 世界".encode()
|
||||||
decoded = decode_base64(encode_base64(data))
|
decoded = decode_base64(encode_base64(data))
|
||||||
assert decoded == data
|
assert decoded == data
|
||||||
|
|
@ -62,81 +108,141 @@ class TestBase64:
|
||||||
|
|
||||||
class TestBase64Url:
|
class TestBase64Url:
|
||||||
def test_encode_with_url_chars(self) -> None:
|
def test_encode_with_url_chars(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that URL-safe base64 never emits + or / characters
|
||||||
|
"""
|
||||||
data = b"\xfb\xff\xfe"
|
data = b"\xfb\xff\xfe"
|
||||||
encoded = encode_base64url(data)
|
encoded = encode_base64url(data)
|
||||||
assert "+" not in encoded
|
assert "+" not in encoded
|
||||||
assert "/" not in encoded
|
assert "/" not in encoded
|
||||||
|
|
||||||
def test_decode_url_safe(self) -> None:
|
def test_decode_url_safe(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that data containing / and + round-trips through URL-safe base64
|
||||||
|
"""
|
||||||
result = decode_base64url(encode_base64url(b"test/path+query"))
|
result = decode_base64url(encode_base64url(b"test/path+query"))
|
||||||
assert result == b"test/path+query"
|
assert result == b"test/path+query"
|
||||||
|
|
||||||
def test_roundtrip(self) -> None:
|
def test_roundtrip(self) -> None:
|
||||||
|
"""
|
||||||
|
Encodes then decodes a URL string and checks it matches the original
|
||||||
|
"""
|
||||||
original = b"https://example.com?token=abc+def/ghi"
|
original = b"https://example.com?token=abc+def/ghi"
|
||||||
assert decode_base64url(encode_base64url(original)) == original
|
assert decode_base64url(encode_base64url(original)) == original
|
||||||
|
|
||||||
|
|
||||||
class TestBase32:
|
class TestBase32:
|
||||||
def test_encode_simple(self) -> None:
|
def test_encode_simple(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that 'Hello' encodes to its known base32 representation
|
||||||
|
"""
|
||||||
assert encode_base32(b"Hello") == "JBSWY3DP"
|
assert encode_base32(b"Hello") == "JBSWY3DP"
|
||||||
|
|
||||||
def test_decode_simple(self) -> None:
|
def test_decode_simple(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a known base32 string decodes to 'Hello'
|
||||||
|
"""
|
||||||
assert decode_base32("JBSWY3DP") == b"Hello"
|
assert decode_base32("JBSWY3DP") == b"Hello"
|
||||||
|
|
||||||
def test_roundtrip(self) -> None:
|
def test_roundtrip(self) -> None:
|
||||||
|
"""
|
||||||
|
Encodes then decodes a short sentence and checks it matches the original
|
||||||
|
"""
|
||||||
original = b"Base32 encoding test"
|
original = b"Base32 encoding test"
|
||||||
assert decode_base32(encode_base32(original)) == original
|
assert decode_base32(encode_base32(original)) == original
|
||||||
|
|
||||||
def test_decode_lowercase_accepted(self) -> None:
|
def test_decode_lowercase_accepted(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that lowercase base32 input is accepted and decoded correctly
|
||||||
|
"""
|
||||||
assert decode_base32("jbswy3dp") == b"Hello"
|
assert decode_base32("jbswy3dp") == b"Hello"
|
||||||
|
|
||||||
def test_decode_with_padding(self) -> None:
|
def test_decode_with_padding(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a padded base32 string decodes to 'Hello World'
|
||||||
|
"""
|
||||||
assert decode_base32("JBSWY3DPEBLW64TMMQ======") == b"Hello World"
|
assert decode_base32("JBSWY3DPEBLW64TMMQ======") == b"Hello World"
|
||||||
|
|
||||||
|
|
||||||
class TestHex:
|
class TestHex:
|
||||||
def test_encode_simple(self) -> None:
|
def test_encode_simple(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that bytes \\xca\\xfe encode to the hex string 'cafe'
|
||||||
|
"""
|
||||||
assert encode_hex(b"\xca\xfe") == "cafe"
|
assert encode_hex(b"\xca\xfe") == "cafe"
|
||||||
|
|
||||||
def test_decode_simple(self) -> None:
|
def test_decode_simple(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that 'cafe' decodes to the bytes \\xca\\xfe
|
||||||
|
"""
|
||||||
assert decode_hex("cafe") == b"\xca\xfe"
|
assert decode_hex("cafe") == b"\xca\xfe"
|
||||||
|
|
||||||
def test_decode_with_colons(self) -> None:
|
def test_decode_with_colons(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that colon-separated hex decodes correctly
|
||||||
|
"""
|
||||||
assert decode_hex("ca:fe:ba:be") == b"\xca\xfe\xba\xbe"
|
assert decode_hex("ca:fe:ba:be") == b"\xca\xfe\xba\xbe"
|
||||||
|
|
||||||
def test_decode_with_spaces(self) -> None:
|
def test_decode_with_spaces(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that space-separated hex decodes correctly
|
||||||
|
"""
|
||||||
assert decode_hex("ca fe ba be") == b"\xca\xfe\xba\xbe"
|
assert decode_hex("ca fe ba be") == b"\xca\xfe\xba\xbe"
|
||||||
|
|
||||||
def test_decode_with_dashes(self) -> None:
|
def test_decode_with_dashes(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that dash-separated hex decodes correctly
|
||||||
|
"""
|
||||||
assert decode_hex("ca-fe-ba-be") == b"\xca\xfe\xba\xbe"
|
assert decode_hex("ca-fe-ba-be") == b"\xca\xfe\xba\xbe"
|
||||||
|
|
||||||
def test_decode_uppercase(self) -> None:
|
def test_decode_uppercase(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that uppercase hex input decodes correctly
|
||||||
|
"""
|
||||||
assert decode_hex("CAFE") == b"\xca\xfe"
|
assert decode_hex("CAFE") == b"\xca\xfe"
|
||||||
|
|
||||||
def test_roundtrip(self) -> None:
|
def test_roundtrip(self) -> None:
|
||||||
|
"""
|
||||||
|
Encodes then decodes a known string and checks no data is lost
|
||||||
|
"""
|
||||||
original = b"Hello World"
|
original = b"Hello World"
|
||||||
assert decode_hex(encode_hex(original)) == original
|
assert decode_hex(encode_hex(original)) == original
|
||||||
|
|
||||||
|
|
||||||
class TestUrl:
|
class TestUrl:
|
||||||
def test_encode_special_chars(self) -> None:
|
def test_encode_special_chars(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that spaces and ampersands are percent-encoded
|
||||||
|
"""
|
||||||
result = encode_url(b"hello world&foo=bar")
|
result = encode_url(b"hello world&foo=bar")
|
||||||
assert " " not in result
|
assert " " not in result
|
||||||
assert "&" not in result
|
assert "&" not in result
|
||||||
|
|
||||||
def test_decode_percent_encoded(self) -> None:
|
def test_decode_percent_encoded(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that %20 decodes to a space
|
||||||
|
"""
|
||||||
assert decode_url("hello%20world") == b"hello world"
|
assert decode_url("hello%20world") == b"hello world"
|
||||||
|
|
||||||
def test_roundtrip(self) -> None:
|
def test_roundtrip(self) -> None:
|
||||||
|
"""
|
||||||
|
Encodes then decodes a URL with a query string and checks it matches the original
|
||||||
|
"""
|
||||||
original = b"path/to/file?key=value&other=test"
|
original = b"path/to/file?key=value&other=test"
|
||||||
assert decode_url(encode_url(original)) == original
|
assert decode_url(encode_url(original)) == original
|
||||||
|
|
||||||
def test_form_encode_space_as_plus(self) -> None:
|
def test_form_encode_space_as_plus(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that form encoding turns spaces into + rather than %20
|
||||||
|
"""
|
||||||
result = encode_url(b"hello world", form = True)
|
result = encode_url(b"hello world", form = True)
|
||||||
assert "+" in result
|
assert "+" in result
|
||||||
assert "%20" not in result
|
assert "%20" not in result
|
||||||
|
|
||||||
def test_form_decode_plus_as_space(self) -> None:
|
def test_form_decode_plus_as_space(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that form decoding turns + back into a space
|
||||||
|
"""
|
||||||
assert decode_url("hello+world", form = True) == b"hello world"
|
assert decode_url("hello+world", form = True) == b"hello world"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -146,15 +252,24 @@ class TestRegistryDispatch:
|
||||||
self,
|
self,
|
||||||
fmt: EncodingFormat,
|
fmt: EncodingFormat,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""
|
||||||
|
Checks that all registered formats produce a lossless roundtrip via encode() and decode()
|
||||||
|
"""
|
||||||
original = b"roundtrip test data"
|
original = b"roundtrip test data"
|
||||||
encoded = encode(original, fmt)
|
encoded = encode(original, fmt)
|
||||||
decoded = decode(encoded, fmt)
|
decoded = decode(encoded, fmt)
|
||||||
assert decoded == original
|
assert decoded == original
|
||||||
|
|
||||||
def test_try_decode_valid(self) -> None:
|
def test_try_decode_valid(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that try_decode returns the correct bytes for valid input
|
||||||
|
"""
|
||||||
result = try_decode("SGVsbG8=", EncodingFormat.BASE64)
|
result = try_decode("SGVsbG8=", EncodingFormat.BASE64)
|
||||||
assert result == b"Hello"
|
assert result == b"Hello"
|
||||||
|
|
||||||
def test_try_decode_invalid_returns_none(self) -> None:
|
def test_try_decode_invalid_returns_none(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that try_decode returns None instead of raising for garbage input
|
||||||
|
"""
|
||||||
result = try_decode("!!!bad!!!", EncodingFormat.BASE64)
|
result = try_decode("!!!bad!!!", EncodingFormat.BASE64)
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
test_peeler.py
|
test_peeler.py
|
||||||
|
|
||||||
|
Tests for single-layer and multi-layer decoding in peeler.py
|
||||||
|
|
||||||
|
Verifies that peel() strips one encoding layer correctly, handles
|
||||||
|
stacked encodings (base64 over hex, double base64), respects the
|
||||||
|
max_depth limit, and populates layer metadata accurately including
|
||||||
|
depth index, format, confidence, and preview strings.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
TestSingleLayer - base64, hex, base32 single-layer peeling
|
||||||
|
TestMultiLayer - base64+hex and double-base64 stacked encodings
|
||||||
|
TestPeelEdgeCases - plain text input, max_depth=0, empty string, layer metadata fields
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
peeler.py - imports peel
|
||||||
|
encoders.py - imports encode to construct layered test inputs
|
||||||
|
constants.py - imports EncodingFormat
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from base64_tool.constants import EncodingFormat
|
from base64_tool.constants import EncodingFormat
|
||||||
|
|
@ -10,6 +27,9 @@ from base64_tool.peeler import peel
|
||||||
|
|
||||||
class TestSingleLayer:
|
class TestSingleLayer:
|
||||||
def test_peel_base64(self) -> None:
|
def test_peel_base64(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a single base64 layer is peeled and the original bytes recovered
|
||||||
|
"""
|
||||||
encoded = encode(b"Hello World", EncodingFormat.BASE64)
|
encoded = encode(b"Hello World", EncodingFormat.BASE64)
|
||||||
result = peel(encoded)
|
result = peel(encoded)
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
|
|
@ -18,6 +38,9 @@ class TestSingleLayer:
|
||||||
assert result.final_output == b"Hello World"
|
assert result.final_output == b"Hello World"
|
||||||
|
|
||||||
def test_peel_hex(self) -> None:
|
def test_peel_hex(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a single hex layer is peeled and the original bytes recovered
|
||||||
|
"""
|
||||||
encoded = encode(b"Hello World", EncodingFormat.HEX)
|
encoded = encode(b"Hello World", EncodingFormat.HEX)
|
||||||
result = peel(encoded)
|
result = peel(encoded)
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
|
|
@ -25,6 +48,9 @@ class TestSingleLayer:
|
||||||
assert result.final_output == b"Hello World"
|
assert result.final_output == b"Hello World"
|
||||||
|
|
||||||
def test_peel_base32(self) -> None:
|
def test_peel_base32(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a single base32 layer is successfully detected and peeled
|
||||||
|
"""
|
||||||
encoded = encode(b"Hello World", EncodingFormat.BASE32)
|
encoded = encode(b"Hello World", EncodingFormat.BASE32)
|
||||||
result = peel(encoded)
|
result = peel(encoded)
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
|
|
@ -33,6 +59,9 @@ class TestSingleLayer:
|
||||||
|
|
||||||
class TestMultiLayer:
|
class TestMultiLayer:
|
||||||
def test_base64_then_hex(self) -> None:
|
def test_base64_then_hex(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that two stacked layers (base64 inside hex) are both peeled
|
||||||
|
"""
|
||||||
step1 = encode(b"secret payload", EncodingFormat.BASE64)
|
step1 = encode(b"secret payload", EncodingFormat.BASE64)
|
||||||
step2 = encode(step1.encode("utf-8"), EncodingFormat.HEX)
|
step2 = encode(step1.encode("utf-8"), EncodingFormat.HEX)
|
||||||
result = peel(step2)
|
result = peel(step2)
|
||||||
|
|
@ -41,6 +70,9 @@ class TestMultiLayer:
|
||||||
assert b"secret payload" in result.final_output
|
assert b"secret payload" in result.final_output
|
||||||
|
|
||||||
def test_base64_double_encoded(self) -> None:
|
def test_base64_double_encoded(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that base64 applied twice is unwrapped through both layers
|
||||||
|
"""
|
||||||
step1 = encode(b"double layer", EncodingFormat.BASE64)
|
step1 = encode(b"double layer", EncodingFormat.BASE64)
|
||||||
step2 = encode(step1.encode("utf-8"), EncodingFormat.BASE64)
|
step2 = encode(step1.encode("utf-8"), EncodingFormat.BASE64)
|
||||||
result = peel(step2)
|
result = peel(step2)
|
||||||
|
|
@ -50,20 +82,32 @@ class TestMultiLayer:
|
||||||
|
|
||||||
class TestPeelEdgeCases:
|
class TestPeelEdgeCases:
|
||||||
def test_plaintext_no_layers(self) -> None:
|
def test_plaintext_no_layers(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that plain text with no encoding returns a failed peel with zero layers
|
||||||
|
"""
|
||||||
result = peel("just plain text")
|
result = peel("just plain text")
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert len(result.layers) == 0
|
assert len(result.layers) == 0
|
||||||
|
|
||||||
def test_max_depth_respected(self) -> None:
|
def test_max_depth_respected(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that setting max_depth=0 prevents any layers from being peeled
|
||||||
|
"""
|
||||||
encoded = encode(b"data", EncodingFormat.BASE64)
|
encoded = encode(b"data", EncodingFormat.BASE64)
|
||||||
result = peel(encoded, max_depth = 0)
|
result = peel(encoded, max_depth = 0)
|
||||||
assert len(result.layers) == 0
|
assert len(result.layers) == 0
|
||||||
|
|
||||||
def test_empty_string(self) -> None:
|
def test_empty_string(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that an empty string results in a failed peel
|
||||||
|
"""
|
||||||
result = peel("")
|
result = peel("")
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
|
|
||||||
def test_layer_metadata_populated(self) -> None:
|
def test_layer_metadata_populated(self) -> None:
|
||||||
|
"""
|
||||||
|
Checks that a successful peel populates depth, confidence, and preview fields
|
||||||
|
"""
|
||||||
encoded = encode(b"test data", EncodingFormat.BASE64)
|
encoded = encode(b"test data", EncodingFormat.BASE64)
|
||||||
result = peel(encoded)
|
result = peel(encoded)
|
||||||
if result.success and result.layers:
|
if result.success and result.layers:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,26 @@
|
||||||
"""
|
"""
|
||||||
©AngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
test_properties.py
|
test_properties.py
|
||||||
|
|
||||||
|
Property-based roundtrip and structural invariant tests using Hypothesis
|
||||||
|
|
||||||
|
Generates arbitrary binary and text inputs to verify that every
|
||||||
|
encode/decode pair is lossless and that encoded output meets format
|
||||||
|
constraints: ASCII-only, correct length multiples, correct character
|
||||||
|
sets, and URL form-encoding symmetry. Cross-format roundtrips are
|
||||||
|
verified in a single parametrized test.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
TestBase64Properties - roundtrip, ASCII output, length multiple of 4
|
||||||
|
TestBase64UrlProperties - roundtrip, no +/ characters in output
|
||||||
|
TestBase32Properties - roundtrip, uppercase output, length multiple of 8
|
||||||
|
TestHexProperties - roundtrip, output length exactly double, lowercase hex chars only
|
||||||
|
TestUrlProperties - roundtrip and form encoding roundtrip (200 examples each)
|
||||||
|
TestCrossFormatProperties - all non-URL formats pass roundtrip for arbitrary binary
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
encoders.py - all encode/decode functions under test
|
||||||
|
constants.py - imports EncodingFormat
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from hypothesis import given, settings, strategies as st
|
from hypothesis import given, settings, strategies as st
|
||||||
|
|
@ -25,15 +45,24 @@ from base64_tool.encoders import (
|
||||||
class TestBase64Properties:
|
class TestBase64Properties:
|
||||||
@given(st.binary())
|
@given(st.binary())
|
||||||
def test_roundtrip(self, data: bytes) -> None:
|
def test_roundtrip(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that arbitrary binary data survives a base64 encode-decode cycle unchanged
|
||||||
|
"""
|
||||||
assert decode_base64(encode_base64(data)) == data
|
assert decode_base64(encode_base64(data)) == data
|
||||||
|
|
||||||
@given(st.binary(min_size=1))
|
@given(st.binary(min_size=1))
|
||||||
def test_encoded_is_ascii(self, data: bytes) -> None:
|
def test_encoded_is_ascii(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that base64 output is always valid ASCII
|
||||||
|
"""
|
||||||
encoded = encode_base64(data)
|
encoded = encode_base64(data)
|
||||||
encoded.encode("ascii")
|
encoded.encode("ascii")
|
||||||
|
|
||||||
@given(st.binary())
|
@given(st.binary())
|
||||||
def test_encoded_length_is_multiple_of_4(self, data: bytes) -> None:
|
def test_encoded_length_is_multiple_of_4(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that base64 output length is always a multiple of 4
|
||||||
|
"""
|
||||||
encoded = encode_base64(data)
|
encoded = encode_base64(data)
|
||||||
if encoded:
|
if encoded:
|
||||||
assert len(encoded) % 4 == 0
|
assert len(encoded) % 4 == 0
|
||||||
|
|
@ -42,10 +71,16 @@ class TestBase64Properties:
|
||||||
class TestBase64UrlProperties:
|
class TestBase64UrlProperties:
|
||||||
@given(st.binary())
|
@given(st.binary())
|
||||||
def test_roundtrip(self, data: bytes) -> None:
|
def test_roundtrip(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that arbitrary binary data survives a base64url encode-decode cycle unchanged
|
||||||
|
"""
|
||||||
assert decode_base64url(encode_base64url(data)) == data
|
assert decode_base64url(encode_base64url(data)) == data
|
||||||
|
|
||||||
@given(st.binary(min_size=1))
|
@given(st.binary(min_size=1))
|
||||||
def test_no_standard_base64_chars(self, data: bytes) -> None:
|
def test_no_standard_base64_chars(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that base64url output never contains + or /
|
||||||
|
"""
|
||||||
encoded = encode_base64url(data)
|
encoded = encode_base64url(data)
|
||||||
assert "+" not in encoded
|
assert "+" not in encoded
|
||||||
assert "/" not in encoded
|
assert "/" not in encoded
|
||||||
|
|
@ -54,15 +89,24 @@ class TestBase64UrlProperties:
|
||||||
class TestBase32Properties:
|
class TestBase32Properties:
|
||||||
@given(st.binary())
|
@given(st.binary())
|
||||||
def test_roundtrip(self, data: bytes) -> None:
|
def test_roundtrip(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that arbitrary binary data survives a base32 encode-decode cycle unchanged
|
||||||
|
"""
|
||||||
assert decode_base32(encode_base32(data)) == data
|
assert decode_base32(encode_base32(data)) == data
|
||||||
|
|
||||||
@given(st.binary(min_size=1))
|
@given(st.binary(min_size=1))
|
||||||
def test_encoded_is_uppercase(self, data: bytes) -> None:
|
def test_encoded_is_uppercase(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that base32 output is always uppercase
|
||||||
|
"""
|
||||||
encoded = encode_base32(data)
|
encoded = encode_base32(data)
|
||||||
assert encoded == encoded.upper()
|
assert encoded == encoded.upper()
|
||||||
|
|
||||||
@given(st.binary())
|
@given(st.binary())
|
||||||
def test_encoded_length_is_multiple_of_8(self, data: bytes) -> None:
|
def test_encoded_length_is_multiple_of_8(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that base32 output length is always a multiple of 8
|
||||||
|
"""
|
||||||
encoded = encode_base32(data)
|
encoded = encode_base32(data)
|
||||||
if encoded:
|
if encoded:
|
||||||
assert len(encoded) % 8 == 0
|
assert len(encoded) % 8 == 0
|
||||||
|
|
@ -71,14 +115,23 @@ class TestBase32Properties:
|
||||||
class TestHexProperties:
|
class TestHexProperties:
|
||||||
@given(st.binary())
|
@given(st.binary())
|
||||||
def test_roundtrip(self, data: bytes) -> None:
|
def test_roundtrip(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that arbitrary binary data survives a hex encode-decode cycle unchanged
|
||||||
|
"""
|
||||||
assert decode_hex(encode_hex(data)) == data
|
assert decode_hex(encode_hex(data)) == data
|
||||||
|
|
||||||
@given(st.binary(min_size=1))
|
@given(st.binary(min_size=1))
|
||||||
def test_encoded_length_is_double(self, data: bytes) -> None:
|
def test_encoded_length_is_double(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that hex output is exactly twice the length of the input
|
||||||
|
"""
|
||||||
assert len(encode_hex(data)) == len(data) * 2
|
assert len(encode_hex(data)) == len(data) * 2
|
||||||
|
|
||||||
@given(st.binary())
|
@given(st.binary())
|
||||||
def test_encoded_is_hex_chars_only(self, data: bytes) -> None:
|
def test_encoded_is_hex_chars_only(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that hex output contains only lowercase hex characters
|
||||||
|
"""
|
||||||
encoded = encode_hex(data)
|
encoded = encode_hex(data)
|
||||||
assert all(c in "0123456789abcdef" for c in encoded)
|
assert all(c in "0123456789abcdef" for c in encoded)
|
||||||
|
|
||||||
|
|
@ -87,12 +140,18 @@ class TestUrlProperties:
|
||||||
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
|
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
|
||||||
@settings(max_examples=200)
|
@settings(max_examples=200)
|
||||||
def test_roundtrip(self, text: str) -> None:
|
def test_roundtrip(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Checks that arbitrary UTF-8 text survives a URL encode-decode cycle unchanged
|
||||||
|
"""
|
||||||
data = text.encode("utf-8")
|
data = text.encode("utf-8")
|
||||||
assert decode_url(encode_url(data)) == data
|
assert decode_url(encode_url(data)) == data
|
||||||
|
|
||||||
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
|
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
|
||||||
@settings(max_examples=200)
|
@settings(max_examples=200)
|
||||||
def test_form_roundtrip(self, text: str) -> None:
|
def test_form_roundtrip(self, text: str) -> None:
|
||||||
|
"""
|
||||||
|
Checks that arbitrary UTF-8 text survives a form-encoded URL roundtrip unchanged
|
||||||
|
"""
|
||||||
data = text.encode("utf-8")
|
data = text.encode("utf-8")
|
||||||
assert decode_url(encode_url(data, form=True), form=True) == data
|
assert decode_url(encode_url(data, form=True), form=True) == data
|
||||||
|
|
||||||
|
|
@ -100,6 +159,9 @@ class TestUrlProperties:
|
||||||
class TestCrossFormatProperties:
|
class TestCrossFormatProperties:
|
||||||
@given(st.binary(min_size=1, max_size=256))
|
@given(st.binary(min_size=1, max_size=256))
|
||||||
def test_all_formats_roundtrip(self, data: bytes) -> None:
|
def test_all_formats_roundtrip(self, data: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Checks that arbitrary binary data roundtrips losslessly through all non-URL formats
|
||||||
|
"""
|
||||||
for fmt in (
|
for fmt in (
|
||||||
EncodingFormat.BASE64,
|
EncodingFormat.BASE64,
|
||||||
EncodingFormat.BASE64URL,
|
EncodingFormat.BASE64URL,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
__main__.py
|
__main__.py
|
||||||
|
|
||||||
|
FastAPI application factory and server entry point
|
||||||
|
|
||||||
|
create_app assembles the FastAPI instance with CORS middleware, health
|
||||||
|
and root endpoints, and all three routers. The lifespan handler
|
||||||
|
initializes the database and creates the shared registry,
|
||||||
|
task_manager, and ops_manager singletons stored on app.state.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
config.py - reads all settings
|
||||||
|
database.py - calls init_db()
|
||||||
|
beacon/registry.py - creates BeaconRegistry singleton
|
||||||
|
beacon/tasking.py - creates TaskManager singleton
|
||||||
|
beacon/router.py - mounts beacon WebSocket router
|
||||||
|
ops/manager.py - creates OpsManager singleton
|
||||||
|
ops/router.py - mounts operator WebSocket and REST routers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
registry.py
|
registry.py
|
||||||
|
|
||||||
|
Tracks active beacon WebSocket connections with SQLite persistence
|
||||||
|
|
||||||
|
BeaconRegistry holds an in-memory dict of live connections keyed by
|
||||||
|
beacon ID alongside the SQLite beacons table. register/unregister
|
||||||
|
update both stores. is_active and get_connection read from memory;
|
||||||
|
get_all and get_one query the database.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
core/models.py - uses BeaconMeta, BeaconRecord
|
||||||
|
beacon/router.py - registers and unregisters connections
|
||||||
|
ops/router.py - reads beacon list and active status
|
||||||
|
__main__.py - creates singleton on startup
|
||||||
|
tests/test_registry.py - tests all registry methods
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
router.py
|
router.py
|
||||||
|
|
||||||
|
WebSocket endpoint that manages the full beacon connection lifecycle
|
||||||
|
|
||||||
|
The /ws/beacon handler validates the REGISTER handshake, then runs
|
||||||
|
two concurrent coroutines: one pushing queued tasks to the beacon and
|
||||||
|
one processing incoming RESULT and HEARTBEAT messages. On disconnect,
|
||||||
|
it cleans up the registry and queue and broadcasts the event to
|
||||||
|
operators.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
beacon/registry.py - registers, unregisters, updates heartbeat
|
||||||
|
beacon/tasking.py - dequeues tasks, stores results
|
||||||
|
config.py - reads XOR_KEY
|
||||||
|
core/models.py - uses BeaconMeta, TaskResult
|
||||||
|
core/protocol.py - calls pack, unpack
|
||||||
|
database.py - calls get_db()
|
||||||
|
ops/manager.py - broadcasts beacon events
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
tasking.py
|
tasking.py
|
||||||
|
|
||||||
|
Per-beacon asyncio task queues backed by SQLite persistence
|
||||||
|
|
||||||
|
TaskManager maintains an asyncio.Queue per beacon for pending tasks.
|
||||||
|
submit writes to SQLite and enqueues; get_next blocks until a task is
|
||||||
|
available; store_result persists the result and marks the task
|
||||||
|
completed; get_history returns tasks joined with their results.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
core/models.py - uses TaskRecord, TaskResult
|
||||||
|
beacon/router.py - calls get_next, store_result, remove_queue
|
||||||
|
ops/router.py - calls submit, get_history
|
||||||
|
__main__.py - creates singleton on startup
|
||||||
|
tests/test_tasking.py - tests all task lifecycle methods
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
config.py
|
config.py
|
||||||
|
|
||||||
|
Application settings loaded from environment variables and a .env file
|
||||||
|
|
||||||
|
Defines the Settings class using pydantic-settings, covering server
|
||||||
|
host/port, database path, XOR encryption key, CORS origins, and log
|
||||||
|
level. The module-level settings singleton is shared across the app.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
database.py - reads DATABASE_PATH
|
||||||
|
beacon/router.py - reads XOR_KEY
|
||||||
|
__main__.py - reads HOST, PORT, APP_NAME, LOG_LEVEL
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
encoding.py
|
encoding.py
|
||||||
|
|
||||||
|
XOR cipher and Base64 encoding pipeline for obfuscating C2 traffic
|
||||||
|
|
||||||
|
Provides the low-level encoding primitives used on both the server and
|
||||||
|
implant sides. encode applies a repeating XOR with the shared key then
|
||||||
|
Base64-encodes the result; decode reverses the operation. This is
|
||||||
|
traffic obfuscation, not encryption.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
xor_bytes - byte-level XOR with repeating key
|
||||||
|
encode - plaintext string to XOR+Base64 string
|
||||||
|
decode - XOR+Base64 string back to plaintext
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
protocol.py - calls encode and decode
|
||||||
|
tests/test_encoding.py - tests all three functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
models.py
|
models.py
|
||||||
|
|
||||||
|
Pydantic models and command types shared across the C2 server
|
||||||
|
|
||||||
|
Defines the data types used throughout the server: CommandType (the
|
||||||
|
supported C2 commands mapped to MITRE ATT&CK techniques), beacon
|
||||||
|
registration and storage models, and the full task lifecycle from
|
||||||
|
request to result.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
CommandType - enum of supported beacon commands
|
||||||
|
BeaconMeta, BeaconRecord - beacon registration and database types
|
||||||
|
TaskRequest, TaskRecord, TaskResult - task lifecycle types
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
beacon/registry.py - uses BeaconMeta, BeaconRecord
|
||||||
|
beacon/tasking.py - uses TaskRecord, TaskResult
|
||||||
|
beacon/router.py - uses BeaconMeta, TaskResult
|
||||||
|
ops/router.py - uses CommandType, TaskRecord
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
protocol.py
|
protocol.py
|
||||||
|
|
||||||
|
Protocol envelope types and pack/unpack for all WebSocket messages
|
||||||
|
|
||||||
|
Defines the shared message format for beacon/server communication.
|
||||||
|
MessageType enumerates the five protocol states. pack serializes a
|
||||||
|
Message to an encoded string; unpack decodes and validates the result,
|
||||||
|
raising ValueError on any malformed input.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
MessageType - REGISTER, HEARTBEAT, TASK, RESULT, ERROR
|
||||||
|
Message - protocol envelope model
|
||||||
|
pack - serialize and encode a Message to a wire string
|
||||||
|
unpack - decode and validate a raw wire string into a Message
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
encoding.py - calls encode and decode
|
||||||
|
beacon/router.py - calls pack, unpack
|
||||||
|
tests/test_protocol.py - tests pack/unpack roundtrips
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
database.py
|
database.py
|
||||||
|
|
||||||
|
SQLite schema definition and async connection management
|
||||||
|
|
||||||
|
Holds the three-table schema (beacons, tasks, task_results) as a SQL
|
||||||
|
string and exposes init_db for first-run setup and get_db as an async
|
||||||
|
context manager. WAL mode and foreign keys are enabled on every
|
||||||
|
connection.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
config.py - reads DATABASE_PATH via settings
|
||||||
|
beacon/router.py - calls get_db()
|
||||||
|
ops/router.py - calls get_db()
|
||||||
|
__main__.py - calls init_db()
|
||||||
|
tests/test_registry.py - imports SCHEMA
|
||||||
|
tests/test_tasking.py - imports SCHEMA
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
manager.py
|
manager.py
|
||||||
|
|
||||||
|
Fan-out broadcaster for operator WebSocket connections
|
||||||
|
|
||||||
|
OpsManager keeps a set of active operator WebSocket connections.
|
||||||
|
connect accepts a new connection; broadcast serializes an event dict
|
||||||
|
as JSON and sends it to all connected operators, silently dropping
|
||||||
|
any connections that fail to send.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
beacon/router.py - broadcast called for beacon connect/disconnect/result
|
||||||
|
ops/router.py - connect, disconnect, and broadcast called per operator
|
||||||
|
__main__.py - creates singleton on startup
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,25 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
router.py
|
router.py
|
||||||
|
|
||||||
|
Operator interface combining a WebSocket dashboard channel and REST beacon routes
|
||||||
|
|
||||||
|
The /ws/operator WebSocket sends the current beacon list on connect
|
||||||
|
and accepts submit_task messages from the operator. Three REST routes
|
||||||
|
cover beacon listing, single-beacon lookup, and task history. All
|
||||||
|
handlers share the registry, task manager, and ops manager from
|
||||||
|
app.state.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
ws_router - WebSocket router mounted at /ws/operator
|
||||||
|
rest_router - REST routes for /beacons and /beacons/{id}
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
beacon/registry.py - reads beacon list and active status
|
||||||
|
beacon/tasking.py - submits tasks and fetches history
|
||||||
|
core/models.py - uses CommandType, TaskRecord
|
||||||
|
database.py - calls get_db()
|
||||||
|
ops/manager.py - manages operator connections
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
conftest.py
|
conftest.py
|
||||||
|
|
||||||
|
Shared pytest fixtures for the backend test suite
|
||||||
|
|
||||||
|
Provides two fixtures: tmp_db_path supplies a temp SQLite path for
|
||||||
|
test isolation, and test_settings builds a Settings instance pointed
|
||||||
|
at that path with a known XOR key.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
test_app.py
|
test_app.py
|
||||||
|
|
||||||
|
Integration tests for the FastAPI HTTP endpoints
|
||||||
|
|
||||||
|
Spins up the full application using ASGITransport and verifies the
|
||||||
|
health check, root info endpoint, empty beacon list, and 404 handling
|
||||||
|
for unknown beacon IDs.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
__main__.py - create_app, /health, /, /beacons, /beacons/{id}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
test_encoding.py
|
test_encoding.py
|
||||||
|
|
||||||
|
Unit tests for the XOR cipher and Base64 encoding pipeline
|
||||||
|
|
||||||
|
Verifies xor_bytes correctness and idempotency, encode/decode
|
||||||
|
roundtrips for ASCII and Unicode payloads, and that mismatched keys
|
||||||
|
produce different ciphertext.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
core/encoding.py - xor_bytes, encode, decode
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
test_protocol.py
|
test_protocol.py
|
||||||
|
|
||||||
|
Unit tests for protocol message pack/unpack roundtrips and validation
|
||||||
|
|
||||||
|
Verifies that all five MessageType values survive pack -> unpack, and
|
||||||
|
that unpack raises ValueError for invalid base64, malformed JSON,
|
||||||
|
missing fields, and unknown message types.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
core/protocol.py - pack, unpack, Message, MessageType
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
test_registry.py
|
test_registry.py
|
||||||
|
|
||||||
|
Unit tests for BeaconRegistry connection tracking and persistence
|
||||||
|
|
||||||
|
Verifies that registering a beacon adds it to both the in-memory
|
||||||
|
store and SQLite, that unregistering removes it from memory while
|
||||||
|
preserving the database record, and that query methods return correct
|
||||||
|
results.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
beacon/registry.py - BeaconRegistry
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
test_tasking.py
|
test_tasking.py
|
||||||
|
|
||||||
|
Unit tests for TaskManager queue behavior and SQLite persistence
|
||||||
|
|
||||||
|
Verifies task submission, FIFO ordering, blocking get_next behavior,
|
||||||
|
result storage and status transitions, task history queries, and queue
|
||||||
|
cleanup on beacon disconnect.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
beacon/tasking.py - TaskManager
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
"""
|
"""
|
||||||
AngelaMos | 2026
|
AngelaMos | 2026
|
||||||
beacon.py
|
beacon.py
|
||||||
|
|
||||||
|
Standalone implant that connects to the C2 server and executes tasks
|
||||||
|
|
||||||
|
Self-contained beacon implant. Connects to the server over WebSocket,
|
||||||
|
sends a REGISTER message with host metadata, then concurrently runs a
|
||||||
|
heartbeat loop and a task receive loop. Handles all C2 commands
|
||||||
|
locally (shell, sysinfo, proclist, upload, download, screenshot,
|
||||||
|
keylogging, persistence, sleep configuration) and sends results back.
|
||||||
|
Reconnects with exponential backoff on failure.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
BeaconConfig - runtime configuration dataclass
|
||||||
|
main - async entry point for the beacon loop
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
// ===========================
|
// ===========================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// App.tsx
|
// App.tsx
|
||||||
|
//
|
||||||
|
// Root application component wrapping the router and toast notifications
|
||||||
|
//
|
||||||
|
// Renders RouterProvider with the configured browser router and a
|
||||||
|
// Sonner Toaster styled to the dark C2 theme. This is the single
|
||||||
|
// component mounted by main.tsx.
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// routers.tsx - provides the router instance
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
// ===================
|
// ===================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// config.ts
|
// config.ts
|
||||||
|
//
|
||||||
|
// Client-side URL constants for API endpoints, WebSocket paths, and routes
|
||||||
|
//
|
||||||
|
// All hard-coded paths live here so components never construct URLs
|
||||||
|
// directly. API_ENDPOINTS covers REST paths, WS_ENDPOINTS covers the
|
||||||
|
// operator WebSocket, ROUTES covers client-side navigation paths, and
|
||||||
|
// STORAGE_KEYS names the localStorage persistence key.
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// core/ws.ts - reads WS_ENDPOINTS.OPERATOR
|
||||||
|
// core/lib/shell.ui.store.ts - reads STORAGE_KEYS.UI
|
||||||
|
// core/app/routers.tsx - reads ROUTES.DASHBOARD
|
||||||
|
// core/app/shell.tsx - reads ROUTES.DASHBOARD
|
||||||
|
// pages/dashboard/index.tsx - reads ROUTES.SESSION
|
||||||
|
// pages/session/index.tsx - reads ROUTES.DASHBOARD
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
export const API_BASE = '/api'
|
export const API_BASE = '/api'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
// ===================
|
// ===================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// routers.tsx
|
// routers.tsx
|
||||||
|
//
|
||||||
|
// React Router browser router with Shell layout and lazy page routes
|
||||||
|
//
|
||||||
|
// All routes nest under the Shell layout element. Dashboard and Session
|
||||||
|
// pages are lazily imported so they split into separate chunks at build
|
||||||
|
// time.
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// config.ts - reads ROUTES.DASHBOARD for the dashboard path
|
||||||
|
// core/app/shell.tsx - Shell is the parent layout element
|
||||||
|
// pages/dashboard/index.tsx - lazy-loaded for /
|
||||||
|
// pages/session/index.tsx - lazy-loaded for /session/:id
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
import { createBrowserRouter, type RouteObject } from 'react-router-dom'
|
import { createBrowserRouter, type RouteObject } from 'react-router-dom'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
// ===========================
|
// ===========================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// shell.tsx
|
// shell.tsx
|
||||||
|
//
|
||||||
|
// Application shell with collapsible sidebar, top header, and page outlet
|
||||||
|
//
|
||||||
|
// Renders the persistent layout used by every page: a sidebar with
|
||||||
|
// navigation links, a header showing the current page title, and an
|
||||||
|
// Outlet for child routes. Sidebar open/collapsed state comes from
|
||||||
|
// useUIStore. An ErrorBoundary and Suspense wrap the Outlet to handle
|
||||||
|
// lazy-loaded pages and runtime errors gracefully.
|
||||||
|
//
|
||||||
|
// Key components:
|
||||||
|
// Shell - layout wrapper rendered as the parent route for all pages
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// config.ts - reads ROUTES.DASHBOARD for the nav link
|
||||||
|
// core/lib/index.ts - imports useUIStore
|
||||||
|
// core/app/routers.tsx - Shell is the element for the root route
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
// ===================
|
// ===================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// shell.ui.store.ts
|
// shell.ui.store.ts
|
||||||
|
//
|
||||||
|
// Zustand store for sidebar open and collapsed UI state
|
||||||
|
//
|
||||||
|
// Manages two boolean flags: sidebarOpen (transient, not persisted)
|
||||||
|
// and sidebarCollapsed (persisted to localStorage). Exposes toggle
|
||||||
|
// and setter actions alongside selector hooks for each flag.
|
||||||
|
//
|
||||||
|
// Key exports:
|
||||||
|
// useUIStore - full Zustand store with sidebar state and actions
|
||||||
|
// useSidebarOpen, useSidebarCollapsed - selector hooks
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// config.ts - reads STORAGE_KEYS.UI for the localStorage key
|
||||||
|
// core/app/shell.tsx - reads and mutates sidebar state
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,24 @@
|
||||||
// ===================
|
// ===================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// types.ts
|
// types.ts
|
||||||
|
//
|
||||||
|
// Zod schemas and TypeScript types for all C2 data structures
|
||||||
|
//
|
||||||
|
// Mirrors the server-side Pydantic models: CommandType, BeaconRecord,
|
||||||
|
// TaskRecord, and TaskResult. Also defines the full set of WebSocket
|
||||||
|
// message schemas and WsServerMessage, a discriminated union that
|
||||||
|
// covers every event the operator channel can emit.
|
||||||
|
//
|
||||||
|
// Key exports:
|
||||||
|
// CommandType - enum of supported beacon commands
|
||||||
|
// BeaconRecord, TaskRecord, TaskResult - core C2 data models
|
||||||
|
// WsServerMessage - discriminated union of all server message types
|
||||||
|
// parseServerMessage - parse and validate a raw WebSocket string
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// core/ws.ts - imports all types and parseServerMessage
|
||||||
|
// pages/dashboard/index.tsx - imports BeaconRecord
|
||||||
|
// pages/session/index.tsx - imports CommandType, TaskResult
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
import { z } from 'zod/v4'
|
import { z } from 'zod/v4'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,25 @@
|
||||||
// ===================
|
// ===================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// ws.ts
|
// ws.ts
|
||||||
|
//
|
||||||
|
// Zustand store and WebSocket hook for the live operator C2 connection
|
||||||
|
//
|
||||||
|
// useC2Store holds all live beacon state: beacon map, task results, and
|
||||||
|
// local-to-server task ID mapping. useOperatorSocket manages the
|
||||||
|
// /ws/operator WebSocket, dispatches incoming server messages into the
|
||||||
|
// store, and reconnects with exponential backoff on disconnect.
|
||||||
|
// sendTask sends a submit_task message to the server.
|
||||||
|
//
|
||||||
|
// Key exports:
|
||||||
|
// useC2Store - Zustand store for all C2 state and actions
|
||||||
|
// useOperatorSocket - hook that owns the WebSocket lifecycle
|
||||||
|
// useBeacons, useBeacon, useTaskResults, useTaskIdMap, useIsConnected - selectors
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// config.ts - reads WS_ENDPOINTS.OPERATOR
|
||||||
|
// core/types.ts - imports BeaconRecord, TaskResult, parseServerMessage
|
||||||
|
// pages/dashboard/index.tsx - calls useBeacons, useIsConnected, useOperatorSocket
|
||||||
|
// pages/session/index.tsx - calls useBeacon, useOperatorSocket, useTaskResults, useTaskIdMap
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// ===========================
|
// ===========================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// main.tsx
|
// main.tsx
|
||||||
|
//
|
||||||
|
// React entry point that mounts App into the DOM root
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
// ===========================
|
// ===========================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// pages/dashboard/index.tsx
|
// pages/dashboard/index.tsx
|
||||||
|
//
|
||||||
|
// Operator dashboard listing all known beacons with live status
|
||||||
|
//
|
||||||
|
// Renders a table of every beacon the C2 server has seen. Active status
|
||||||
|
// and last-seen timestamps update in real time via the operator
|
||||||
|
// WebSocket. Clicking a row navigates to the session page for that
|
||||||
|
// beacon. An empty state is shown when no beacons are registered.
|
||||||
|
//
|
||||||
|
// Key components:
|
||||||
|
// Component (Dashboard) - lazy-loaded route component for /
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// config.ts - reads ROUTES.SESSION for row navigation
|
||||||
|
// core/types.ts - imports BeaconRecord
|
||||||
|
// core/ws.ts - calls useBeacons, useIsConnected, useOperatorSocket
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,22 @@
|
||||||
// ===========================
|
// ===========================
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// pages/session/index.tsx
|
// pages/session/index.tsx
|
||||||
|
//
|
||||||
|
// Interactive terminal session for issuing commands to a single beacon
|
||||||
|
//
|
||||||
|
// Renders a terminal-style interface for a specific beacon: command
|
||||||
|
// input with history navigation and Tab autocomplete, quick-action
|
||||||
|
// buttons for sysinfo/proclist/screenshot, and a scrolling output panel
|
||||||
|
// that updates as task results arrive. Screenshots render inline as
|
||||||
|
// images; all other output renders as preformatted text.
|
||||||
|
//
|
||||||
|
// Key components:
|
||||||
|
// Component (Session) - lazy-loaded route component for /session/:id
|
||||||
|
//
|
||||||
|
// Connects to:
|
||||||
|
// config.ts - reads ROUTES.DASHBOARD for the back link
|
||||||
|
// core/types.ts - imports CommandType, TaskResult
|
||||||
|
// core/ws.ts - calls useBeacon, useOperatorSocket, useTaskResults, useTaskIdMap
|
||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
|
||||||
|
|
@ -1 +1,24 @@
|
||||||
.venv
|
# 2026 | ©AngelaMos LLC
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.venv
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@
|
||||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣴⣦⣤⣴⣿⣷⣦⡔⣿⡄⡘⠂⠠⠈⠛⠿⣿⣿⣮⠻⣿⣿⠟⢡⣿⢏⣼⣿⣿⢡⡸⣿⣧⡀⡻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣴⣦⣤⣴⣿⣷⣦⡔⣿⡄⡘⠂⠠⠈⠛⠿⣿⣿⣮⠻⣿⣿⠟⢡⣿⢏⣼⣿⣿⢡⡸⣿⣧⡀⡻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠲⠶⠶⠮⠭⠭⠍⠛⠋⠀⢈⣋⠀⠀⣠⣎⠺⣿⣶⣄⡉⠛⠶⣶⡶⠟⣡⣾⣿⣿⣿⣾⢡⣍⢹⣷⠀⠐⠈⠻⠿⠿⠿⠿⠿⠿⠻⠻⠟⠟
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠲⠶⠶⠮⠭⠭⠍⠛⠋⠀⢈⣋⠀⠀⣠⣎⠺⣿⣶⣄⡉⠛⠶⣶⡶⠟⣡⣾⣿⣿⣿⣾⢡⣍⢹⣷⠀⠐⠈⠻⠿⠿⠿⠿⠿⠿⠻⠻⠟⠟
|
||||||
------------------
|
------------------
|
||||||
|
|
||||||
|
Public surface of the caesar_cipher package
|
||||||
|
|
||||||
|
Re-exports CaesarCipher and FrequencyAnalyzer so callers can import
|
||||||
|
directly from the package root. Sets the package version string.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
cipher.py - imports CaesarCipher
|
||||||
|
analyzer.py - imports FrequencyAnalyzer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from caesar_cipher.cipher import CaesarCipher
|
from caesar_cipher.cipher import CaesarCipher
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
analyzer.py
|
analyzer.py
|
||||||
|
|
||||||
|
Statistical frequency analysis for ranking Caesar cipher brute-force results
|
||||||
|
|
||||||
|
Provides the FrequencyAnalyzer class that scores decryption candidates
|
||||||
|
by comparing their letter distributions against expected English frequency
|
||||||
|
percentages using a chi-squared test. Lower scores indicate text that
|
||||||
|
more closely matches natural English.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
constants.py - imports ENGLISH_LETTER_FREQUENCIES
|
||||||
|
main.py - crack command passes CaesarCipher.crack() output to rank_candidates()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
cipher.py
|
cipher.py
|
||||||
|
|
||||||
|
Caesar cipher with encrypt, decrypt, and brute-force crack methods
|
||||||
|
|
||||||
|
Provides the CaesarCipher class that performs letter shifting for a given
|
||||||
|
key while preserving case, spaces, and punctuation. The static crack()
|
||||||
|
method generates all 26 possible decryptions without needing the key,
|
||||||
|
leaving ranking to the analyzer layer.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
constants.py - imports UPPERCASE_LETTERS, LOWERCASE_LETTERS, ALPHABET_SIZE
|
||||||
|
main.py - all three CLI commands instantiate CaesarCipher
|
||||||
|
analyzer.py - crack() output is passed to FrequencyAnalyzer for ranking
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from caesar_cipher.constants import (
|
from caesar_cipher.constants import (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
constants.py
|
constants.py
|
||||||
|
|
||||||
|
Shared constants for the Caesar cipher: alphabet definitions and English letter frequency data
|
||||||
|
|
||||||
|
Defines the letter sets used by the cipher for case-aware shifting,
|
||||||
|
the expected frequency percentages for each English letter (used by
|
||||||
|
frequency analysis to score decryption candidates), and the chi-squared
|
||||||
|
threshold for filtering results.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
cipher.py - imports UPPERCASE_LETTERS, LOWERCASE_LETTERS, ALPHABET_SIZE
|
||||||
|
analyzer.py - imports ENGLISH_LETTER_FREQUENCIES
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import string
|
import string
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
main.py
|
main.py
|
||||||
|
|
||||||
|
CLI entry point with encrypt, decrypt, and crack commands via Typer
|
||||||
|
|
||||||
|
Wires together input reading, cipher operations, and formatted Rich output
|
||||||
|
for three commands. The encrypt and decrypt commands require a key; crack
|
||||||
|
tries all 26 shifts and displays the ranked results as a table with the
|
||||||
|
best match highlighted.
|
||||||
|
|
||||||
|
Key exports:
|
||||||
|
app - Typer application, registered as the caesar-cipher CLI entry point
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
cipher.py - instantiates CaesarCipher for all three commands
|
||||||
|
analyzer.py - crack command uses FrequencyAnalyzer to rank candidates
|
||||||
|
utils.py - imports read_input, validate_key, write_output
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
utils.py
|
utils.py
|
||||||
|
|
||||||
|
Input/output helpers and key validation shared across CLI commands
|
||||||
|
|
||||||
|
Handles the three sources of input (positional argument, file, or stdin)
|
||||||
|
and routes output to either stdout or a file. Also validates that the
|
||||||
|
shift key is in the acceptable range before the cipher is constructed.
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
main.py - imports read_input, write_output, validate_key
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
"""
|
"""
|
||||||
ⒸAngelaMos | 2026
|
©AngelaMos | 2026
|
||||||
test_analyzer.py
|
test_analyzer.py
|
||||||
|
|
||||||
|
Tests for FrequencyAnalyzer covering chi-squared scoring and candidate ranking
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
chi-squared scoring distinguishes English text from gibberish
|
||||||
|
empty string returns infinity
|
||||||
|
rank_candidates() orders results by score ascending
|
||||||
|
end-to-end crack-and-rank against a known plaintext
|
||||||
|
|
||||||
|
Connects to:
|
||||||
|
analyzer.py - the class under test
|
||||||
|
cipher.py - crack() output used as input to rank_candidates()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from caesar_cipher.analyzer import FrequencyAnalyzer
|
from caesar_cipher.analyzer import FrequencyAnalyzer
|
||||||
|
|
@ -9,28 +21,43 @@ from caesar_cipher.cipher import CaesarCipher
|
||||||
|
|
||||||
class TestFrequencyAnalyzer:
|
class TestFrequencyAnalyzer:
|
||||||
def test_calculate_chi_squared_english_text(self) -> None:
|
def test_calculate_chi_squared_english_text(self) -> None:
|
||||||
|
"""
|
||||||
|
Confirms real English text scores below a reasonable chi-squared threshold
|
||||||
|
"""
|
||||||
analyzer = FrequencyAnalyzer()
|
analyzer = FrequencyAnalyzer()
|
||||||
english_text = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
|
english_text = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
|
||||||
score = analyzer.calculate_chi_squared(english_text)
|
score = analyzer.calculate_chi_squared(english_text)
|
||||||
assert score < 150
|
assert score < 150
|
||||||
|
|
||||||
def test_calculate_chi_squared_gibberish(self) -> None:
|
def test_calculate_chi_squared_gibberish(self) -> None:
|
||||||
|
"""
|
||||||
|
Confirms text made of rare letters scores above the threshold
|
||||||
|
"""
|
||||||
analyzer = FrequencyAnalyzer()
|
analyzer = FrequencyAnalyzer()
|
||||||
gibberish = "ZZZZZ QQQQQ XXXXX"
|
gibberish = "ZZZZZ QQQQQ XXXXX"
|
||||||
score = analyzer.calculate_chi_squared(gibberish)
|
score = analyzer.calculate_chi_squared(gibberish)
|
||||||
assert score > 100
|
assert score > 100
|
||||||
|
|
||||||
def test_calculate_chi_squared_empty_string(self) -> None:
|
def test_calculate_chi_squared_empty_string(self) -> None:
|
||||||
|
"""
|
||||||
|
Confirms an empty string returns infinity since there are no letters to score
|
||||||
|
"""
|
||||||
analyzer = FrequencyAnalyzer()
|
analyzer = FrequencyAnalyzer()
|
||||||
assert analyzer.calculate_chi_squared("") == float("inf")
|
assert analyzer.calculate_chi_squared("") == float("inf")
|
||||||
|
|
||||||
def test_score_text_lowercase(self) -> None:
|
def test_score_text_lowercase(self) -> None:
|
||||||
|
"""
|
||||||
|
Confirms score_text returns a non-negative float for lowercase input
|
||||||
|
"""
|
||||||
analyzer = FrequencyAnalyzer()
|
analyzer = FrequencyAnalyzer()
|
||||||
score = analyzer.score_text("hello world")
|
score = analyzer.score_text("hello world")
|
||||||
assert isinstance(score, float)
|
assert isinstance(score, float)
|
||||||
assert score >= 0
|
assert score >= 0
|
||||||
|
|
||||||
def test_rank_candidates_orders_by_score(self) -> None:
|
def test_rank_candidates_orders_by_score(self) -> None:
|
||||||
|
"""
|
||||||
|
Confirms candidates are sorted so the most English-like text ranks first
|
||||||
|
"""
|
||||||
analyzer = FrequencyAnalyzer()
|
analyzer = FrequencyAnalyzer()
|
||||||
candidates = [
|
candidates = [
|
||||||
(0,
|
(0,
|
||||||
|
|
@ -48,6 +75,9 @@ class TestFrequencyAnalyzer:
|
||||||
assert ranked[1][2] < ranked[2][2]
|
assert ranked[1][2] < ranked[2][2]
|
||||||
|
|
||||||
def test_rank_candidates_with_actual_cipher(self) -> None:
|
def test_rank_candidates_with_actual_cipher(self) -> None:
|
||||||
|
"""
|
||||||
|
Encrypts known text, brute-forces all shifts, and confirms the correct key ranks first
|
||||||
|
"""
|
||||||
cipher = CaesarCipher(key = 3)
|
cipher = CaesarCipher(key = 3)
|
||||||
plaintext = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
|
plaintext = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
|
||||||
ciphertext = cipher.encrypt(plaintext)
|
ciphertext = cipher.encrypt(plaintext)
|
||||||
|
|
@ -61,6 +91,9 @@ class TestFrequencyAnalyzer:
|
||||||
assert best_text == plaintext
|
assert best_text == plaintext
|
||||||
|
|
||||||
def test_rank_candidates_empty_list(self) -> None:
|
def test_rank_candidates_empty_list(self) -> None:
|
||||||
|
"""
|
||||||
|
Confirms an empty candidate list returns an empty ranked list
|
||||||
|
"""
|
||||||
analyzer = FrequencyAnalyzer()
|
analyzer = FrequencyAnalyzer()
|
||||||
ranked = analyzer.rank_candidates([])
|
ranked = analyzer.rank_candidates([])
|
||||||
assert ranked == []
|
assert ranked == []
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue