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>
198 lines
7.1 KiB
Python
198 lines
7.1 KiB
Python
"""Best-effort file locking for the real-time download gate.
|
|
|
|
Windows has no user-mode API that lets an ordinary process intercept every
|
|
``CreateFile`` (that is a kernel minifilter). What this module *can* do is
|
|
hold the download file open with ``dwShareMode = 0`` while ClamAV scans a
|
|
copy, which makes Windows refuse other opens for that period. If the file
|
|
was already open elsewhere, we fall back to scanning a copy and say so in
|
|
the event detail. This is a meaningful speed bump, not a guarantee against
|
|
a kernel-level bypass.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from ctypes import wintypes
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LockError(Exception):
|
|
pass
|
|
|
|
|
|
if os.name == "nt":
|
|
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
_CreateFileW = _kernel32.CreateFileW
|
|
_CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
|
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD,
|
|
wintypes.HANDLE]
|
|
_CreateFileW.restype = wintypes.HANDLE
|
|
_ReadFile = _kernel32.ReadFile
|
|
_ReadFile.argtypes = [wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD,
|
|
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
|
_ReadFile.restype = wintypes.BOOL
|
|
_CloseHandle = _kernel32.CloseHandle
|
|
_CloseHandle.argtypes = [wintypes.HANDLE]
|
|
_CloseHandle.restype = wintypes.BOOL
|
|
_SetFileInformationByHandle = _kernel32.SetFileInformationByHandle
|
|
_SetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.c_int,
|
|
wintypes.LPVOID, wintypes.DWORD]
|
|
_SetFileInformationByHandle.restype = wintypes.BOOL
|
|
|
|
_GENERIC_READ = 0x80000000
|
|
_DELETE = 0x00010000
|
|
_OPEN_EXISTING = 3
|
|
_FILE_ATTRIBUTE_NORMAL = 0x80
|
|
_FILE_DISPOSITION_INFO_CLASS = 4
|
|
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
|
|
class _FILE_DISPOSITION_INFO(ctypes.Structure):
|
|
_fields_ = [("DeleteFile", wintypes.BOOL)]
|
|
else:
|
|
_kernel32 = None
|
|
_FILE_DISPOSITION_INFO = None
|
|
|
|
|
|
class ExclusiveFile:
|
|
"""Open a file (share-deny on Windows) and copy it while held."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
self.path = Path(path)
|
|
self._handle = None
|
|
self._file = None
|
|
self._delete_access = False
|
|
self._delete_on_close = False
|
|
self._open = False
|
|
|
|
@property
|
|
def exclusive(self) -> bool:
|
|
return bool(self._open and os.name == "nt")
|
|
|
|
def open(self) -> None:
|
|
if self._open:
|
|
return
|
|
if os.name == "nt":
|
|
handle = _CreateFileW(
|
|
str(self.path), _GENERIC_READ | _DELETE, 0, None, _OPEN_EXISTING,
|
|
_FILE_ATTRIBUTE_NORMAL, None,
|
|
)
|
|
if handle == _INVALID_HANDLE_VALUE:
|
|
handle = _CreateFileW(
|
|
str(self.path), _GENERIC_READ, 0, None, _OPEN_EXISTING,
|
|
_FILE_ATTRIBUTE_NORMAL, None,
|
|
)
|
|
self._delete_access = False
|
|
else:
|
|
self._delete_access = True
|
|
if handle == _INVALID_HANDLE_VALUE:
|
|
error = ctypes.get_last_error()
|
|
raise LockError(f"Could not open {self.path.name} exclusively (error {error}).")
|
|
self._handle = handle
|
|
else:
|
|
try:
|
|
self._file = open(self.path, "rb")
|
|
except OSError as exc:
|
|
raise LockError(f"Could not open {self.path.name}: {exc}") from exc
|
|
self._open = True
|
|
|
|
def copy_to(self, destination: Path) -> None:
|
|
if not self._open:
|
|
self.open()
|
|
destination = Path(destination)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
if os.name == "nt":
|
|
buffer = ctypes.create_string_buffer(1024 * 1024)
|
|
read = wintypes.DWORD()
|
|
with open(destination, "wb") as output:
|
|
while True:
|
|
ok = _ReadFile(self._handle, buffer, len(buffer),
|
|
ctypes.byref(read), None)
|
|
if not ok:
|
|
raise LockError(
|
|
f"Could not read {self.path.name} (error "
|
|
f"{ctypes.get_last_error()}).")
|
|
if read.value == 0:
|
|
break
|
|
output.write(buffer.raw[:read.value])
|
|
else:
|
|
with open(destination, "wb") as output:
|
|
shutil.copyfileobj(self._file, output, length=1024 * 1024)
|
|
except OSError as exc:
|
|
raise LockError(f"Could not copy {self.path.name}: {exc}") from exc
|
|
|
|
def mark_delete_on_close(self) -> bool:
|
|
if os.name != "nt" or not self._delete_access or self._handle is None:
|
|
return False
|
|
info = _FILE_DISPOSITION_INFO(True)
|
|
ok = _SetFileInformationByHandle(
|
|
self._handle, _FILE_DISPOSITION_INFO_CLASS, ctypes.byref(info),
|
|
ctypes.sizeof(info),
|
|
)
|
|
if not ok:
|
|
logger.warning("Could not mark %s delete-on-close (error %s)",
|
|
self.path, ctypes.get_last_error())
|
|
return False
|
|
self._delete_on_close = True
|
|
return True
|
|
|
|
def close(self) -> None:
|
|
if not self._open:
|
|
return
|
|
if os.name == "nt":
|
|
if self._handle is not None:
|
|
_CloseHandle(self._handle)
|
|
self._handle = None
|
|
else:
|
|
if self._file is not None:
|
|
self._file.close()
|
|
self._file = None
|
|
self._open = False
|
|
|
|
def __enter__(self) -> "ExclusiveFile":
|
|
self.open()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
self.close()
|
|
|
|
|
|
def copy_for_scan(path: Path, destination: Path, attempts: int = 8,
|
|
delay: float = 0.4) -> tuple[ExclusiveFile, bool]:
|
|
"""Copy *path* while holding it where possible.
|
|
|
|
Returns ``(locker, exclusive)``. The caller must keep ``locker`` open
|
|
until the scan finishes, then close it. If the file cannot be opened
|
|
exclusively (another process is still writing or reading it), this
|
|
retries briefly and then copies without the lock.
|
|
"""
|
|
import time
|
|
|
|
last_error: Exception | None = None
|
|
for _ in range(max(1, attempts)):
|
|
locker = ExclusiveFile(path)
|
|
try:
|
|
locker.open()
|
|
locker.copy_to(destination)
|
|
return locker, locker.exclusive
|
|
except LockError as exc:
|
|
last_error = exc
|
|
try:
|
|
locker.close()
|
|
except Exception:
|
|
pass
|
|
time.sleep(delay)
|
|
# Last resort: a plain copy. The watcher already waited for the size to
|
|
# stop changing, so this is still normally a complete file.
|
|
try:
|
|
shutil.copy2(path, destination)
|
|
except OSError as exc:
|
|
raise LockError(f"Could not copy {path.name}: {exc}") from exc
|
|
logger.warning("Real-time scan could not exclusively lock %s: %s", path, last_error)
|
|
return ExclusiveFile(path), False
|