fix(evidence): harden source acquisition

This commit is contained in:
2026-07-12 03:32:18 +02:00
parent ffd683c587
commit 81ff1810d1
8 changed files with 523 additions and 183 deletions
+99 -75
View File
@@ -1,8 +1,11 @@
"""Contained, deterministic filesystem Evidence source."""
"""Contained, race-safe filesystem Evidence source."""
import hashlib
import os
import stat
from datetime import UTC, datetime
from pathlib import Path
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlsplit
from tht.ports.evidence import (
AcquiredDocument,
@@ -22,103 +25,124 @@ class FilesystemEvidenceSource:
) -> None:
if max_bytes < 1:
raise ValueError("max_bytes must be positive")
if not patterns or any(not pattern for pattern in patterns):
if not patterns or any(
not pattern or Path(pattern).is_absolute() or ".." in Path(pattern).parts
for pattern in patterns
):
raise ValueError("at least one non-empty discovery pattern is required")
try:
self.root = Path(root).expanduser().resolve(strict=True)
self._root_fd = os.open(
self.root,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
)
except OSError as error:
raise ValueError("filesystem evidence root is unavailable") from error
if not self.root.is_dir():
raise ValueError("filesystem evidence root must be a directory")
self.patterns = tuple(patterns)
self.max_bytes = max_bytes
def _contained(self, path: Path) -> Path:
try:
resolved = path.resolve(strict=True)
resolved.relative_to(self.root)
except (OSError, ValueError) as error:
raise EvidenceSourceError(
"unsafe filesystem object",
category=EvidenceSourceErrorCategory.PERMANENT,
details={"operation": "path_validation"},
) from error
if not resolved.is_file():
raise EvidenceSourceError(
"unsupported filesystem object",
category=EvidenceSourceErrorCategory.PERMANENT,
details={"operation": "path_validation"},
)
return resolved
def __del__(self):
root_fd = getattr(self, "_root_fd", None)
if root_fd is not None:
try:
os.close(root_fd)
except OSError:
pass
def _read(self, path: Path) -> bytes:
@staticmethod
def _safe_error(operation: str, *, transient: bool = False, **details):
return EvidenceSourceError(
"filesystem source operation failed",
category=(
EvidenceSourceErrorCategory.TRANSIENT
if transient
else EvidenceSourceErrorCategory.PERMANENT
),
details={"operation": operation, **details},
)
def _open_read(self, relative: PurePosixPath) -> tuple[bytes, os.stat_result]:
parts = relative.parts
if not parts or any(part in {"", ".", ".."} for part in parts):
raise self._safe_error("path_validation")
directory_fd = os.dup(self._root_fd)
file_fd = None
try:
if path.stat().st_size > self.max_bytes:
raise EvidenceSourceError(
"filesystem object exceeds configured limit",
category=EvidenceSourceErrorCategory.PERMANENT,
details={"operation": "read", "limit_bytes": self.max_bytes},
for component in parts[:-1]:
next_fd = os.open(
component,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=directory_fd,
)
with path.open("rb") as stream:
content = stream.read(self.max_bytes + 1)
os.close(directory_fd)
directory_fd = next_fd
file_fd = os.open(
parts[-1],
os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC,
dir_fd=directory_fd,
)
file_stat = os.fstat(file_fd)
if not stat.S_ISREG(file_stat.st_mode):
raise self._safe_error("path_validation")
if file_stat.st_size > self.max_bytes:
raise self._safe_error("read", limit_bytes=self.max_bytes)
content = bytearray()
while len(content) <= self.max_bytes:
chunk = os.read(file_fd, min(64 * 1024, self.max_bytes + 1 - len(content)))
if not chunk:
break
content.extend(chunk)
if len(content) > self.max_bytes:
raise self._safe_error("read", limit_bytes=self.max_bytes)
return bytes(content), file_stat
except EvidenceSourceError:
raise
except OSError as error:
raise EvidenceSourceError(
"filesystem read failed",
category=EvidenceSourceErrorCategory.TRANSIENT,
details={"operation": "read"},
) from error
if len(content) > self.max_bytes:
raise EvidenceSourceError(
"filesystem object exceeds configured limit",
category=EvidenceSourceErrorCategory.PERMANENT,
details={"operation": "read", "limit_bytes": self.max_bytes},
)
return content
raise self._safe_error("open") from error
finally:
if file_fd is not None:
os.close(file_fd)
os.close(directory_fd)
def _item(self, path: Path, content: bytes) -> SourceObject:
relative = path.relative_to(self.root).as_posix()
digest = hashlib.sha256(content).hexdigest()
stable_id = hashlib.sha256(relative.encode()).hexdigest()
modified = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
def _item(
self, relative: PurePosixPath, content: bytes, file_stat: os.stat_result
) -> SourceObject:
relative_text = relative.as_posix()
return SourceObject(
source_id=f"filesystem:{stable_id}",
uri=path.as_uri(),
fingerprint=f"sha256:{digest}",
modified_at=modified,
metadata={"relative_path": relative},
source_id=f"filesystem:{hashlib.sha256(relative_text.encode()).hexdigest()}",
uri=(self.root / relative_text).as_uri(),
fingerprint=f"sha256:{hashlib.sha256(content).hexdigest()}",
modified_at=datetime.fromtimestamp(file_stat.st_mtime, tz=UTC),
metadata={"relative_path": relative_text},
)
def discover(self):
candidates = {path for pattern in self.patterns for path in self.root.glob(pattern)}
for candidate in sorted(candidates, key=lambda path: path.as_posix()):
path = self._contained(candidate)
content = self._read(path)
yield self._item(path, content)
candidates = {
path.relative_to(self.root).as_posix()
for pattern in self.patterns
for path in self.root.glob(pattern)
}
for relative_text in sorted(candidates):
relative = PurePosixPath(relative_text)
content, file_stat = self._open_read(relative)
yield self._item(relative, content, file_stat)
def acquire(self, item: SourceObject) -> AcquiredDocument:
if not item.uri.startswith("file:"):
raise EvidenceSourceError(
"object does not belong to filesystem source",
category=EvidenceSourceErrorCategory.PERMANENT,
details={"operation": "acquire"},
)
from urllib.parse import unquote, urlsplit
parsed = urlsplit(item.uri)
path = self._contained(Path(unquote(parsed.path)))
content = self._read(path)
expected = self._item(path, content)
if item.source_id != expected.source_id:
raise EvidenceSourceError(
"object does not belong to filesystem source",
category=EvidenceSourceErrorCategory.PERMANENT,
details={"operation": "acquire"},
)
if parsed.scheme != "file" or parsed.netloc or parsed.query or parsed.fragment:
raise self._safe_error("acquire")
try:
relative = Path(unquote(parsed.path)).relative_to(self.root)
except ValueError as error:
raise self._safe_error("acquire") from error
pure_relative = PurePosixPath(relative.as_posix())
content, file_stat = self._open_read(pure_relative)
expected = self._item(pure_relative, content, file_stat)
if item.source_id != expected.source_id or item.fingerprint != expected.fingerprint:
raise self._safe_error("acquire")
return AcquiredDocument(
source=expected,
content=content,
media_type="text/markdown" if path.suffix.lower() == ".md" else None,
media_type="text/markdown" if relative.suffix.lower() == ".md" else None,
acquired_at=datetime.now(UTC),
)