fix: verify raw source bytes and bound reads after file changes
This commit is contained in:
@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from micro_scout.symbols import Symbol, build_edges, parse_source, source_paths
|
from micro_scout.symbols import Symbol, build_edges, parse_source, read_source, source_paths
|
||||||
from micro_scout.text import digest
|
from micro_scout.text import digest
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -68,8 +68,8 @@ def build_index(
|
|||||||
for path in source_paths(root):
|
for path in source_paths(root):
|
||||||
relative = path.relative_to(root).as_posix()
|
relative = path.relative_to(root).as_posix()
|
||||||
try:
|
try:
|
||||||
text = path.read_text(encoding="utf-8")
|
text = read_source(path)
|
||||||
except (UnicodeError, OSError) as exc:
|
except (ValueError, OSError) as exc:
|
||||||
warnings.append({"path": relative, "reason": type(exc).__name__})
|
warnings.append({"path": relative, "reason": type(exc).__name__})
|
||||||
continue
|
continue
|
||||||
files[relative] = digest(text)
|
files[relative] = digest(text)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import numpy as np
|
|||||||
|
|
||||||
from micro_scout.index import Index
|
from micro_scout.index import Index
|
||||||
from micro_scout.lexical import BM25, reciprocal_rank_fusion, top_indices
|
from micro_scout.lexical import BM25, reciprocal_rank_fusion, top_indices
|
||||||
from micro_scout.symbols import EXTENSIONS, Symbol
|
from micro_scout.symbols import EXTENSIONS, Symbol, read_source
|
||||||
from micro_scout.text import digest
|
from micro_scout.text import digest
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -55,7 +55,7 @@ class Scout:
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
raise ValueError("Source path escapes repository or uses a symlink")
|
raise ValueError("Source path escapes repository or uses a symlink")
|
||||||
text = path.read_text(encoding="utf-8")
|
text = read_source(path)
|
||||||
except (OSError, UnicodeError) as exc:
|
except (OSError, UnicodeError) as exc:
|
||||||
raise StaleReferenceError(
|
raise StaleReferenceError(
|
||||||
f"Source unavailable: {symbol.path}; rebuild index"
|
f"Source unavailable: {symbol.path}; rebuild index"
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ EXCLUDED = {
|
|||||||
".mypy_cache",
|
".mypy_cache",
|
||||||
".pytest_cache",
|
".pytest_cache",
|
||||||
}
|
}
|
||||||
|
MAX_FILE_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def read_source(path: Path, max_bytes: int = MAX_FILE_BYTES) -> str:
|
||||||
|
"""Read bounded UTF-8 without newline translation, so SHA-256 matches file bytes."""
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
raw = stream.read(max_bytes + 1)
|
||||||
|
if len(raw) > max_bytes:
|
||||||
|
raise ValueError("Source exceeds the file-size limit")
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -71,7 +81,7 @@ class Symbol:
|
|||||||
return asdict(self)
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
def source_paths(root: Path, max_file_bytes: int = 1_000_000) -> list[Path]:
|
def source_paths(root: Path, max_file_bytes: int = MAX_FILE_BYTES) -> list[Path]:
|
||||||
"""Honor gitignore when available; never traverse symlinks or hidden trees."""
|
"""Honor gitignore when available; never traverse symlinks or hidden trees."""
|
||||||
root = root.resolve(strict=True)
|
root = root.resolve(strict=True)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
|
|||||||
@@ -237,3 +237,29 @@ def test_repository_cluster_bootstrap_retains_group_correlation():
|
|||||||
assert result["delta"] == pytest.approx(0.275)
|
assert result["delta"] == pytest.approx(0.275)
|
||||||
assert result["ci95"] == pytest.approx([-0.4, 0.5])
|
assert result["ci95"] == pytest.approx([-0.4, 0.5])
|
||||||
assert result["repository_clusters"] == 2
|
assert result["repository_clusters"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_hash_matches_raw_crlf_bytes_and_detects_line_ending_changes(repository, tmp_path):
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
raw = b"def crlf():\r\n return 42\r\n"
|
||||||
|
source = repository / "crlf.py"
|
||||||
|
source.write_bytes(raw)
|
||||||
|
path = tmp_path / "index.sqlite"
|
||||||
|
build_index(repository, path)
|
||||||
|
scout = Scout(Index(path))
|
||||||
|
symbol = next(s for s in scout.index.symbols if s.name == "crlf")
|
||||||
|
assert scout.read(symbol.id)["file_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||||
|
source.write_bytes(raw.replace(b"\r\n", b"\n"))
|
||||||
|
with pytest.raises(StaleReferenceError):
|
||||||
|
scout.read(symbol.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_that_grows_after_indexing_is_bounded(repository, tmp_path):
|
||||||
|
path = tmp_path / "index.sqlite"
|
||||||
|
build_index(repository, path)
|
||||||
|
scout = Scout(Index(path))
|
||||||
|
symbol = next(s for s in scout.index.symbols if s.name == "add_numbers")
|
||||||
|
(repository / "numbers.py").write_text("x" * 1_000_001)
|
||||||
|
with pytest.raises(ValueError, match="file-size limit"):
|
||||||
|
scout.read(symbol.id)
|
||||||
|
|||||||
Reference in New Issue
Block a user