"""Local append-only event journal and inert, content-addressed evidence.""" import datetime import hashlib import json import os import pathlib import threading import time class EvidenceError(OSError): pass class Evidence: def __init__(self, directory, quota=1024 ** 3): self.directory = pathlib.Path(directory) self.directory.mkdir(parents=True, exist_ok=True, mode=0o700) self.artifacts = self.directory / 'artifacts' self.artifacts.mkdir(exist_ok=True, mode=0o700) os.chmod(self.directory, 0o700) os.chmod(self.artifacts, 0o700) self.lock = threading.RLock() self.quota = quota self.used = sum(p.stat().st_size for p in self.directory.rglob('*') if p.is_file()) self.journal = os.open(self.directory / 'events.jsonl', os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600) os.fchmod(self.journal, 0o600) def reserve(self, size): if self.used + size > self.quota: raise OSError('Evidence quota reached; preserve/archive evidence before restarting') def emit(self, event, **data): now = time.time_ns() entry = dict(schema_version=2, event=event, time_ns=now, timestamp=datetime.datetime.fromtimestamp(now / 1e9, datetime.timezone.utc).isoformat(timespec='microseconds'), **data) raw = (json.dumps(entry, ensure_ascii=True) + '\n').encode() with self.lock: self.reserve(len(raw)) with os.fdopen(os.dup(self.journal), 'ab') as output: output.write(raw) output.flush() os.fsync(output.fileno()) self.used += len(raw) def save(self, data): digest = hashlib.sha256(data).hexdigest() path = self.artifacts / (digest + '.bin') with self.lock: if not path.exists(): self.reserve(len(data)) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) try: with os.fdopen(fd, 'wb') as output: output.write(data) output.flush() os.fsync(output.fileno()) except BaseException: path.unlink(missing_ok=True) # do not leave a partial blob with a full-content hash raise self.used += len(data) return dict(sha256=digest, bytes=len(data), artifact='artifacts/' + path.name) def close(self): os.close(self.journal) class RecordingReader: """Retain only bytes consumed by the HTTP parser, after TLS decryption.""" def __init__(self, source, limit): self.source, self.limit = source, limit self.data = bytearray() self.truncated = False def record(self, data): room = max(0, self.limit - len(self.data)) self.data.extend(data[:room]) self.truncated |= len(data) > room return data def read(self, *args): return self.record(self.source.read(*args)) def read1(self, *args): return self.record(self.source.read1(*args)) def readline(self, *args): return self.record(self.source.readline(*args)) def close(self): self.source.close()