The login cache is shared by every mdrs process, but the refresh was guarded by a lock that only reaches inside one. Concurrent runs each sent the same refresh token, and a provider that rotates them accepts the first and refuses the rest. - hold a lock that spans processes across the whole read-refresh- write, checking cheaply first so ordinary requests never take it - write the cache through a temporary file: opening it for writing truncates it, and a reader landing in that window found it empty and threw the session away - take the lock for every write, not just the refresh, so a login running beside one cannot be silently reverted - wait for a busy lock on Windows rather than giving up after the ten attempts msvcrt allows, and retry the rename it refuses while a reader still holds the file open - bound the refresh request on its own, so a provider that goes quiet cannot hold the lock indefinitely - accept bare hostnames such as localhost, store URLs without the trailing slash, and join download paths through one helper
291 lines
8.8 KiB
Python
291 lines
8.8 KiB
Python
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)
|