BoltClam 1.0.0: ClamAV desktop protection with Bolt sign-in

Adds the PyQt6 Windows client (Homepage, Scan & results, About & updates, Settings), ClamAV scanning and quarantine, browser-download watch with an exclusive scan gate, Bolt loopback/PKCE sign-in with DPAPI key storage, Gitea release feed with checksum verification, opt-in diagnostics, and Windows/portable build tooling.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
TheMajesticMagician
2026-10-25 16:21:14 -06:00
co-authored by Claude Code
commit addd00364d
76 changed files with 12503 additions and 0 deletions
View File
+32
View File
@@ -0,0 +1,32 @@
"""Test bootstrap: never touch the real BoltClam profile or Downloads."""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
import pytest # noqa: E402
from boltclam import paths # noqa: E402
@pytest.fixture
def isolated_env(tmp_path: Path, monkeypatch) -> Path:
monkeypatch.setenv("BOLTCLAM_CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("BOLTCLAM_DATA_DIR", str(tmp_path / "data"))
paths.ensure_dirs()
return tmp_path
@pytest.fixture(scope="session")
def qapp():
from PyQt6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
return app
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
import base64
import hashlib
import http.server
import json
import socket
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pytest
from boltclam.auth import (
AuthError,
AuthService,
CredentialStore,
LoopbackAuthReceiver,
pkce_pair,
)
from boltclam.config import AccountCache
ACCOUNT = {
"user": {"id": "1", "email": "aaron@example.com", "name": "Aaron",
"username": "aaron"},
"subscription": {"tier": "premium", "label": "Premium", "status": "active"},
"balance": {"weekly_remaining": 100, "topup": 0, "allowed": True},
"features": {"licensed": True},
}
class _Handler(http.server.BaseHTTPRequestHandler):
def _json(self, status: int, payload: dict) -> None:
data = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self) -> None: # noqa: N802
if self.path.startswith("/boltclam/account"):
key = self.headers.get("X-Desk-Api-Key", "")
if key in ("bolt_live_good", "bolt_live_new"):
self._json(200, ACCOUNT)
else:
self._json(401, {"error": "invalid_credentials"})
return
self._json(404, {"error": "not_found"})
def do_POST(self) -> None: # noqa: N802
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
if self.path == "/desk/auth/oauth/exchange":
payload = json.loads(raw or b"{}")
self.server.exchanges.append(payload) # type: ignore[attr-defined]
self._json(200, {"api_key": "bolt_live_new", "user": ACCOUNT["user"]})
return
if self.path == "/desk/auth/logout":
self._json(200, {})
return
self._json(404, {"error": "not_found"})
def log_message(self, fmt: str, *args) -> None:
return
@pytest.fixture
def web_server():
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
server.exchanges = [] # type: ignore[attr-defined]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def _auth(tmp_path, base_url) -> AuthService:
return AuthService(
base_url, install_id="test-install",
credentials=CredentialStore(tmp_path / "credentials.dat"),
account_cache=AccountCache(tmp_path / "account.json"))
def test_pkce_pair_matches_s256():
verifier, challenge = pkce_pair()
expected = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
assert challenge == expected
assert 43 <= len(verifier) <= 128
def test_credential_store_fallback_round_trip(tmp_path):
store = CredentialStore(tmp_path / "credentials.dat")
store.save("bolt_live_secret")
assert store.load() == "bolt_live_secret"
store.delete()
assert store.load() is None
def test_key_login_restore_and_logout(tmp_path, web_server):
base = f"http://127.0.0.1:{web_server.server_address[1]}"
auth = _auth(tmp_path, base)
session = auth.login_with_key("bolt_live_good")
assert session.api_key == "bolt_live_good"
assert session.account["user"]["email"] == "aaron@example.com"
assert auth.stored_key() == "bolt_live_good"
restored = auth.restore()
assert restored.account["subscription"]["tier"] == "premium"
assert restored.offline is False
auth.logout()
assert auth.stored_key() is None
assert auth.account_cache.load() is None
def test_key_login_rejects_a_bad_key(tmp_path, web_server):
base = f"http://127.0.0.1:{web_server.server_address[1]}"
auth = _auth(tmp_path, base)
with pytest.raises(AuthError):
auth.login_with_key("bolt_live_wrong")
def test_browser_login_completes_the_loopback_pkce_handoff(tmp_path, web_server):
base = f"http://127.0.0.1:{web_server.server_address[1]}"
auth = _auth(tmp_path, base)
opened: dict[str, str] = {}
def open_url(url: str) -> bool:
opened["url"] = url
parsed = urlparse(url)
query = parse_qs(parsed.query)
redirect_uri = query["redirect_uri"][0]
state = query["state"][0]
def callback() -> None:
time.sleep(0.2)
with urllib.request.urlopen(
f"{redirect_uri}?code=one-time&state={state}", timeout=5) as response:
response.read()
threading.Thread(target=callback, daemon=True).start()
return True
session = auth.browser_login(open_url, timeout=10)
assert session.api_key == "bolt_live_new"
assert auth.stored_key() == "bolt_live_new"
exchange = web_server.exchanges[-1] # type: ignore[attr-defined]
assert exchange["client"] == "boltclam"
assert exchange["code"] == "one-time"
assert exchange["code_verifier"]
assert exchange["redirect_uri"].startswith("http://127.0.0.1:")
assert "device_name=BoltClam" in opened["url"]
def test_loopback_rejects_the_wrong_state():
receiver = LoopbackAuthReceiver("expected-state")
receiver.start()
try:
try:
urllib.request.urlopen(
f"{receiver.redirect_uri}?code=bad&state=wrong", timeout=5).read()
assert False, "expected a 400"
except urllib.error.HTTPError as exc:
assert exc.code == 400
assert receiver.event.is_set() is False
with urllib.request.urlopen(
f"{receiver.redirect_uri}?code=good&state=expected-state",
timeout=5) as response:
assert response.status == 200
result = receiver.wait(2)
assert result["code"] == "good"
finally:
receiver.close()
def test_restore_offline_uses_the_cached_account(tmp_path):
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
credential_path = tmp_path / "credentials.dat"
CredentialStore(credential_path).save("bolt_live_cached")
cache = AccountCache(tmp_path / "account.json")
cache.save(ACCOUNT)
auth = AuthService(
f"http://127.0.0.1:{port}", install_id="offline",
credentials=CredentialStore(credential_path), account_cache=cache)
session = auth.restore()
assert session.offline is True
assert session.account["user"]["email"] == "aaron@example.com"
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import os
import stat
import time
from pathlib import Path
import pytest
from boltclam.clamav import (
ClamAVService,
build_freshclam_args,
build_scan_args,
database_age,
database_files,
find_clamscan,
parse_clamscan_line,
parse_extra_options,
parse_version_output,
scan_targets,
)
def _script(path: Path, body: str = "") -> Path:
path.write_text("#!/bin/sh\n" + body, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR)
return path
def test_parse_version_output():
assert parse_version_output("ClamAV 1.4.3/27452/Wed Oct 22 07:17:23 2026") == (
"1.4.3", "27452", "Wed Oct 22 07:17:23 2026")
assert parse_version_output("ClamAV 1.4.0") == ("1.4.0", "", "")
assert parse_version_output("nonsense") == ("", "", "")
def test_parse_extra_options_handles_windows_quotes():
options = parse_extra_options(
'--max-filesize=200M --exclude-dir="C:\\Program Files\\Skip" '
'--database="C:\\My DB"')
assert options == [
"--max-filesize=200M",
"--exclude-dir=C:\\Program Files\\Skip",
"--database=C:\\My DB",
]
def test_parse_extra_options_refuses_destructive_flags():
for text in ("--remove=yes", "--move=C:\\Quarantine", "--copy=/tmp"):
with pytest.raises(ValueError):
parse_extra_options(text)
def test_parse_extra_options_rejects_malformed_quoting():
with pytest.raises(ValueError):
parse_extra_options('--exclude-dir="unterminated')
def test_build_scan_args_uses_managed_database_and_excludes(tmp_path):
db = tmp_path / "db"
db.mkdir()
(db / "main.cvd").write_bytes(b"x")
quarantine = tmp_path / "quarantine"
temp = tmp_path / "temp"
args = build_scan_args(
Path("/usr/bin/clamscan"), [tmp_path / "target"],
database_dir=db, quarantine_dir=quarantine, temp_dir=temp,
extra_options=["--max-filesize=200M"])
assert args[0].endswith("clamscan")
assert "--recursive=yes" in args
assert "--infected" in args
assert f"--database={db}" in args
assert any(arg.startswith("--exclude-dir=") for arg in args)
assert "--max-filesize=200M" in args
assert args[-1] == str(tmp_path / "target")
def test_build_scan_args_omits_database_when_no_signatures(tmp_path):
args = build_scan_args(Path("/usr/bin/clamscan"), [tmp_path])
assert not any(arg.startswith("--database=") for arg in args)
def test_parse_clamscan_line_detection_windows_path():
event = parse_clamscan_line(
r"C:\Users\aaron\Downloads\invoice.pdf.exe: Win.Trojan.Agent-123 FOUND")
assert event == {
"kind": "detection",
"path": r"C:\Users\aaron\Downloads\invoice.pdf.exe",
"signature": "Win.Trojan.Agent-123",
}
def test_parse_clamscan_line_summary_progress_and_errors():
assert parse_clamscan_line("Scanned files: 18234")["key"] == "scanned_files"
assert parse_clamscan_line("Scanned files: 18234")["value"] == "18234"
assert parse_clamscan_line("Engine version: 1.4.3")["value"] == "1.4.3"
assert parse_clamscan_line("Scanning /home/a/file")["kind"] == "progress"
assert parse_clamscan_line("/home/a/file: Access denied")["kind"] == "error"
assert parse_clamscan_line("/home/a/file: Size limit exceeded")["kind"] == "skipped"
assert parse_clamscan_line("") is None
def test_scan_targets_deduplicates_injected_profiles(tmp_path):
one = tmp_path / "one"
two = tmp_path / "two"
one.mkdir()
two.mkdir()
targets = scan_targets("homes", profiles=[one, two, one, Path("/does/not/exist")])
assert targets == [one.resolve(), two.resolve()]
def test_scan_targets_full_uses_injected_drives(tmp_path):
drive = tmp_path / "drive"
drive.mkdir()
assert scan_targets("full", drives=[drive]) == [drive.resolve()]
def test_database_detection_and_age(tmp_path):
db = tmp_path / "db"
db.mkdir()
assert database_files(db) == []
main = db / "main.cvd"
main.write_bytes(b"signatures")
old = time.time() - 3 * 86400
os.utime(main, (old, old))
assert database_files(db) == [main]
date, age = database_age(db)
assert date
assert 2.8 < age < 3.2
def test_clamav_service_reads_a_fake_engine(tmp_path):
fake = _script(tmp_path / "clamscan", 'echo "ClamAV 1.4.3/27452/Wed Oct 22 07:17:23 2026"\n')
service = ClamAVService(str(fake))
info = service.info(force=True)
assert info.available is True
assert info.engine_version == "1.4.3"
assert info.db_version == "27452"
assert info.path == fake
assert service.info(force=True).summary.startswith("1.4.3")
def test_find_clamscan_accepts_a_file_or_directory(tmp_path):
fake = _script(tmp_path / "clamscan")
found, error = find_clamscan(str(fake))
assert found == fake and error == ""
found, error = find_clamscan(str(tmp_path))
assert found == fake and error == ""
missing, error = find_clamscan(str(tmp_path / "missing"))
assert missing is None and "does not exist" in error
def test_freshclam_arguments_target_managed_database(tmp_path):
args = build_freshclam_args(Path("/usr/bin/freshclam"), tmp_path / "db")
assert "--foreground" in args
assert "--stdout" in args
assert f"--datadir={tmp_path / 'db'}" in args
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import json
from boltclam.config import AccountCache, AppSettings, SettingsStore
def test_defaults_are_normalized_and_install_id_is_generated(isolated_env):
settings = SettingsStore().load()
assert settings.update_channel == "release"
assert settings.scan_scope == "homes"
assert settings.auto_action == "quarantine"
assert settings.install_id
assert len(settings.install_id) == 32
def test_invalid_values_fall_back_to_defaults(isolated_env):
settings = AppSettings(
update_channel="nightly",
scan_scope="somewhere",
auto_action="explode",
debug_logging=True,
logging_enabled=False,
api_base_url="",
).normalized()
assert settings.update_channel == "release"
assert settings.scan_scope == "homes"
assert settings.auto_action == "quarantine"
assert settings.debug_logging is False
assert settings.api_base_url == "https://api.themajesticnetwork.com"
assert settings.install_id
def test_round_trip_and_atomic_save(isolated_env):
store = SettingsStore()
settings = store.load()
saved = store.update(update_channel="beta", extra_options="--max-filesize=200M",
extra_watch_dirs=["~/Downloads/Test", ""])
reloaded = SettingsStore().load()
assert reloaded.update_channel == "beta"
assert reloaded.extra_options == "--max-filesize=200M"
assert len(reloaded.extra_watch_dirs) == 1
assert saved.install_id == reloaded.install_id
assert json.loads(store.path.read_text())["update_channel"] == "beta"
def test_corrupt_settings_are_backed_up_and_defaults_load(isolated_env):
store = SettingsStore()
store.path.parent.mkdir(parents=True, exist_ok=True)
store.path.write_text("{not json", encoding="utf-8")
loaded = SettingsStore().load()
assert loaded.update_channel == "release"
backups = list(store.path.parent.glob("settings.corrupt-*.json"))
assert len(backups) == 1
def test_public_snapshot_does_not_expose_install_id(isolated_env):
settings = SettingsStore().load()
public = settings.public_snapshot()
assert "install_id" not in public
assert public["update_channel"] == "release"
def test_account_cache_round_trip_and_clear(isolated_env):
cache = AccountCache()
account = {"user": {"email": "a@example.com"}, "subscription": {"tier": "premium"}}
cache.save(account)
assert cache.load() == account
cache.clear()
assert cache.load() is None
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import zipfile
from pathlib import Path
from boltclam import paths
from boltclam.config import SettingsStore
from boltclam.logging_setup import redact
from boltclam.logs import build_bundle, redact_paths, sanitize_text, upload_bundle
from boltclam.models import ClamAVInfo, Detection
from boltclam.storage import Storage
from boltclam.system_info import collect
def test_redaction_covers_tokens_and_emails():
text = "key=bolt_live_abcdef password=hunter2 user aaron@example.com"
redacted = sanitize_text(text)
assert "bolt_live_abcdef" not in redacted
assert "hunter2" not in redacted
assert "aaron@example.com" not in redacted
assert "[email]" in redacted or "[redacted]" in redacted
def test_path_redaction_uses_userprofile(isolated_env):
sample = str(Path.home() / "Downloads" / "bad.exe")
assert "%USERPROFILE%" in redact_paths(sample)
assert "Downloads" in redact_paths(sample)
def test_bundle_contains_redacted_logs_and_scan_summary(isolated_env):
settings = SettingsStore().load()
storage = Storage()
scan_id = storage.create_scan("homes", [str(Path.home() / "Downloads")])
detection = Detection(path=str(Path.home() / "Downloads" / "bad.exe"),
signature="Win.Test", size=10)
detection.id = storage.add_detection(scan_id, detection)
storage.finish_scan(scan_id, status="completed", scanned_files=10,
infected_files=1, errors=0, duration_seconds=2, cancelled=False)
paths.logs_dir().mkdir(parents=True, exist_ok=True)
(paths.logs_dir() / "boltclam.log").write_text(
f"token=bolt_live_supersecret user aaron@example.com path={Path.home()}/x\n",
encoding="utf-8")
info = ClamAVInfo(available=True, path=Path("/fake/clamscan"),
engine_version="1.4.3", db_version="27452",
db_path=Path("/fake/db"), db_date="2026-10-22",
db_age_days=1.0)
snapshot = collect(settings, info, storage, realtime_running=False, force=True)
bundle = build_bundle(settings, info, system_snapshot=snapshot,
account={"user": {"email": "aaron@example.com"}},
realtime_roots=[str(Path.home() / "Downloads")])
assert bundle.is_file()
with zipfile.ZipFile(bundle) as archive:
names = set(archive.namelist())
assert {"manifest.json", "boltclam-logs.txt", "system-summary.json",
"README.txt"} <= names
logs = archive.read("boltclam-logs.txt").decode()
assert "bolt_live_supersecret" not in logs
assert "aaron@example.com" not in logs
assert "%USERPROFILE%" in logs
manifest = json.loads(archive.read("manifest.json"))
assert manifest["app"] == "BoltClam"
assert manifest["account"]["email"] == "aaron@example.com"
summary = json.loads(archive.read("system-summary.json"))
assert summary["stats"]["latest_scan"]["id"] == scan_id
class FakeApi:
def upload_diagnostics(self, path, metadata=None):
return {"id": "42/test.zip", "bytes": Path(path).stat().st_size}
def test_upload_bundle_returns_server_id(isolated_env):
settings = SettingsStore().load()
storage = Storage()
info = ClamAVInfo(available=False, error="not installed")
bundle = build_bundle(settings, info)
result = upload_bundle(FakeApi(), bundle, settings)
assert result["id"] == "42/test.zip"
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import json
import os
import stat
from pathlib import Path
import pytest
from boltclam.quarantine import QuarantineError, QuarantineService, delete_file, hash_file
from boltclam.storage import Storage
def _service(tmp_path) -> QuarantineService:
storage = Storage(tmp_path / "history.db")
return QuarantineService(storage, tmp_path / "quarantine")
def test_quarantine_moves_records_and_writes_metadata(tmp_path):
service = _service(tmp_path)
source = tmp_path / "malware.exe"
source.write_bytes(b"malware bytes")
digest = hash_file(source)
item = service.quarantine_file(source, "Win.Test", scan_id=7, reason="Unit test")
assert not source.exists()
destination = Path(item.quarantine_path)
assert destination.is_file()
assert destination.name.endswith(".quarantine")
assert item.sha256 == digest
assert service.storage.get_quarantine(item.id).signature == "Win.Test"
metadata = json.loads((service.root / f"{item.id}.json").read_text())
assert metadata["original_path"] == str(source)
assert metadata["reason"] == "Unit test"
def test_restore_to_original_or_chosen_path(tmp_path):
service = _service(tmp_path)
source = tmp_path / "bad.exe"
source.write_bytes(b"x")
item = service.quarantine_file(source, "Sig")
target = service.restore(item.id)
assert target.read_bytes() == b"x"
assert service.storage.get_quarantine(item.id).status == "restored"
source.write_bytes(b"y")
item2 = service.quarantine_file(source, "Sig")
chosen = tmp_path / "restored" / "safe.exe"
restored = service.restore(item2.id, chosen)
assert restored == chosen
assert chosen.read_bytes() == b"y"
def test_delete_quarantined_item_and_missing_original(tmp_path):
service = _service(tmp_path)
source = tmp_path / "bad.exe"
source.write_bytes(b"x")
item = service.quarantine_file(source, "Sig")
service.delete(item.id)
assert not Path(item.quarantine_path).exists()
assert service.storage.get_quarantine(item.id).status == "deleted"
with pytest.raises(QuarantineError):
service.restore(item.id)
def test_refuses_symlink_and_directory(tmp_path):
service = _service(tmp_path)
directory = tmp_path / "folder"
directory.mkdir()
with pytest.raises(QuarantineError):
service.quarantine_file(directory, "Sig")
real = tmp_path / "real.exe"
real.write_bytes(b"x")
link = tmp_path / "link.exe"
try:
link.symlink_to(real)
except OSError:
pytest.skip("symlinks unavailable")
with pytest.raises(QuarantineError):
service.quarantine_file(link, "Sig")
with pytest.raises(QuarantineError):
delete_file(directory)
def test_delete_file_clears_readonly_bit(tmp_path):
target = tmp_path / "readonly.exe"
target.write_bytes(b"x")
os.chmod(target, stat.S_IREAD)
delete_file(target)
assert not target.exists()
def test_restore_refuses_to_overwrite(tmp_path):
service = _service(tmp_path)
source = tmp_path / "bad.exe"
source.write_bytes(b"x")
item = service.quarantine_file(source, "Sig")
source.write_bytes(b"replacement")
with pytest.raises(QuarantineError):
service.restore(item.id)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import json
from pathlib import Path
from boltclam import realtime
from boltclam.realtime import browser_download_roots, snapshot
from boltclam.winlock import copy_for_scan
def test_snapshot_finds_final_files_and_skips_partials(tmp_path):
root = tmp_path / "Downloads"
root.mkdir()
(root / "old.exe").write_bytes(b"abc")
(root / "new.crdownload").write_bytes(b"partial")
(root / ".hidden").write_bytes(b"hidden")
sub = root / "Sub"
sub.mkdir()
(sub / "inside.zip").write_bytes(b"zip")
found = snapshot([root])
names = {Path(value[2]).name for value in found.values()}
assert names == {"old.exe", "inside.zip"}
def test_chromium_download_preference_is_watched(tmp_path, monkeypatch):
local = tmp_path / "Local"
roaming = tmp_path / "Roaming"
monkeypatch.setenv("LOCALAPPDATA", str(local))
monkeypatch.setenv("APPDATA", str(roaming))
download = tmp_path / "Custom Downloads"
download.mkdir()
profile = local / "Google" / "Chrome" / "User Data" / "Default"
profile.mkdir(parents=True)
(profile / "Preferences").write_text(json.dumps({
"download": {"default_directory": str(download)}}), encoding="utf-8")
monkeypatch.setattr(realtime.paths, "browser_download_roots", lambda: [])
roots = browser_download_roots()
assert download.resolve() in [path.resolve() for path in roots]
def test_firefox_download_preference_is_watched(tmp_path, monkeypatch):
local = tmp_path / "Local"
roaming = tmp_path / "Roaming"
monkeypatch.setenv("LOCALAPPDATA", str(local))
monkeypatch.setenv("APPDATA", str(roaming))
download = tmp_path / "Firefox Downloads"
download.mkdir()
profile = roaming / "Mozilla" / "Firefox" / "Profiles" / "abc.default"
profile.mkdir(parents=True)
escaped = str(download).replace("\\", "\\\\")
(profile / "prefs.js").write_text(
f'user_pref("browser.download.dir", "{escaped}");\n', encoding="utf-8")
monkeypatch.setattr(realtime.paths, "browser_download_roots", lambda: [])
roots = browser_download_roots()
assert download.resolve() in [path.resolve() for path in roots]
def test_copy_for_scan_preserves_bytes(tmp_path):
source = tmp_path / "download.exe"
source.write_bytes(b"sample bytes")
destination = tmp_path / "copy.bin"
locker, exclusive = copy_for_scan(source, destination, attempts=1, delay=0)
try:
assert destination.read_bytes() == b"sample bytes"
assert isinstance(exclusive, bool)
finally:
locker.close()
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import json
from boltclam.models import Detection, QuarantineItem, RealtimeEvent
from boltclam.storage import Storage
def _storage(tmp_path) -> Storage:
return Storage(tmp_path / "history.db")
def test_scan_and_detection_lifecycle(tmp_path):
storage = _storage(tmp_path)
scan_id = storage.create_scan("homes", [r"C:\Users"], engine_version="1.4.3",
db_version="27452")
assert scan_id == 1
detection = Detection(path=r"C:\Users\a\bad.exe", signature="Win.Test", size=42)
detection.id = storage.add_detection(scan_id, detection)
storage.finish_scan(scan_id, status="completed", scanned_files=100,
infected_files=1, errors=2, duration_seconds=12.5,
cancelled=False)
record = storage.get_scan(scan_id)
assert record is not None
assert record.scope == "homes"
assert record.scope_label == "User homes"
assert record.scanned_files == 100
assert record.errors == 2
assert record.paths == [r"C:\Users"]
assert record.detections[0].signature == "Win.Test"
stats = storage.stats()
assert stats["active_threats"] == 1
assert stats["latest_scan"].id == scan_id
storage.update_detection_action(detection.id, "quarantined", "Q-1")
assert storage.stats()["active_threats"] == 0
assert storage.recent_detections()[0]["action"] == "quarantined"
def test_quarantine_rows_and_counts(tmp_path):
storage = _storage(tmp_path)
item = QuarantineItem(
id="Q-1", original_path=r"C:\bad.exe", quarantine_path=str(tmp_path / "q.bin"),
signature="Win.Test", sha256="a" * 64, size=10, created_at="2026-10-25T10:00:00Z",
scan_id=1)
storage.add_quarantine(item)
assert storage.quarantine_count() == 1
assert storage.get_quarantine("Q-1").signature == "Win.Test"
storage.set_quarantine_status("Q-1", "restored")
assert storage.quarantine_count() == 0
assert storage.list_quarantine()[0].status == "restored"
def test_realtime_events_and_diagnostics_are_serializable(tmp_path):
storage = _storage(tmp_path)
storage.add_realtime_event(RealtimeEvent(
created_at="2026-10-25T10:00:00Z", path=r"C:\Users\a\bad.exe",
signature="Win.Test", action="quarantined", detail="Locked"))
events = storage.list_realtime_events()
assert events[0].action == "quarantined"
snapshot = storage.diagnostics_snapshot()
json.dumps(snapshot)
assert snapshot["realtime"][0]["signature"] == "Win.Test"
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
import time
from PyQt6.QtCore import Qt
from boltclam.api_client import ApiClient
from boltclam.auth import AuthService, CredentialStore
from boltclam.clamav import ClamAVService
from boltclam.config import AccountCache, SettingsStore
from boltclam.context import AppContext
from boltclam.quarantine import QuarantineService
from boltclam.storage import Storage
from boltclam.ui.login_dialog import LoginDialog
from boltclam.ui.main_window import MainWindow
ACCOUNT = {
"user": {"id": "1", "email": "aaron@example.com", "name": "Aaron",
"username": "aaron"},
"subscription": {"tier": "premium", "label": "Premium", "status": "active",
"current_period_end": "2026-12-01T00:00:00Z"},
"balance": {"weekly_remaining": 100, "topup": 0, "allowed": True},
"features": {"licensed": True},
}
class FakeApi:
def get_account(self):
return ACCOUNT
def get_updates(self, *args, **kwargs):
raise RuntimeError("offline")
def _context(tmp_path) -> AppContext:
store = SettingsStore()
store.save(store.load())
settings = store.update(last_update_check=time.time(), logging_enabled=False,
realtime_enabled=False)
storage = Storage()
quarantine = QuarantineService(storage, tmp_path / "quarantine")
auth = AuthService(
"http://127.0.0.1:9", install_id=settings.install_id,
credentials=CredentialStore(tmp_path / "credentials.dat"),
account_cache=AccountCache(tmp_path / "account.json"))
return AppContext(
settings_store=store, settings=settings, storage=storage,
quarantine=quarantine, clamav=ClamAVService(settings.clamav_path),
auth=auth, session_key="bolt_live_fake", account=ACCOUNT,
install_id=settings.install_id, api=FakeApi())
def test_main_window_builds_four_pages_and_switches_tabs(qapp, isolated_env):
context = _context(isolated_env)
window = MainWindow(context)
try:
window.show()
qapp.processEvents()
assert window.stack.count() == 4
assert [button.text() for button in window.nav_buttons] == [
"Homepage", "Scan & results", "About & updates", "Settings"]
assert window.scan_page.tabs.currentIndex() == 0
window.scan_page.tabs.setCurrentIndex(1)
assert window.scan_page.tabs.currentIndex() == 1
window._navigate(3)
assert window.stack.currentIndex() == 3
assert window.settings_page.channel_combo.currentData() == "release"
assert window.about_page.rows["account"].value_label.text() == "Aaron"
finally:
window._allow_close = True
window.close()
qapp.processEvents()
def test_login_dialog_builds_without_starting_a_worker(qapp, isolated_env):
auth = AuthService(
"http://127.0.0.1:9", install_id="test",
credentials=CredentialStore(isolated_env / "credentials.dat"),
account_cache=AccountCache(isolated_env / "account.json"))
dialog = LoginDialog(auth, notice="Test notice")
try:
assert "BoltClam" in dialog.windowTitle()
assert dialog.browser_button.isEnabled()
assert dialog.session is None
finally:
dialog.reject()
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
from boltclam.models import UpdateAsset, UpdateFeed, UpdateRelease
from boltclam.updates import (
UpdateError,
UpdateService,
choose_asset,
is_newer,
parse_checksums,
parse_version,
)
def test_version_parsing_and_comparison():
assert parse_version("v1.2.3") == ((1, 2, 3), ())
assert parse_version("1.2.3-beta.1")
assert is_newer("v1.2.0", "1.1.9") is True
assert is_newer("v1.0.0", "1.0.0") is False
assert is_newer("v1.0.0", "1.0.0-beta.2") is True
assert is_newer("v1.0.0-beta.2", "1.0.0") is False
assert is_newer("v1.0.0-beta.2", "1.0.0-beta.1") is True
assert is_newer("not-a-version", "1.0.0") is False
def _release(tag="v1.2.0", prerelease=False, assets=None) -> UpdateRelease:
return UpdateRelease(id="2", tag=tag, name="Test", notes="notes",
prerelease=prerelease, published_at="2026-10-20T00:00:00Z",
html_url="https://git.example", assets=assets or [])
def test_choose_asset_prefers_a_setup_executable():
release = _release(assets=[
UpdateAsset("1", "BoltClam-1.2.0.zip", 5, "application/zip", "u1"),
UpdateAsset("2", "BoltClam-1.2.0-Setup.exe", 5, "application/octet-stream", "u2"),
])
assert choose_asset(release).name == "BoltClam-1.2.0-Setup.exe"
def test_choose_asset_falls_back_to_zip():
release = _release(assets=[
UpdateAsset("1", "notes.txt", 5, "text/plain", "u1"),
UpdateAsset("2", "BoltClam-windows.zip", 5, "application/zip", "u2"),
])
assert choose_asset(release).name == "BoltClam-windows.zip"
assert choose_asset(_release(assets=[])) is None
def test_feed_from_api_preserves_release_fields():
feed = UpdateFeed.from_api({
"channel": "beta",
"latest": {"id": "3", "tag": "v2.0.0-beta", "name": "Beta",
"notes": "notes", "prerelease": True,
"published_at": "2026-10-22T00:00:00Z",
"assets": [{"id": "9", "name": "a.exe", "size": 12,
"content_type": "x", "download_url": "u"}]},
"releases": [],
"checked_at": "2026-10-25T00:00:00Z",
})
assert feed.channel == "beta"
assert feed.latest.tag == "v2.0.0-beta"
assert feed.latest.assets[0].name == "a.exe"
def test_parse_checksums():
digest = "a" * 64
assert parse_checksums(f"{digest} BoltClam-Setup.exe\n") == {
"BoltClam-Setup.exe": digest}
class FakeApi:
def __init__(self, checksum_text: str | None = None, payload: bytes = b"payload"):
self.checksum_text = checksum_text
self.payload = payload
def download_asset(self, url, destination, progress=None):
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
data = self.checksum_text.encode() if self.checksum_text and "SUM" in url else self.payload
destination.write_bytes(data)
return destination, hashlib.sha256(data).hexdigest()
def _download_release(checksum: str) -> UpdateRelease:
asset = UpdateAsset("1", "BoltClam-Setup.exe", 7, "application/octet-stream",
"https://api.example/asset")
checksum_asset = UpdateAsset("2", "SHA256SUMS.txt", 100, "text/plain",
"https://api.example/SUM")
return _release(assets=[asset, checksum_asset])
def test_download_verifies_a_matching_checksum(isolated_env):
payload = b"payload"
digest = hashlib.sha256(payload).hexdigest()
release = _download_release(digest)
service = UpdateService(FakeApi(f"{digest} BoltClam-Setup.exe\n", payload))
result = service.download(release, release.assets[0])
assert result.path.read_bytes() == payload
assert result.verified is True
assert result.expected_sha256 == digest
def test_download_rejects_a_bad_checksum(isolated_env):
release = _download_release("b" * 64)
service = UpdateService(FakeApi(f"{'b' * 64} BoltClam-Setup.exe\n", b"payload"))
with pytest.raises(UpdateError):
service.download(release, release.assets[0])
def test_check_returns_the_feed(isolated_env):
class Api:
def get_updates(self, channel, force=False):
return {"channel": channel, "latest": None, "releases": []}
feed = UpdateService(Api(), "beta").check()
assert feed.channel == "beta"
assert feed.latest is None