Harden portable Redmine CLI runtime and cache writes
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
"""POSIX local-filesystem cache transactions; persistent locks, atomic JSON.
|
||||
|
||||
Locks protect read/modify/write, not only replacement. File existence never
|
||||
indicates ownership. Kernel locks are released even when the holder is killed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
class CacheError(RuntimeError):
|
||||
pass
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
def lock_timeout():
|
||||
raw = os.environ.get('REDMINE_LOCK_TIMEOUT','60')
|
||||
try:
|
||||
value=float(raw)
|
||||
if not math.isfinite(value) or value < 0:
|
||||
raise ValueError()
|
||||
return value
|
||||
except ValueError as exc:
|
||||
raise CacheError('REDMINE_LOCK_TIMEOUT must be a finite nonnegative number of seconds.') from exc
|
||||
|
||||
@contextmanager
|
||||
def cache_lock(target, timeout=None):
|
||||
target=Path(target).resolve()
|
||||
seconds=lock_timeout() if timeout is None else float(timeout)
|
||||
if not math.isfinite(seconds) or seconds < 0:
|
||||
raise CacheError('Cache lock timeout must be finite and nonnegative.')
|
||||
held=getattr(_local,'held',None)
|
||||
if held is None:
|
||||
held=_local.held={}
|
||||
key=(os.getpid(),str(target))
|
||||
if key in held:
|
||||
yield
|
||||
return
|
||||
path=target.with_name(target.name+'.lock')
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
fd=os.open(path,os.O_RDWR|os.O_CREAT,0o600)
|
||||
acquired=False
|
||||
start=time.monotonic()
|
||||
warned=False
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
||||
acquired=True
|
||||
held[key]=fd
|
||||
# A killed writer may leave its same-directory temporary file.
|
||||
# Once this lock is ours, no live writer for this target can own
|
||||
# one, so removing matching leftovers is safe.
|
||||
for stale in target.parent.glob('.' + target.name + '.*.tmp'):
|
||||
try:
|
||||
stale.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
break
|
||||
except BlockingIOError:
|
||||
if not warned:
|
||||
print(f'Waiting for cache lock: {path} (timeout {seconds:g}s)',file=sys.stderr,flush=True)
|
||||
warned=True
|
||||
remaining=seconds-(time.monotonic()-start)
|
||||
if remaining <= 0:
|
||||
raise CacheError(f'Cache lock timeout after {seconds:g}s: {path}. Another writer is active; retry or increase REDMINE_LOCK_TIMEOUT. Do not delete the lock file.')
|
||||
time.sleep(min(0.05,remaining))
|
||||
yield
|
||||
finally:
|
||||
if acquired:
|
||||
held.pop(key,None)
|
||||
fcntl.flock(fd,fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def read_json(path):
|
||||
path=Path(path)
|
||||
try:
|
||||
data=json.loads(path.read_text(encoding='utf-8'))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (json.JSONDecodeError,UnicodeError) as exc:
|
||||
raise CacheError(f'Invalid cache JSON: {path}; refusing to overwrite it. Restore or repair this file.') from exc
|
||||
if not isinstance(data,dict):
|
||||
raise CacheError(f'Cache JSON must be an object: {path}; refusing to overwrite it.')
|
||||
return data
|
||||
|
||||
|
||||
def locked_read_json(path):
|
||||
with cache_lock(path):
|
||||
return read_json(path)
|
||||
|
||||
|
||||
def atomic_write_json(path,data):
|
||||
path=Path(path)
|
||||
with cache_lock(path):
|
||||
fd,name=tempfile.mkstemp(prefix='.'+path.name+'.',suffix='.tmp',dir=path.parent)
|
||||
tmp=Path(name)
|
||||
try:
|
||||
with os.fdopen(fd,'w',encoding='utf-8') as handle:
|
||||
json.dump(data,handle,indent=2,sort_keys=True)
|
||||
handle.write('\n')
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
# Keep existing shared-file permissions; new sensitive caches are 0600.
|
||||
if path.exists():
|
||||
os.chmod(tmp,path.stat().st_mode & 0o777)
|
||||
os.replace(tmp,path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def update_manifest_atomic(path,updater):
|
||||
with cache_lock(path):
|
||||
current=read_json(path)
|
||||
updater(current)
|
||||
atomic_write_json(path,current)
|
||||
return current
|
||||
Reference in New Issue
Block a user