feat(evidence): add safe retention and materialized reads

This commit is contained in:
2026-07-12 05:02:47 +02:00
parent b6a52995ae
commit f3b49f41c8
11 changed files with 310 additions and 14 deletions
+43 -5
View File
@@ -9,6 +9,7 @@ import re
import stat
import shutil
import uuid
import hashlib
from pathlib import Path
from contextlib import contextmanager
@@ -170,6 +171,14 @@ class CorpusStore:
generation = self.active_generation()
return self.manifest(generation) if generation else None
def list_generations(self) -> list[str]:
values = []
for entry in self.root.iterdir():
match = re.fullmatch(r"gen-([0-9a-f]{32})", entry.name)
if match and not entry.is_symlink() and stat.S_ISDIR(entry.lstat().st_mode):
values.append(f"gen:{match.group(1)}")
return sorted(values, key=lambda value: self.generation_path(value).stat().st_mtime_ns)
def resolve_document(self, document_id: str, generation: str | None = None) -> Path | None:
generation = generation or self.active_generation()
if generation is None:
@@ -178,8 +187,37 @@ class CorpusStore:
relative = manifest.metadata.get("files", {}).get(document_id)
if not isinstance(relative, str):
return None
base = self.generation_path(generation).resolve()
path = (base / relative).resolve()
if not path.is_relative_to(base) or path.is_symlink():
raise UnsafeCorpusPath("materialized document escapes its generation")
return path
parts = Path(relative).parts
if Path(relative).is_absolute() or parts[:1] != ("documents",) or len(parts) != 2:
raise UnsafeCorpusPath("materialized document path is unsafe")
return self.generation_path(generation) / relative
def read_document(self, document_id: str, generation: str | None = None) -> str | None:
generation = generation or self.active_generation()
if generation is None:
return None
manifest = self.manifest(generation)
path = self.resolve_document(document_id, generation)
document = next((item for item in manifest.documents if item.document_id == document_id), None)
if path is None or document is None:
return None
generation_fd = os.open(self.generation_path(generation), os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
documents_fd = fd = None
try:
documents_fd = os.open("documents", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=generation_fd)
fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=documents_fd)
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
raise UnsafeCorpusPath("materialized document is unsafe")
payload = os.read(fd, info.st_size + 1)
if len(payload) != info.st_size or "sha256:" + hashlib.sha256(payload).hexdigest() != document.content_hash:
raise UnsafeCorpusPath("materialized document hash mismatch")
return payload.decode("utf-8")
except (OSError, UnicodeError) as error:
raise UnsafeCorpusPath("materialized document read failed") from error
finally:
if fd is not None:
os.close(fd)
if documents_fd is not None:
os.close(documents_fd)
os.close(generation_fd)