"""Small typed records shared by the storage, scanner and UI layers.""" from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") @dataclass class Detection: path: str signature: str action: str = "detected" quarantine_id: str | None = None size: int = 0 detected_at: str = "" id: int | None = None def __post_init__(self) -> None: if not self.detected_at: self.detected_at = now_iso() @dataclass class ScanRecord: id: int | None started_at: str finished_at: str | None scope: str status: str paths: list[str] scanned_files: int = 0 infected_files: int = 0 errors: int = 0 engine_version: str = "" db_version: str = "" duration_seconds: float = 0.0 cancelled: bool = False detections: list[Detection] = field(default_factory=list) @property def scope_label(self) -> str: return "Full disk" if self.scope == "full" else "User homes" @property def threat_count(self) -> int: return max(self.infected_files, len(self.detections)) @dataclass class QuarantineItem: id: str original_path: str quarantine_path: str signature: str sha256: str size: int created_at: str status: str = "quarantined" scan_id: int | None = None @dataclass class RealtimeEvent: created_at: str path: str signature: str action: str detail: str = "" id: int | None = None @dataclass class ClamAVInfo: available: bool path: Path | None = None engine_version: str = "" db_version: str = "" db_path: Path | None = None db_date: str = "" db_age_days: float | None = None error: str = "" @property def db_fresh(self) -> bool: return self.db_age_days is not None and self.db_age_days <= 7 @property def summary(self) -> str: if not self.available: return self.error or "ClamAV not found" bits = [self.engine_version or "ClamAV"] if self.db_version: bits.append(f"signatures {self.db_version}") if self.db_age_days is not None: bits.append(f"{self.db_age_days:.0f}d old") return " ยท ".join(bits) @dataclass class UpdateAsset: id: str name: str size: int content_type: str download_url: str @dataclass class UpdateRelease: id: str tag: str name: str notes: str prerelease: bool published_at: str html_url: str assets: list[UpdateAsset] = field(default_factory=list) @dataclass class UpdateFeed: channel: str latest: UpdateRelease | None releases: list[UpdateRelease] checked_at: str @classmethod def from_api(cls, payload: dict) -> "UpdateFeed": def release_from(item: dict) -> UpdateRelease: assets = [ UpdateAsset( id=str(asset.get("id") or ""), name=str(asset.get("name") or "download"), size=int(asset.get("size") or 0), content_type=str(asset.get("content_type") or ""), download_url=str(asset.get("download_url") or ""), ) for asset in (item.get("assets") or []) if isinstance(asset, dict) ] return UpdateRelease( id=str(item.get("id") or ""), tag=str(item.get("tag") or ""), name=str(item.get("name") or item.get("tag") or ""), notes=str(item.get("notes") or ""), prerelease=bool(item.get("prerelease")), published_at=str(item.get("published_at") or item.get("created_at") or ""), html_url=str(item.get("html_url") or ""), assets=assets, ) releases = [release_from(item) for item in (payload.get("releases") or []) if isinstance(item, dict)] latest_raw = payload.get("latest") latest = release_from(latest_raw) if isinstance(latest_raw, dict) else ( releases[0] if releases else None) return cls( channel=str(payload.get("channel") or "release"), latest=latest, releases=releases, checked_at=str(payload.get("checked_at") or now_iso()), ) def display_name(path: str) -> str: """Basename for a Windows or POSIX path, independent of host OS.""" text = str(path or "") name = Path(text.replace("\\", "/")).name return name or text def human_scope(scope: str) -> str: return "Full disk" if scope == "full" else "User homes" def human_action(action: str) -> str: return { "detected": "Detected", "quarantined": "Quarantined", "deleted": "Deleted", "failed": "Action failed", "ignored": "Ignored", "allowed": "Allowed", "blocked": "Blocked", }.get(action, action.replace("_", " ").title())