"""Scheduler for recurring scans and signature updates. Wraps the ``schedule`` library to provide cron-like recurring tasks that drive the :class:`ScanEngine` and signature updater in a long-running daemon loop. """ from __future__ import annotations import logging import time from typing import Optional import schedule from ayn_antivirus.config import Config from ayn_antivirus.core.engine import ScanEngine, ScanResult from ayn_antivirus.core.event_bus import EventType, event_bus logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Cron expression helpers # --------------------------------------------------------------------------- def _parse_cron_field(field: str, min_val: int, max_val: int) -> list[int]: """Parse a single cron field (e.g. ``*/5``, ``1,3,5``, ``0-23``, ``*``). Returns a sorted list of matching integer values. """ values: set[int] = set() for part in field.split(","): part = part.strip() # */step if part.startswith("*/"): step = int(part[2:]) values.update(range(min_val, max_val + 1, step)) # range with optional step (e.g. 1-5 or 1-5/2) elif "-" in part: range_part, _, step_part = part.partition("/") lo, hi = range_part.split("-", 1) step = int(step_part) if step_part else 1 values.update(range(int(lo), int(hi) + 1, step)) # wildcard elif part == "*": values.update(range(min_val, max_val + 1)) # literal else: values.add(int(part)) return sorted(values) def _cron_to_schedule(cron_expr: str) -> dict: """Convert a 5-field cron expression into components. Returns a dict with keys ``minutes``, ``hours``, ``days``, ``months``, ``weekdays`` — each a list of integers. Only *minute* and *hour* are used by the ``schedule`` library adapter below; the rest are validated but not fully honoured (``schedule`` lacks calendar-level granularity). """ parts = cron_expr.strip().split() if len(parts) != 5: raise ValueError(f"Expected 5-field cron expression, got: {cron_expr!r}") return { "minutes": _parse_cron_field(parts[0], 0, 59), "hours": _parse_cron_field(parts[1], 0, 23), "days": _parse_cron_field(parts[2], 1, 31), "months": _parse_cron_field(parts[3], 1, 12), "weekdays": _parse_cron_field(parts[4], 0, 6), } # --------------------------------------------------------------------------- # Scheduler # --------------------------------------------------------------------------- class Scheduler: """Manages recurring scan and update jobs. Parameters ---------- config: Application configuration — used to build a :class:`ScanEngine` and read schedule expressions. engine: Optional pre-built engine instance. If ``None``, one is created from *config*. """ def __init__(self, config: Config, engine: Optional[ScanEngine] = None) -> None: self.config = config self.engine = engine or ScanEngine(config) self._scheduler = schedule.Scheduler() # ------------------------------------------------------------------ # Job builders # ------------------------------------------------------------------ def schedule_scan(self, cron_expr: str, scan_type: str = "full") -> None: """Schedule a recurring scan using a cron expression. Parameters ---------- cron_expr: Standard 5-field cron string (``minute hour dom month dow``). scan_type: One of ``"full"``, ``"quick"``, or ``"deep"``. """ parsed = _cron_to_schedule(cron_expr) # ``schedule`` doesn't natively support cron, so we approximate by # scheduling at every matching hour:minute combination. For simple # expressions like ``0 2 * * *`` this is exact. for hour in parsed["hours"]: for minute in parsed["minutes"]: time_str = f"{hour:02d}:{minute:02d}" self._scheduler.every().day.at(time_str).do( self._run_scan, scan_type=scan_type ) logger.info("Scheduled %s scan at %s daily", scan_type, time_str) def schedule_update(self, interval_hours: int = 6) -> None: """Schedule recurring signature updates. Parameters ---------- interval_hours: How often (in hours) to pull fresh signatures. """ self._scheduler.every(interval_hours).hours.do(self._run_update) logger.info("Scheduled signature update every %d hour(s)", interval_hours) # ------------------------------------------------------------------ # Daemon loop # ------------------------------------------------------------------ def run_daemon(self) -> None: """Start the blocking scheduler loop. Runs all pending jobs and sleeps between iterations. Designed to be the main loop of a background daemon process. Press ``Ctrl+C`` (or send ``SIGINT``) to exit cleanly. """ logger.info("AYN scheduler daemon started — %d job(s)", len(self._scheduler.get_jobs())) try: while True: self._scheduler.run_pending() time.sleep(30) except KeyboardInterrupt: logger.info("Scheduler daemon stopped by user") # ------------------------------------------------------------------ # Job implementations # ------------------------------------------------------------------ def _run_scan(self, scan_type: str = "full") -> None: """Execute a scan job.""" logger.info("Starting scheduled %s scan", scan_type) try: if scan_type == "quick": result: ScanResult = self.engine.quick_scan() else: # "full" and "deep" both scan all paths; deep adds process/network # via full_scan on the engine, but here we keep it simple. result = ScanResult() for path in self.config.scan_paths: partial = self.engine.scan_path(path, recursive=True) result.files_scanned += partial.files_scanned result.files_skipped += partial.files_skipped result.threats.extend(partial.threats) logger.info( "Scheduled %s scan complete — %d files, %d threats", scan_type, result.files_scanned, len(result.threats), ) except Exception: logger.exception("Scheduled %s scan failed", scan_type) def _run_update(self) -> None: """Execute a signature update job.""" logger.info("Starting scheduled signature update") try: from ayn_antivirus.signatures.manager import SignatureManager manager = SignatureManager(self.config) summary = manager.update_all() total = summary.get("total_new", 0) errors = summary.get("errors", []) logger.info( "Scheduled signature update complete: %d new, %d errors", total, len(errors), ) if errors: for err in errors: logger.warning("Feed error: %s", err) manager.close() event_bus.publish(EventType.SIGNATURE_UPDATED, { "total_new": total, "feeds": list(summary.get("feeds", {}).keys()), "errors": errors, }) except Exception: logger.exception("Scheduled signature update failed")