feat: did a couple things idk
This commit is contained in:
parent
65e01d055c
commit
f9b5a614d3
|
|
@ -1,7 +1,7 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
|
||||
# Planning docs
|
||||
# dev docs
|
||||
.angelusvigil/
|
||||
|
||||
# Environment
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ async def _get_active_models(
|
|||
Query all active model metadata records
|
||||
"""
|
||||
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()
|
||||
return [{
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ inference.py
|
|||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
except ImportError:
|
||||
ort = None # type: ignore[assignment]
|
||||
ort = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -33,9 +34,9 @@ class InferenceEngine:
|
|||
"""
|
||||
|
||||
def __init__(self, model_dir: str) -> None:
|
||||
self._ae_session: ort.InferenceSession | None = None # type: ignore[union-attr]
|
||||
self._rf_session: ort.InferenceSession | None = None # type: ignore[union-attr]
|
||||
self._if_session: ort.InferenceSession | None = None # type: ignore[union-attr]
|
||||
self._ae_session: ort.InferenceSession | None = None
|
||||
self._rf_session: ort.InferenceSession | None = None
|
||||
self._if_session: ort.InferenceSession | None = None
|
||||
self._scaler_center: np.ndarray | None = None
|
||||
self._scaler_scale: np.ndarray | None = None
|
||||
self._threshold: float = 0.0
|
||||
|
|
@ -127,12 +128,12 @@ class InferenceEngine:
|
|||
"""
|
||||
if self._scaler_center is None or self._scaler_scale is None:
|
||||
return batch
|
||||
return (batch - self._scaler_center) / self._scaler_scale
|
||||
return (batch - self._scaler_center) / self._scaler_scale # type: ignore[no-any-return]
|
||||
|
||||
@staticmethod
|
||||
def _extract_rf_proba(
|
||||
ort_output: list | np.ndarray
|
||||
) -> np.ndarray: # type: ignore[type-arg]
|
||||
ort_output: list[Any] | np.ndarray
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Extract attack probability from skl2onnx RF output format.
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ CATEGORICAL_ENCODERS: dict[str, dict[str, int]] = {
|
|||
"file_extension": EXTENSION_MAP,
|
||||
}
|
||||
|
||||
WINDOWED_FEATURE_NAMES: list[str] = FEATURE_ORDER[23:]
|
||||
|
||||
BOOLEAN_FEATURES: frozenset[str] = frozenset({
|
||||
"has_encoded_chars",
|
||||
"has_double_encoding",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import time
|
|||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import FastAPI
|
||||
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 threat_event as _threat_event_reg # noqa: F401
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.core.detection.inference import InferenceEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -110,7 +114,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||
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
|
||||
configured model directory, returning None if ML
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ class ModelMetadata(TimestampedModel, table=True):
|
|||
__table_args__ = (Index(
|
||||
"idx_model_metadata_active",
|
||||
"model_type",
|
||||
unique=True,
|
||||
postgresql_where=text("is_active = TRUE"),
|
||||
), )
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
main.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
|
@ -22,6 +24,44 @@ DEFAULT_EXPERIMENT_NAME = "angelusvigil-training"
|
|||
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()
|
||||
def serve(
|
||||
host: str = typer.Option("0.0.0.0", help="Bind address"),
|
||||
|
|
@ -92,9 +132,10 @@ def train(
|
|||
)
|
||||
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_test_path = csic_dir / "normalTrafficTest.txt"
|
||||
attack_path = csic_dir / "anomalousTrafficTest.txt"
|
||||
typer.echo(f"Loading CSIC data from {csic_dir}")
|
||||
X_csic, y_csic = load_csic_dataset(
|
||||
|
|
@ -106,6 +147,14 @@ def train(
|
|||
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:
|
||||
from ml.synthetic import generate_mixed_dataset
|
||||
|
||||
|
|
@ -143,6 +192,25 @@ def train(
|
|||
)
|
||||
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}")
|
||||
if result.ensemble_metrics is not None:
|
||||
typer.echo(
|
||||
|
|
|
|||
|
|
@ -51,13 +51,13 @@ class ThreatAutoencoder(nn.Module):
|
|||
"""
|
||||
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:
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import numpy as np
|
|||
|
||||
from app.core.features.encoder import encode_for_inference
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -26,21 +27,6 @@ _DEFAULT_IP = "192.168.1.100"
|
|||
_DEFAULT_UA = ("Mozilla/5.0 (compatible; Konqueror/3.5; Linux)"
|
||||
" 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
|
||||
class CSICRequest:
|
||||
|
|
@ -192,7 +178,7 @@ def load_csic_dataset(
|
|||
entry = csic_to_parsed_entry(req)
|
||||
features = extract_request_features(entry)
|
||||
|
||||
for name in _WINDOWED_FEATURE_NAMES:
|
||||
for name in WINDOWED_FEATURE_NAMES:
|
||||
features[name] = 0.0
|
||||
|
||||
vector = encode_for_inference(features)
|
||||
|
|
@ -211,3 +197,32 @@ def load_csic_dataset(
|
|||
)
|
||||
|
||||
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 sys
|
||||
from pathlib import Path
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -26,24 +27,6 @@ FILES = [
|
|||
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:
|
||||
"""
|
||||
Compute SHA-256 hash of a file
|
||||
|
|
@ -73,7 +56,31 @@ def download_csic(output_dir: Path = DATASET_DIR, ) -> None:
|
|||
print(f"Downloading {filename}...")
|
||||
|
||||
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()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def export_autoencoder(
|
|||
|
||||
torch.onnx.export(
|
||||
model,
|
||||
dummy,
|
||||
dummy, # type: ignore[arg-type]
|
||||
str(path),
|
||||
opset_version=opset,
|
||||
export_params=True,
|
||||
|
|
|
|||
|
|
@ -50,11 +50,11 @@ async def save_model_metadata(
|
|||
|
||||
result = await session.execute(
|
||||
select(ModelMetadata).where(
|
||||
ModelMetadata.model_type == model_type,
|
||||
ModelMetadata.is_active == True, # noqa: E712
|
||||
ModelMetadata.model_type == model_type, # type: ignore[arg-type]
|
||||
ModelMetadata.is_active == True, # type: ignore[arg-type] # noqa: E712
|
||||
))
|
||||
for old in result.scalars().all():
|
||||
await session.delete(old)
|
||||
old.is_active = False
|
||||
await session.flush()
|
||||
|
||||
row = ModelMetadata(
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ orchestrator.py
|
|||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
|
@ -133,13 +135,24 @@ class TrainingOrchestrator:
|
|||
"ensemble_roc_auc": ensemble.roc_auc,
|
||||
})
|
||||
passed = ensemble.passed_gates
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
logger.exception("Ensemble validation failed")
|
||||
print(
|
||||
f" WARNING: validation raised"
|
||||
f" {type(exc).__name__}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
ensemble = None
|
||||
passed = False
|
||||
|
||||
for artifact in self._output_dir.iterdir():
|
||||
experiment.log_artifact(artifact)
|
||||
for name in (
|
||||
AE_FILENAME,
|
||||
RF_FILENAME,
|
||||
IF_FILENAME,
|
||||
SCALER_FILENAME,
|
||||
THRESHOLD_FILENAME,
|
||||
):
|
||||
experiment.log_artifact(self._output_dir / name)
|
||||
|
||||
run_id = experiment.run_id
|
||||
|
||||
|
|
@ -159,7 +172,7 @@ class TrainingOrchestrator:
|
|||
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
|
||||
"""
|
||||
|
|
@ -174,7 +187,7 @@ class TrainingOrchestrator:
|
|||
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
|
||||
"""
|
||||
|
|
@ -184,7 +197,7 @@ class TrainingOrchestrator:
|
|||
)
|
||||
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
|
||||
"""
|
||||
|
|
@ -196,9 +209,9 @@ class TrainingOrchestrator:
|
|||
|
||||
def _export_models(
|
||||
self,
|
||||
ae_result: dict,
|
||||
rf_result: dict,
|
||||
if_result: dict,
|
||||
ae_result: dict[str, Any],
|
||||
rf_result: dict[str, Any],
|
||||
if_result: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
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:
|
||||
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:
|
||||
"""
|
||||
|
|
@ -54,7 +54,7 @@ class FeatureScaler:
|
|||
"""
|
||||
if not self._fitted or self._scaler is None:
|
||||
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:
|
||||
"""
|
||||
|
|
@ -74,14 +74,14 @@ class FeatureScaler:
|
|||
"scale": self._scaler.scale_.tolist(),
|
||||
"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
|
||||
def load_json(cls, path: Path | str) -> FeatureScaler:
|
||||
"""
|
||||
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._scaler = RobustScaler()
|
||||
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.extractor import extract_request_features
|
||||
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
|
||||
from app.core.ingestion.parsers import ParsedLogEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -189,21 +190,6 @@ ATTACK_PATHS: list[str] = [
|
|||
"/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:
|
||||
"""
|
||||
|
|
@ -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)}"
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
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]),
|
||||
response_size=random.randint(0, 5000),
|
||||
user_agent=random.choice(NORMAL_UAS),
|
||||
ip=_random_private_ip(),
|
||||
))
|
||||
else:
|
||||
entries.append(
|
||||
|
|
@ -281,7 +259,6 @@ def generate_sqli_requests(n: int, ) -> list[ParsedLogEntry]:
|
|||
status_code=random.choice([200, 403]),
|
||||
response_size=random.randint(0, 2000),
|
||||
user_agent=random.choice(NORMAL_UAS),
|
||||
ip=_random_private_ip(),
|
||||
))
|
||||
return entries
|
||||
|
||||
|
|
@ -302,7 +279,6 @@ def generate_xss_requests(n: int, ) -> list[ParsedLogEntry]:
|
|||
status_code=200,
|
||||
response_size=random.randint(500, 5000),
|
||||
user_agent=random.choice(NORMAL_UAS),
|
||||
ip=_random_private_ip(),
|
||||
))
|
||||
return entries
|
||||
|
||||
|
|
@ -322,7 +298,6 @@ def generate_traversal_requests(n: int, ) -> list[ParsedLogEntry]:
|
|||
status_code=random.choice([200, 403, 404]),
|
||||
response_size=random.randint(0, 1000),
|
||||
user_agent=random.choice(NORMAL_UAS),
|
||||
ip=_random_private_ip(),
|
||||
))
|
||||
return entries
|
||||
|
||||
|
|
@ -343,7 +318,6 @@ def generate_log4shell_requests(n: int, ) -> list[ParsedLogEntry]:
|
|||
status_code=200,
|
||||
response_size=random.randint(0, 2000),
|
||||
user_agent=payload,
|
||||
ip=_random_private_ip(),
|
||||
))
|
||||
return entries
|
||||
|
||||
|
|
@ -364,7 +338,6 @@ def generate_ssrf_requests(n: int, ) -> list[ParsedLogEntry]:
|
|||
status_code=random.choice([200, 302]),
|
||||
response_size=random.randint(0, 3000),
|
||||
user_agent=random.choice(NORMAL_UAS),
|
||||
ip=_random_private_ip(),
|
||||
))
|
||||
return entries
|
||||
|
||||
|
|
@ -384,7 +357,6 @@ def generate_scanner_requests(n: int, ) -> list[ParsedLogEntry]:
|
|||
status_code=random.choice([200, 301, 403, 404]),
|
||||
response_size=random.randint(0, 500),
|
||||
user_agent=random.choice(SCANNER_UAS),
|
||||
ip=_random_private_ip(),
|
||||
))
|
||||
return entries
|
||||
|
||||
|
|
@ -417,7 +389,7 @@ def _entries_to_vectors(entries: list[ParsedLogEntry], ) -> list[list[float]]:
|
|||
vectors: list[list[float]] = []
|
||||
for entry in entries:
|
||||
features = extract_request_features(entry)
|
||||
for name in _WINDOWED_FEATURE_NAMES:
|
||||
for name in WINDOWED_FEATURE_NAMES:
|
||||
features[name] = 0.0
|
||||
vectors.append(encode_for_inference(features))
|
||||
return vectors
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def train_autoencoder(
|
|||
reconstructed = model(batch)
|
||||
loss = torch.nn.functional.mse_loss(reconstructed, batch)
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
loss.backward() # type: ignore[no-untyped-call]
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
||||
optimizer.step()
|
||||
epoch_loss += loss.item()
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ ignore = [
|
|||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
exclude = ["^alembic/"]
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
|
|
@ -151,6 +152,14 @@ ignore_missing_imports = true
|
|||
module = "app.models.*"
|
||||
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]
|
||||
py-version = "3.12"
|
||||
jobs = 4
|
||||
|
|
@ -175,6 +184,7 @@ ignore-paths = [
|
|||
"^.venv/.*",
|
||||
"^build/.*",
|
||||
"^dist/.*",
|
||||
"^tests/.*",
|
||||
]
|
||||
|
||||
[tool.pylint.messages_control]
|
||||
|
|
@ -201,13 +211,18 @@ disable = [
|
|||
"C0415",
|
||||
"E1102",
|
||||
"R1732",
|
||||
"C0121",
|
||||
"E0601",
|
||||
"E0602",
|
||||
"R0902",
|
||||
]
|
||||
|
||||
[tool.pylint.design]
|
||||
max-args = 12
|
||||
max-attributes = 10
|
||||
max-attributes = 12
|
||||
max-locals = 35
|
||||
max-positional-arguments = 12
|
||||
max-statements = 60
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ test_cli.py
|
|||
"""
|
||||
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
|
|
@ -105,3 +106,38 @@ class TestCLICommands:
|
|||
],
|
||||
)
|
||||
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 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.extractor import extract_request_features
|
||||
from app.core.features.mappings import WINDOWED_FEATURE_NAMES
|
||||
from app.core.ingestion.parsers import ParsedLogEntry
|
||||
from ml.synthetic import (
|
||||
generate_log4shell_requests,
|
||||
|
|
@ -19,22 +20,6 @@ from ml.synthetic import (
|
|||
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:
|
||||
"""
|
||||
Test individual attack and normal traffic generators
|
||||
|
|
@ -152,7 +137,7 @@ class TestGenerators:
|
|||
for gen in generators:
|
||||
for entry in gen(5):
|
||||
features = extract_request_features(entry)
|
||||
for name in _WINDOWED_FEATURE_NAMES:
|
||||
for name in WINDOWED_FEATURE_NAMES:
|
||||
features[name] = 0.0
|
||||
vector = encode_for_inference(features)
|
||||
assert len(vector) == 35
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,23 @@
|
|||
ⒸAngelaMos | 2025
|
||||
__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
|
||||
__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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
__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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,26 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,27 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,26 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
__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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,25 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,27 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
__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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2025
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -259,20 +259,20 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "fakeredis"
|
||||
version = "2.33.0"
|
||||
version = "2.34.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "redis" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
name = "fastapi"
|
||||
version = "0.128.7"
|
||||
version = "0.134.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
|
|
@ -281,9 +281,9 @@ dependencies = [
|
|||
{ name = "typing-extensions" },
|
||||
{ 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 = [
|
||||
{ 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]
|
||||
|
|
@ -331,13 +331,13 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.1.0" },
|
||||
{ name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.33.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.128.7" },
|
||||
{ name = "fakeredis", marker = "extra == 'dev'", specifier = ">=2.34.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" },
|
||||
{ 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 = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" },
|
||||
{ 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-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "redis", specifier = ">=7.1.1" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" },
|
||||
{ name = "redis", specifier = ">=7.2.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.1" },
|
||||
{ name = "time-machine", marker = "extra == 'dev'", specifier = ">=3.2.0" },
|
||||
{ name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6.0.20241004" },
|
||||
]
|
||||
|
|
@ -802,16 +802,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.12.0"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
|
|
@ -975,11 +975,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.1.1"
|
||||
version = "7.2.1"
|
||||
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 = [
|
||||
{ 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]]
|
||||
|
|
@ -1049,27 +1049,27 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.0"
|
||||
version = "0.15.4"
|
||||
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 = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,28 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,28 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,29 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,27 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,27 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
@ -13,11 +31,17 @@ runner = CliRunner()
|
|||
|
||||
class TestEncodeCommand:
|
||||
def test_encode_base64(self) -> None:
|
||||
"""
|
||||
Checks that the encode command outputs the correct base64 string
|
||||
"""
|
||||
result = runner.invoke(app, ["encode", "Hello World"])
|
||||
assert result.exit_code == 0
|
||||
assert "SGVsbG8gV29ybGQ=" in result.output
|
||||
|
||||
def test_encode_hex(self) -> None:
|
||||
"""
|
||||
Checks that the encode command outputs the correct hex string with --format hex
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["encode",
|
||||
|
|
@ -29,6 +53,9 @@ class TestEncodeCommand:
|
|||
assert "48656c6c6f" in result.output
|
||||
|
||||
def test_encode_base32(self) -> None:
|
||||
"""
|
||||
Checks that the encode command outputs the correct base32 string with --format base32
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["encode",
|
||||
|
|
@ -40,6 +67,9 @@ class TestEncodeCommand:
|
|||
assert "JBSWY3DP" in result.output
|
||||
|
||||
def test_encode_url(self) -> None:
|
||||
"""
|
||||
Checks that the encode command percent-encodes special characters with --format url
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["encode",
|
||||
|
|
@ -51,12 +81,18 @@ class TestEncodeCommand:
|
|||
assert "%20" in result.output or "hello" in result.output
|
||||
|
||||
def test_encode_empty_string(self) -> None:
|
||||
"""
|
||||
Checks that encoding an empty string succeeds without error
|
||||
"""
|
||||
result = runner.invoke(app, ["encode", ""])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestDecodeCommand:
|
||||
def test_decode_base64(self) -> None:
|
||||
"""
|
||||
Checks that the decode command recovers 'Hello World' from a known base64 string
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["decode",
|
||||
|
|
@ -66,6 +102,9 @@ class TestDecodeCommand:
|
|||
assert "Hello World" in result.output
|
||||
|
||||
def test_decode_hex(self) -> None:
|
||||
"""
|
||||
Checks that the decode command recovers 'Hello' from a hex string
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["decode",
|
||||
|
|
@ -77,6 +116,9 @@ class TestDecodeCommand:
|
|||
assert "Hello" in result.output
|
||||
|
||||
def test_decode_invalid_base64(self) -> None:
|
||||
"""
|
||||
Checks that decoding garbage input exits with a non-zero code
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["decode",
|
||||
|
|
@ -87,6 +129,9 @@ class TestDecodeCommand:
|
|||
|
||||
class TestDetectCommand:
|
||||
def test_detect_base64(self) -> None:
|
||||
"""
|
||||
Checks that the detect command identifies base64 in its output
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["detect",
|
||||
|
|
@ -96,6 +141,9 @@ class TestDetectCommand:
|
|||
assert "base64" in result.output.lower()
|
||||
|
||||
def test_detect_hex(self) -> None:
|
||||
"""
|
||||
Checks that the detect command identifies hex in its output
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["detect",
|
||||
|
|
@ -105,6 +153,9 @@ class TestDetectCommand:
|
|||
assert "hex" in result.output.lower()
|
||||
|
||||
def test_detect_no_match(self) -> None:
|
||||
"""
|
||||
Checks that the detect command reports no encoding found for plain text
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["detect",
|
||||
|
|
@ -116,6 +167,9 @@ class TestDetectCommand:
|
|||
|
||||
class TestPeelCommand:
|
||||
def test_peel_single_layer(self) -> None:
|
||||
"""
|
||||
Checks that the peel command reports at least one layer for a base64 string
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["peel",
|
||||
|
|
@ -125,6 +179,9 @@ class TestPeelCommand:
|
|||
assert "layer" in result.output.lower()
|
||||
|
||||
def test_peel_no_encoding(self) -> None:
|
||||
"""
|
||||
Checks that the peel command exits cleanly when no encoding is found
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["peel",
|
||||
|
|
@ -135,6 +192,9 @@ class TestPeelCommand:
|
|||
|
||||
class TestChainCommand:
|
||||
def test_chain_single_step(self) -> None:
|
||||
"""
|
||||
Checks that the chain command applies one base64 step correctly
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["chain",
|
||||
|
|
@ -146,6 +206,9 @@ class TestChainCommand:
|
|||
assert "SGVsbG8=" in result.output
|
||||
|
||||
def test_chain_multiple_steps(self) -> None:
|
||||
"""
|
||||
Checks that the chain command applies two steps in sequence without error
|
||||
"""
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["chain",
|
||||
|
|
@ -156,6 +219,9 @@ class TestChainCommand:
|
|||
assert result.exit_code == 0
|
||||
|
||||
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(
|
||||
app,
|
||||
["chain",
|
||||
|
|
@ -168,6 +234,9 @@ class TestChainCommand:
|
|||
|
||||
class TestVersionFlag:
|
||||
def test_version_output(self) -> None:
|
||||
"""
|
||||
Checks that --version prints the tool name and exits cleanly
|
||||
"""
|
||||
result = runner.invoke(app, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "b64tool" in result.output
|
||||
|
|
|
|||
|
|
@ -1,6 +1,26 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
@ -9,17 +29,26 @@ from base64_tool.detector import detect_best, detect_encoding
|
|||
|
||||
class TestDetectBase64:
|
||||
def test_standard_base64(self) -> None:
|
||||
"""
|
||||
Checks that a padded base64 string is detected as base64 with high confidence
|
||||
"""
|
||||
result = detect_best("SGVsbG8gV29ybGQ=")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE64
|
||||
assert result.confidence >= 0.7
|
||||
|
||||
def test_base64_no_padding(self) -> None:
|
||||
"""
|
||||
Checks that base64 without trailing = padding is still detected
|
||||
"""
|
||||
result = detect_best("SGVsbG8gV29ybGQh")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE64
|
||||
|
||||
def test_base64_with_plus_slash(self) -> None:
|
||||
"""
|
||||
Checks that base64 containing + and / characters is detected correctly
|
||||
"""
|
||||
result = detect_best("dGVzdC9wYXRoK3F1ZXJ5")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE64
|
||||
|
|
@ -27,6 +56,9 @@ class TestDetectBase64:
|
|||
|
||||
class TestDetectBase64Url:
|
||||
def test_url_safe_chars(self) -> None:
|
||||
"""
|
||||
Checks that a base64url string with - and _ is detected as base64url or base64
|
||||
"""
|
||||
result = detect_best("dGVzdC1kYXRhX3ZhbHVl")
|
||||
assert result is not None
|
||||
assert result.format in (
|
||||
|
|
@ -37,12 +69,18 @@ class TestDetectBase64Url:
|
|||
|
||||
class TestDetectBase32:
|
||||
def test_standard_base32(self) -> None:
|
||||
"""
|
||||
Checks that a padded uppercase base32 string is detected with sufficient confidence
|
||||
"""
|
||||
result = detect_best("JBSWY3DPEBLW64TMMQ======")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE32
|
||||
assert result.confidence >= 0.6
|
||||
|
||||
def test_base32_uppercase(self) -> None:
|
||||
"""
|
||||
Checks that a short uppercase base32 string is detected
|
||||
"""
|
||||
result = detect_best("JBSWY3DP")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE32
|
||||
|
|
@ -50,17 +88,26 @@ class TestDetectBase32:
|
|||
|
||||
class TestDetectHex:
|
||||
def test_hex_with_letters(self) -> None:
|
||||
"""
|
||||
Checks that a hex string containing alpha characters is detected as hex
|
||||
"""
|
||||
result = detect_best("48656c6c6f20576f726c64")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.HEX
|
||||
assert result.confidence >= 0.6
|
||||
|
||||
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")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.HEX
|
||||
|
||||
def test_pure_digits_not_detected(self) -> None:
|
||||
"""
|
||||
Checks that a digit-only string is not confidently detected as hex
|
||||
"""
|
||||
result = detect_best("1234567890")
|
||||
if result is not None and result.format == EncodingFormat.HEX:
|
||||
assert result.confidence < 0.7
|
||||
|
|
@ -68,11 +115,17 @@ class TestDetectHex:
|
|||
|
||||
class TestDetectUrl:
|
||||
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")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.URL
|
||||
|
||||
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")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.URL
|
||||
|
|
@ -81,22 +134,34 @@ class TestDetectUrl:
|
|||
|
||||
class TestDetectMultiple:
|
||||
def test_returns_sorted_by_confidence(self) -> None:
|
||||
"""
|
||||
Checks that multiple detection results come back sorted highest confidence first
|
||||
"""
|
||||
results = detect_encoding("SGVsbG8gV29ybGQ=")
|
||||
if len(results) > 1:
|
||||
confidences = [r.confidence for r in results]
|
||||
assert confidences == sorted(confidences, reverse = True)
|
||||
|
||||
def test_no_match_returns_empty(self) -> None:
|
||||
"""
|
||||
Checks that plain unencoded text returns no detection results
|
||||
"""
|
||||
results = detect_encoding("hello world")
|
||||
assert results == []
|
||||
|
||||
def test_short_string_returns_empty(self) -> None:
|
||||
"""
|
||||
Checks that strings below the minimum length return no results
|
||||
"""
|
||||
results = detect_encoding("ab")
|
||||
assert results == []
|
||||
|
||||
|
||||
class TestDetectBest:
|
||||
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=")
|
||||
assert result is not None
|
||||
all_results = detect_encoding("SGVsbG8gV29ybGQ=")
|
||||
|
|
@ -104,4 +169,7 @@ class TestDetectBest:
|
|||
assert result.confidence == all_results[0].confidence
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,25 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
@ -27,34 +46,61 @@ from base64_tool.encoders import (
|
|||
|
||||
class TestBase64:
|
||||
def test_encode_simple_text(self) -> None:
|
||||
"""
|
||||
Checks that 'Hello World' encodes to the known base64 string
|
||||
"""
|
||||
assert encode_base64(b"Hello World") == "SGVsbG8gV29ybGQ="
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
assert decode_base64(encode_base64(original)) == original
|
||||
|
||||
def test_encode_empty(self) -> None:
|
||||
"""
|
||||
Checks that encoding empty bytes produces an empty string
|
||||
"""
|
||||
assert encode_base64(b"") == ""
|
||||
|
||||
def test_decode_empty(self) -> None:
|
||||
"""
|
||||
Checks that decoding an empty string produces empty bytes
|
||||
"""
|
||||
assert decode_base64("") == b""
|
||||
|
||||
def test_encode_binary_data(self) -> None:
|
||||
"""
|
||||
Encodes all 256 byte values and checks the roundtrip is lossless
|
||||
"""
|
||||
data = bytes(range(256))
|
||||
assert decode_base64(encode_base64(data)) == data
|
||||
|
||||
def test_decode_with_whitespace(self) -> None:
|
||||
"""
|
||||
Checks that a base64 string split across newlines still decodes correctly
|
||||
"""
|
||||
encoded = "SGVs\nbG8g\nV29y\nbGQ="
|
||||
assert decode_base64(encoded) == b"Hello World"
|
||||
|
||||
def test_decode_invalid_raises(self) -> None:
|
||||
"""
|
||||
Checks that decoding garbage characters raises an exception
|
||||
"""
|
||||
with pytest.raises((ValueError, binascii.Error)):
|
||||
decode_base64("!!!invalid!!!")
|
||||
|
||||
def test_encode_unicode(self) -> None:
|
||||
"""
|
||||
Checks that multi-byte unicode survives a base64 roundtrip
|
||||
"""
|
||||
data = "Hello 世界".encode()
|
||||
decoded = decode_base64(encode_base64(data))
|
||||
assert decoded == data
|
||||
|
|
@ -62,81 +108,141 @@ class TestBase64:
|
|||
|
||||
class TestBase64Url:
|
||||
def test_encode_with_url_chars(self) -> None:
|
||||
"""
|
||||
Checks that URL-safe base64 never emits + or / characters
|
||||
"""
|
||||
data = b"\xfb\xff\xfe"
|
||||
encoded = encode_base64url(data)
|
||||
assert "+" not in encoded
|
||||
assert "/" not in encoded
|
||||
|
||||
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"))
|
||||
assert result == b"test/path+query"
|
||||
|
||||
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"
|
||||
assert decode_base64url(encode_base64url(original)) == original
|
||||
|
||||
|
||||
class TestBase32:
|
||||
def test_encode_simple(self) -> None:
|
||||
"""
|
||||
Checks that 'Hello' encodes to its known base32 representation
|
||||
"""
|
||||
assert encode_base32(b"Hello") == "JBSWY3DP"
|
||||
|
||||
def test_decode_simple(self) -> None:
|
||||
"""
|
||||
Checks that a known base32 string decodes to 'Hello'
|
||||
"""
|
||||
assert decode_base32("JBSWY3DP") == b"Hello"
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
"""
|
||||
Encodes then decodes a short sentence and checks it matches the original
|
||||
"""
|
||||
original = b"Base32 encoding test"
|
||||
assert decode_base32(encode_base32(original)) == original
|
||||
|
||||
def test_decode_lowercase_accepted(self) -> None:
|
||||
"""
|
||||
Checks that lowercase base32 input is accepted and decoded correctly
|
||||
"""
|
||||
assert decode_base32("jbswy3dp") == b"Hello"
|
||||
|
||||
def test_decode_with_padding(self) -> None:
|
||||
"""
|
||||
Checks that a padded base32 string decodes to 'Hello World'
|
||||
"""
|
||||
assert decode_base32("JBSWY3DPEBLW64TMMQ======") == b"Hello World"
|
||||
|
||||
|
||||
class TestHex:
|
||||
def test_encode_simple(self) -> None:
|
||||
"""
|
||||
Checks that bytes \\xca\\xfe encode to the hex string 'cafe'
|
||||
"""
|
||||
assert encode_hex(b"\xca\xfe") == "cafe"
|
||||
|
||||
def test_decode_simple(self) -> None:
|
||||
"""
|
||||
Checks that 'cafe' decodes to the bytes \\xca\\xfe
|
||||
"""
|
||||
assert decode_hex("cafe") == b"\xca\xfe"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
def test_decode_uppercase(self) -> None:
|
||||
"""
|
||||
Checks that uppercase hex input decodes correctly
|
||||
"""
|
||||
assert decode_hex("CAFE") == b"\xca\xfe"
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
"""
|
||||
Encodes then decodes a known string and checks no data is lost
|
||||
"""
|
||||
original = b"Hello World"
|
||||
assert decode_hex(encode_hex(original)) == original
|
||||
|
||||
|
||||
class TestUrl:
|
||||
def test_encode_special_chars(self) -> None:
|
||||
"""
|
||||
Checks that spaces and ampersands are percent-encoded
|
||||
"""
|
||||
result = encode_url(b"hello world&foo=bar")
|
||||
assert " " not in result
|
||||
assert "&" not in result
|
||||
|
||||
def test_decode_percent_encoded(self) -> None:
|
||||
"""
|
||||
Checks that %20 decodes to a space
|
||||
"""
|
||||
assert decode_url("hello%20world") == b"hello world"
|
||||
|
||||
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"
|
||||
assert decode_url(encode_url(original)) == original
|
||||
|
||||
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)
|
||||
assert "+" in result
|
||||
assert "%20" not in result
|
||||
|
||||
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"
|
||||
|
||||
|
||||
|
|
@ -146,15 +252,24 @@ class TestRegistryDispatch:
|
|||
self,
|
||||
fmt: EncodingFormat,
|
||||
) -> None:
|
||||
"""
|
||||
Checks that all registered formats produce a lossless roundtrip via encode() and decode()
|
||||
"""
|
||||
original = b"roundtrip test data"
|
||||
encoded = encode(original, fmt)
|
||||
decoded = decode(encoded, fmt)
|
||||
assert decoded == original
|
||||
|
||||
def test_try_decode_valid(self) -> None:
|
||||
"""
|
||||
Checks that try_decode returns the correct bytes for valid input
|
||||
"""
|
||||
result = try_decode("SGVsbG8=", EncodingFormat.BASE64)
|
||||
assert result == b"Hello"
|
||||
|
||||
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)
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
@ -10,6 +27,9 @@ from base64_tool.peeler import peel
|
|||
|
||||
class TestSingleLayer:
|
||||
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)
|
||||
result = peel(encoded)
|
||||
assert result.success is True
|
||||
|
|
@ -18,6 +38,9 @@ class TestSingleLayer:
|
|||
assert result.final_output == b"Hello World"
|
||||
|
||||
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)
|
||||
result = peel(encoded)
|
||||
assert result.success is True
|
||||
|
|
@ -25,6 +48,9 @@ class TestSingleLayer:
|
|||
assert result.final_output == b"Hello World"
|
||||
|
||||
def test_peel_base32(self) -> None:
|
||||
"""
|
||||
Checks that a single base32 layer is successfully detected and peeled
|
||||
"""
|
||||
encoded = encode(b"Hello World", EncodingFormat.BASE32)
|
||||
result = peel(encoded)
|
||||
assert result.success is True
|
||||
|
|
@ -33,6 +59,9 @@ class TestSingleLayer:
|
|||
|
||||
class TestMultiLayer:
|
||||
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)
|
||||
step2 = encode(step1.encode("utf-8"), EncodingFormat.HEX)
|
||||
result = peel(step2)
|
||||
|
|
@ -41,6 +70,9 @@ class TestMultiLayer:
|
|||
assert b"secret payload" in result.final_output
|
||||
|
||||
def test_base64_double_encoded(self) -> None:
|
||||
"""
|
||||
Checks that base64 applied twice is unwrapped through both layers
|
||||
"""
|
||||
step1 = encode(b"double layer", EncodingFormat.BASE64)
|
||||
step2 = encode(step1.encode("utf-8"), EncodingFormat.BASE64)
|
||||
result = peel(step2)
|
||||
|
|
@ -50,20 +82,32 @@ class TestMultiLayer:
|
|||
|
||||
class TestPeelEdgeCases:
|
||||
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")
|
||||
assert result.success is False
|
||||
assert len(result.layers) == 0
|
||||
|
||||
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)
|
||||
result = peel(encoded, max_depth = 0)
|
||||
assert len(result.layers) == 0
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
"""
|
||||
Checks that an empty string results in a failed peel
|
||||
"""
|
||||
result = peel("")
|
||||
assert result.success is False
|
||||
|
||||
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)
|
||||
result = peel(encoded)
|
||||
if result.success and result.layers:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,26 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
@ -25,15 +45,24 @@ from base64_tool.encoders import (
|
|||
class TestBase64Properties:
|
||||
@given(st.binary())
|
||||
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
|
||||
|
||||
@given(st.binary(min_size=1))
|
||||
def test_encoded_is_ascii(self, data: bytes) -> None:
|
||||
"""
|
||||
Checks that base64 output is always valid ASCII
|
||||
"""
|
||||
encoded = encode_base64(data)
|
||||
encoded.encode("ascii")
|
||||
|
||||
@given(st.binary())
|
||||
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)
|
||||
if encoded:
|
||||
assert len(encoded) % 4 == 0
|
||||
|
|
@ -42,10 +71,16 @@ class TestBase64Properties:
|
|||
class TestBase64UrlProperties:
|
||||
@given(st.binary())
|
||||
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
|
||||
|
||||
@given(st.binary(min_size=1))
|
||||
def test_no_standard_base64_chars(self, data: bytes) -> None:
|
||||
"""
|
||||
Checks that base64url output never contains + or /
|
||||
"""
|
||||
encoded = encode_base64url(data)
|
||||
assert "+" not in encoded
|
||||
assert "/" not in encoded
|
||||
|
|
@ -54,15 +89,24 @@ class TestBase64UrlProperties:
|
|||
class TestBase32Properties:
|
||||
@given(st.binary())
|
||||
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
|
||||
|
||||
@given(st.binary(min_size=1))
|
||||
def test_encoded_is_uppercase(self, data: bytes) -> None:
|
||||
"""
|
||||
Checks that base32 output is always uppercase
|
||||
"""
|
||||
encoded = encode_base32(data)
|
||||
assert encoded == encoded.upper()
|
||||
|
||||
@given(st.binary())
|
||||
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)
|
||||
if encoded:
|
||||
assert len(encoded) % 8 == 0
|
||||
|
|
@ -71,14 +115,23 @@ class TestBase32Properties:
|
|||
class TestHexProperties:
|
||||
@given(st.binary())
|
||||
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
|
||||
|
||||
@given(st.binary(min_size=1))
|
||||
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
|
||||
|
||||
@given(st.binary())
|
||||
def test_encoded_is_hex_chars_only(self, data: bytes) -> None:
|
||||
"""
|
||||
Checks that hex output contains only lowercase hex characters
|
||||
"""
|
||||
encoded = encode_hex(data)
|
||||
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"))))
|
||||
@settings(max_examples=200)
|
||||
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")
|
||||
assert decode_url(encode_url(data)) == data
|
||||
|
||||
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
|
||||
@settings(max_examples=200)
|
||||
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")
|
||||
assert decode_url(encode_url(data, form=True), form=True) == data
|
||||
|
||||
|
|
@ -100,6 +159,9 @@ class TestUrlProperties:
|
|||
class TestCrossFormatProperties:
|
||||
@given(st.binary(min_size=1, max_size=256))
|
||||
def test_all_formats_roundtrip(self, data: bytes) -> None:
|
||||
"""
|
||||
Checks that arbitrary binary data roundtrips losslessly through all non-URL formats
|
||||
"""
|
||||
for fmt in (
|
||||
EncodingFormat.BASE64,
|
||||
EncodingFormat.BASE64URL,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
__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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,23 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,25 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
"""
|
||||
AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
// ===========================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
// ===========================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,25 @@
|
|||
// ===================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// ===========================
|
||||
// © AngelaMos | 2026
|
||||
// main.tsx
|
||||
//
|
||||
// React entry point that mounts App into the DOM root
|
||||
// ===========================
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
// ===========================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,22 @@
|
|||
// ===========================
|
||||
// © AngelaMos | 2026
|
||||
// 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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2026
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2026
|
||||
©AngelaMos | 2026
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2026
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2026
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2026
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
"""
|
||||
ⒸAngelaMos | 2026
|
||||
©AngelaMos | 2026
|
||||
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
|
||||
|
|
@ -9,28 +21,43 @@ from caesar_cipher.cipher import CaesarCipher
|
|||
|
||||
class TestFrequencyAnalyzer:
|
||||
def test_calculate_chi_squared_english_text(self) -> None:
|
||||
"""
|
||||
Confirms real English text scores below a reasonable chi-squared threshold
|
||||
"""
|
||||
analyzer = FrequencyAnalyzer()
|
||||
english_text = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
|
||||
score = analyzer.calculate_chi_squared(english_text)
|
||||
assert score < 150
|
||||
|
||||
def test_calculate_chi_squared_gibberish(self) -> None:
|
||||
"""
|
||||
Confirms text made of rare letters scores above the threshold
|
||||
"""
|
||||
analyzer = FrequencyAnalyzer()
|
||||
gibberish = "ZZZZZ QQQQQ XXXXX"
|
||||
score = analyzer.calculate_chi_squared(gibberish)
|
||||
assert score > 100
|
||||
|
||||
def test_calculate_chi_squared_empty_string(self) -> None:
|
||||
"""
|
||||
Confirms an empty string returns infinity since there are no letters to score
|
||||
"""
|
||||
analyzer = FrequencyAnalyzer()
|
||||
assert analyzer.calculate_chi_squared("") == float("inf")
|
||||
|
||||
def test_score_text_lowercase(self) -> None:
|
||||
"""
|
||||
Confirms score_text returns a non-negative float for lowercase input
|
||||
"""
|
||||
analyzer = FrequencyAnalyzer()
|
||||
score = analyzer.score_text("hello world")
|
||||
assert isinstance(score, float)
|
||||
assert score >= 0
|
||||
|
||||
def test_rank_candidates_orders_by_score(self) -> None:
|
||||
"""
|
||||
Confirms candidates are sorted so the most English-like text ranks first
|
||||
"""
|
||||
analyzer = FrequencyAnalyzer()
|
||||
candidates = [
|
||||
(0,
|
||||
|
|
@ -48,6 +75,9 @@ class TestFrequencyAnalyzer:
|
|||
assert ranked[1][2] < ranked[2][2]
|
||||
|
||||
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)
|
||||
plaintext = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG"
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
|
|
@ -61,6 +91,9 @@ class TestFrequencyAnalyzer:
|
|||
assert best_text == plaintext
|
||||
|
||||
def test_rank_candidates_empty_list(self) -> None:
|
||||
"""
|
||||
Confirms an empty candidate list returns an empty ranked list
|
||||
"""
|
||||
analyzer = FrequencyAnalyzer()
|
||||
ranked = analyzer.rank_candidates([])
|
||||
assert ranked == []
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue