import contextlib import dataclasses import hashlib import json import os import tempfile import time from contextlib import AbstractContextManager from typing import Iterator, Protocol, runtime_checkable from pydantic import TypeAdapter, ValidationError from pydantic.dataclasses import dataclass from mdrsclient.exceptions import UnexpectedException from mdrsclient.models import Laboratories, Token, User from mdrsclient.settings import CONFIG_DIRNAME from mdrsclient.utils import FileLock @dataclass class CacheData: user: User | None = None token: Token | None = None laboratories: Laboratories = dataclasses.field(default_factory=Laboratories) digest: str = "" def clear(self) -> None: self.user = None self.token = None self.laboratories.clear() self.digest = "" def update_digest(self) -> None: self.digest = self.__calc_digest() def verify_digest(self) -> bool: return self.digest == self.__calc_digest() def __calc_digest(self) -> str: return hashlib.sha256( json.dumps( [ None if self.user is None else dataclasses.asdict(self.user), None if self.token is None else dataclasses.asdict(self.token), dataclasses.asdict(self.laboratories), ] ).encode("utf-8") ).hexdigest() @runtime_checkable class CacheInterface(Protocol): def lock(self) -> AbstractContextManager[None]: """ Hold exclusive access to the cache for the duration of the block. Refreshing a token is a read-modify-write, and the cache is shared with every other client process using the same remote. """ ... def reload(self) -> None: """Re-read the cache, discarding anything held from an earlier read.""" ... @property def token(self) -> Token | None: ... @token.setter def token(self, token: Token) -> None: ... @token.deleter def token(self) -> None: ... @property def user(self) -> User | None: ... @user.setter def user(self, user: User) -> None: ... @user.deleter def user(self) -> None: ... @property def laboratories(self) -> Laboratories: ... @laboratories.setter def laboratories(self, laboratories: Laboratories) -> None: ... class InMemoryCache(CacheInterface): def __init__(self) -> None: self.__data = CacheData() @contextlib.contextmanager def lock(self) -> Iterator[None]: # Nothing else can reach this cache, so there is nothing to exclude. yield def reload(self) -> None: pass @property def token(self) -> Token | None: return self.__data.token @token.setter def token(self, token: Token) -> None: self.__data.token = token @token.deleter def token(self) -> None: if self.__data.token is not None: self.__data.token = None @property def user(self) -> User | None: return self.__data.user @user.setter def user(self, user: User) -> None: self.__data.user = user @user.deleter def user(self) -> None: if self.__data.user is not None: self.__data.user = None @property def laboratories(self) -> Laboratories: return self.__data.laboratories @laboratories.setter def laboratories(self, laboratories: Laboratories) -> None: self.__data.laboratories = laboratories class CacheFile(CacheInterface): __serial: int __cache_dir: str __cache_file: str __lock_file: str __lock_depth: int __data: CacheData def __init__(self, remote: str) -> None: self.__serial = -1 self.__cache_dir = os.path.join(CONFIG_DIRNAME, "cache") self.__cache_file = os.path.join(self.__cache_dir, remote + ".json") self.__lock_file = os.path.join(self.__cache_dir, remote + ".lock") self.__lock_depth = 0 self.__data = CacheData() @contextlib.contextmanager def lock(self) -> Iterator[None]: # Re-entrant, because every write takes it and a refresh is a write made while # already holding it. A second flock on the same file from the same process would # wait for a release that cannot come. if self.__lock_depth > 0: self.__lock_depth += 1 try: yield finally: self.__lock_depth -= 1 return # A separate file, so that replacing the cache cannot disturb the lock holders. self.__ensure_cache_dir() with open(self.__lock_file, "a") as f: FileLock.lock(f) self.__lock_depth = 1 try: yield finally: self.__lock_depth = 0 FileLock.unlock(f) os.chmod(self.__lock_file, 0o600) def reload(self) -> None: self.__serial = -1 self.__load() @property def token(self) -> Token | None: self.__load() return self.__data.token @token.setter def token(self, token: Token) -> None: with self.lock(): self.reload() self.__data.token = token self.__save() @token.deleter def token(self) -> None: if self.__data.token is not None: self.__clear() @property def user(self) -> User | None: return self.__data.user @user.setter def user(self, user: User) -> None: with self.lock(): self.reload() self.__data.user = user self.__save() @user.deleter def user(self) -> None: if self.__data.user is not None: self.__clear() @property def laboratories(self) -> Laboratories: return self.__data.laboratories @laboratories.setter def laboratories(self, laboratories: Laboratories) -> None: with self.lock(): self.reload() self.__data.laboratories = laboratories self.__save() def __clear(self) -> None: self.__data.clear() self.__save() def __load(self) -> None: if os.path.isfile(self.__cache_file): serial = self.__stat_serial() if self.__serial != serial: try: with open(self.__cache_file) as f: data = TypeAdapter(CacheData).validate_python(json.load(f)) if not data.verify_digest(): raise UnexpectedException("Cache data has been broken.") self.__data = data except (ValidationError, UnexpectedException) as e: self.__clear() self.__save() print(e) else: self.__serial = serial else: self.__clear() self.__serial = -1 def __save(self) -> None: self.__ensure_cache_dir() self.__data.update_digest() payload = json.dumps(dataclasses.asdict(self.__data)) # Written aside and moved into place: opening the cache for writing truncates it # first, and a reader landing in that window would find the file empty and treat # the session as broken. fd, tmp_file = tempfile.mkstemp(dir=self.__cache_dir, prefix=".tmp-") try: with os.fdopen(fd, "w") as f: f.write(payload) # ensure file is secure. os.chmod(tmp_file, 0o600) self.__replace(tmp_file, self.__cache_file) except BaseException: if os.path.exists(tmp_file): os.unlink(tmp_file) raise self.__serial = self.__stat_serial() @staticmethod def __replace(source: str, destination: str) -> None: # Windows refuses the rename while another process still has the destination # open, which a reader briefly does, so give it a moment rather than failing the # save outright. On POSIX the rename always succeeds and the loop ends at once. deadline = time.monotonic() + 5.0 while True: try: os.replace(source, destination) return except PermissionError: if time.monotonic() >= deadline: raise time.sleep(0.05) def __stat_serial(self) -> int: stat = os.stat(self.__cache_file) # st_ino and st_mtime_ns both move when the file is replaced, which a refresh # that happens to produce the same number of bytes otherwise would not show. return hash((stat.st_ino, stat.st_uid, stat.st_gid, stat.st_mode, stat.st_size, stat.st_mtime_ns)) def __ensure_cache_dir(self) -> None: if not os.path.exists(self.__cache_dir): os.makedirs(self.__cache_dir) # ensure directory is secure. os.chmod(self.__cache_dir, 0o700)