remove infra.md.example, infra.md is the source of truth
This commit is contained in:
0
ayn-antivirus/ayn_antivirus/utils/__init__.py
Normal file
0
ayn-antivirus/ayn_antivirus/utils/__init__.py
Normal file
179
ayn-antivirus/ayn_antivirus/utils/helpers.py
Normal file
179
ayn-antivirus/ayn_antivirus/utils/helpers.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""General-purpose utility functions for AYN Antivirus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import psutil
|
||||
|
||||
from ayn_antivirus.constants import SCAN_CHUNK_SIZE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Human-readable formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_size(size_bytes: int | float) -> str:
|
||||
"""Convert bytes to a human-readable string (e.g. ``"14.2 MB"``)."""
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if abs(size_bytes) < 1024:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.1f} PB"
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""Convert seconds to a human-readable duration (e.g. ``"1h 23m 45s"``)."""
|
||||
if seconds < 0:
|
||||
return "0s"
|
||||
td = timedelta(seconds=int(seconds))
|
||||
parts = []
|
||||
total_secs = int(td.total_seconds())
|
||||
|
||||
hours, rem = divmod(total_secs, 3600)
|
||||
minutes, secs = divmod(rem, 60)
|
||||
|
||||
if hours:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{secs}s")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Privilege check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_root() -> bool:
|
||||
"""Return ``True`` if the current process is running as root (UID 0)."""
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System information
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_system_info() -> Dict[str, Any]:
|
||||
"""Collect hostname, OS, kernel, uptime, CPU, and memory details."""
|
||||
mem = psutil.virtual_memory()
|
||||
boot = psutil.boot_time()
|
||||
uptime_secs = psutil.time.time() - boot
|
||||
|
||||
return {
|
||||
"hostname": socket.gethostname(),
|
||||
"os": f"{platform.system()} {platform.release()}",
|
||||
"os_pretty": platform.platform(),
|
||||
"kernel": platform.release(),
|
||||
"architecture": platform.machine(),
|
||||
"cpu_count": psutil.cpu_count(logical=True),
|
||||
"cpu_physical": psutil.cpu_count(logical=False),
|
||||
"cpu_percent": psutil.cpu_percent(interval=0.1),
|
||||
"memory_total": mem.total,
|
||||
"memory_total_human": format_size(mem.total),
|
||||
"memory_available": mem.available,
|
||||
"memory_available_human": format_size(mem.available),
|
||||
"memory_percent": mem.percent,
|
||||
"uptime_seconds": uptime_secs,
|
||||
"uptime_human": format_duration(uptime_secs),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def safe_path(path: str | Path) -> Path:
|
||||
"""Resolve and validate a path.
|
||||
|
||||
Expands ``~``, resolves symlinks, and ensures the result does not
|
||||
escape above the filesystem root via ``..`` traversal.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the path is empty or contains null bytes.
|
||||
"""
|
||||
s = str(path).strip()
|
||||
if not s:
|
||||
raise ValueError("Path must not be empty")
|
||||
if "\x00" in s:
|
||||
raise ValueError("Path must not contain null bytes")
|
||||
|
||||
resolved = Path(os.path.expanduser(s)).resolve()
|
||||
return resolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_id() -> str:
|
||||
"""Return a new UUID4 hex string (32 characters, no hyphens)."""
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def hash_file(path: str | Path, algo: str = "sha256") -> str:
|
||||
"""Return the hex digest of *path* using the specified algorithm.
|
||||
|
||||
Reads the file in chunks of :pydata:`SCAN_CHUNK_SIZE` for efficiency.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
algo:
|
||||
Any algorithm accepted by :func:`hashlib.new`.
|
||||
|
||||
Raises
|
||||
------
|
||||
OSError
|
||||
If the file cannot be opened or read.
|
||||
"""
|
||||
h = hashlib.new(algo)
|
||||
with open(path, "rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(SCAN_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Compiled once at import time.
|
||||
_IPV4_RE = re.compile(
|
||||
r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}"
|
||||
r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$"
|
||||
)
|
||||
_DOMAIN_RE = re.compile(
|
||||
r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+"
|
||||
r"[a-zA-Z]{2,}$"
|
||||
)
|
||||
|
||||
|
||||
def validate_ip(ip: str) -> bool:
|
||||
"""Return ``True`` if *ip* is a valid IPv4 address."""
|
||||
return bool(_IPV4_RE.match(ip.strip()))
|
||||
|
||||
|
||||
def validate_domain(domain: str) -> bool:
|
||||
"""Return ``True`` if *domain* looks like a valid DNS domain name."""
|
||||
d = domain.strip().rstrip(".")
|
||||
if len(d) > 253:
|
||||
return False
|
||||
return bool(_DOMAIN_RE.match(d))
|
||||
101
ayn-antivirus/ayn_antivirus/utils/logger.py
Normal file
101
ayn-antivirus/ayn_antivirus/utils/logger.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Logging setup for AYN Antivirus.
|
||||
|
||||
Provides a one-call ``setup_logging()`` function that configures a
|
||||
rotating file handler and an optional console handler with consistent
|
||||
formatting across the entire application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ayn_antivirus.constants import DEFAULT_LOG_PATH
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format
|
||||
# ---------------------------------------------------------------------------
|
||||
_LOG_FORMAT = "[%(asctime)s] %(levelname)s %(name)s: %(message)s"
|
||||
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# Rotating handler defaults.
|
||||
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
_BACKUP_COUNT = 5
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_dir: str | Path = DEFAULT_LOG_PATH,
|
||||
level: int | str = logging.INFO,
|
||||
console: bool = True,
|
||||
filename: str = "ayn-antivirus.log",
|
||||
) -> logging.Logger:
|
||||
"""Configure the root ``ayn_antivirus`` logger.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
log_dir:
|
||||
Directory for the rotating log file. Created automatically.
|
||||
level:
|
||||
Logging level (``logging.DEBUG``, ``"INFO"``, etc.).
|
||||
console:
|
||||
If ``True``, also emit to stderr.
|
||||
filename:
|
||||
Name of the log file inside *log_dir*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
logging.Logger
|
||||
The configured ``ayn_antivirus`` logger.
|
||||
"""
|
||||
if isinstance(level, str):
|
||||
level = getattr(logging, level.upper(), logging.INFO)
|
||||
|
||||
root = logging.getLogger("ayn_antivirus")
|
||||
root.setLevel(level)
|
||||
|
||||
# Avoid duplicate handlers on repeated calls.
|
||||
if root.handlers:
|
||||
return root
|
||||
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
|
||||
|
||||
# --- Rotating file handler ---
|
||||
log_path = Path(log_dir)
|
||||
try:
|
||||
log_path.mkdir(parents=True, exist_ok=True)
|
||||
fh = RotatingFileHandler(
|
||||
str(log_path / filename),
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
fh.setLevel(level)
|
||||
fh.setFormatter(formatter)
|
||||
root.addHandler(fh)
|
||||
except OSError:
|
||||
# If we can't write to the log dir, fall back to console only.
|
||||
pass
|
||||
|
||||
# --- Console handler ---
|
||||
if console:
|
||||
ch = logging.StreamHandler(sys.stderr)
|
||||
ch.setLevel(level)
|
||||
ch.setFormatter(formatter)
|
||||
root.addHandler(ch)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Return a child logger under the ``ayn_antivirus`` namespace.
|
||||
|
||||
Example::
|
||||
|
||||
logger = get_logger("scanners.file")
|
||||
# → logging.getLogger("ayn_antivirus.scanners.file")
|
||||
"""
|
||||
return logging.getLogger(f"ayn_antivirus.{name}")
|
||||
Reference in New Issue
Block a user