173 lines
5.3 KiB
Python
173 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib
|
|
import os
|
|
import stat
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
ACTIVE_SESSION_MARKER = ".current_session"
|
|
|
|
|
|
class ActiveSessionLeaseError(RuntimeError):
|
|
"""The evidence root already has an active writer or cannot be leased."""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ActiveSessionLease:
|
|
"""Cross-process lease that keeps an in-progress session out of replay.
|
|
|
|
The marker is intentionally written before the session directory is
|
|
created. Discovery therefore observes either no candidate yet or a
|
|
candidate protected by an already locked marker. A process crash releases
|
|
the OS lock; startup recovery can then remove the stale marker while
|
|
preserving the interrupted evidence directory.
|
|
"""
|
|
|
|
sessions_root: Path
|
|
session_root: Path
|
|
_descriptor: int
|
|
_marker_identity: tuple[int, int]
|
|
_released: bool = False
|
|
|
|
@classmethod
|
|
def acquire(cls, sessions_root: Path, session_root: Path) -> ActiveSessionLease:
|
|
root = sessions_root.expanduser().resolve()
|
|
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
target = session_root.expanduser().absolute()
|
|
if target.parent.resolve() != root or not target.name:
|
|
raise ActiveSessionLeaseError("active session must be a direct child of evidence root")
|
|
|
|
marker = root / ACTIVE_SESSION_MARKER
|
|
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
try:
|
|
descriptor = os.open(marker, flags, 0o600)
|
|
except FileExistsError as exc:
|
|
raise ActiveSessionLeaseError(
|
|
"another observation session owns the evidence root"
|
|
) from exc
|
|
try:
|
|
payload = f"{target.name}\n".encode()
|
|
os.write(descriptor, payload)
|
|
os.fsync(descriptor)
|
|
_lock_descriptor(descriptor, blocking=False)
|
|
_fsync_directory(root)
|
|
metadata = os.fstat(descriptor)
|
|
return cls(
|
|
sessions_root=root,
|
|
session_root=target,
|
|
_descriptor=descriptor,
|
|
_marker_identity=(metadata.st_dev, metadata.st_ino),
|
|
)
|
|
except BaseException:
|
|
try:
|
|
marker.unlink(missing_ok=True)
|
|
_fsync_directory(root)
|
|
finally:
|
|
os.close(descriptor)
|
|
raise
|
|
|
|
def release(self) -> None:
|
|
if self._released:
|
|
return
|
|
marker = self.sessions_root / ACTIVE_SESSION_MARKER
|
|
try:
|
|
try:
|
|
metadata = marker.lstat()
|
|
except FileNotFoundError:
|
|
metadata = None
|
|
if metadata is not None and (metadata.st_dev, metadata.st_ino) == self._marker_identity:
|
|
marker.unlink()
|
|
_fsync_directory(self.sessions_root)
|
|
finally:
|
|
_unlock_descriptor(self._descriptor)
|
|
os.close(self._descriptor)
|
|
self._released = True
|
|
|
|
def __enter__(self) -> ActiveSessionLease:
|
|
return self
|
|
|
|
def __exit__(self, *_: object) -> None:
|
|
self.release()
|
|
|
|
|
|
def recover_stale_active_session_marker(sessions_root: Path) -> bool:
|
|
"""Remove only an unlocked marker left by a terminated writer."""
|
|
|
|
root = sessions_root.expanduser().resolve()
|
|
marker = root / ACTIVE_SESSION_MARKER
|
|
try:
|
|
marker_stat = marker.lstat()
|
|
except FileNotFoundError:
|
|
return False
|
|
except OSError:
|
|
return False
|
|
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
|
|
return False
|
|
try:
|
|
descriptor = os.open(marker, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
|
|
except OSError:
|
|
return False
|
|
locked = False
|
|
try:
|
|
try:
|
|
_lock_descriptor(descriptor, blocking=False)
|
|
locked = True
|
|
except OSError:
|
|
return False
|
|
opened = os.fstat(descriptor)
|
|
try:
|
|
current = marker.lstat()
|
|
except OSError:
|
|
return False
|
|
if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
|
|
return False
|
|
marker.unlink()
|
|
_fsync_directory(root)
|
|
return True
|
|
finally:
|
|
if locked:
|
|
_unlock_descriptor(descriptor)
|
|
os.close(descriptor)
|
|
|
|
|
|
def _lock_descriptor(descriptor: int, *, blocking: bool) -> None:
|
|
if os.name == "nt":
|
|
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
|
|
|
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
|
msvcrt.locking(descriptor, mode, 1)
|
|
return
|
|
import fcntl
|
|
|
|
operation = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
|
|
fcntl.flock(descriptor, operation)
|
|
|
|
|
|
def _unlock_descriptor(descriptor: int) -> None:
|
|
try:
|
|
if os.name == "nt":
|
|
msvcrt = cast(Any, importlib.import_module("msvcrt"))
|
|
|
|
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
|
return
|
|
import fcntl
|
|
|
|
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
except OSError:
|
|
return
|
|
|
|
|
|
def _fsync_directory(path: Path) -> None:
|
|
try:
|
|
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
except OSError:
|
|
return
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|