fix(ask): canonicalize question/proposal path collision check
Compare resolved canonical Path values, not raw string spellings, when detecting a question_path / proposal_path collision. A relative proposal_path and an absolute question_path that name the same file now fail closed with reason "path_collision" instead of resolving. Adds a regression test for the absolute-vs-relative same-file collision and keeps the existing exact-string collision test. The new test is non-vacuous: it fails under the prior raw-string comparison. Scope: ask_handle.py seam + its test only. No runtime.py, CLAIMS, metrics, telemetry, schema, or lane-pin changes.
This commit is contained in:
parent
41664ce60d
commit
c0d6493219
2 changed files with 52 additions and 1 deletions
|
|
@ -124,6 +124,24 @@ def _rejected(reason: str) -> AskHandleResolution:
|
|||
return AskHandleResolution(resolved=False, reason=reason, candidate=None)
|
||||
|
||||
|
||||
def _paths_name_same_file(question_path: Path, proposal_path_value: str) -> bool:
|
||||
"""Canonical-path collision check for the question/proposal pair.
|
||||
|
||||
Compares *resolved* canonical paths, not raw string spellings, so an absolute
|
||||
``question_path`` and a relative ``proposal_path`` (or any two differently
|
||||
spelled paths) that name the same file collide and fail closed. Resolution is
|
||||
best-effort (``strict=False``); if a path cannot be canonicalized (e.g. a
|
||||
symlink loop), fall back to the raw-spelling comparison, which still catches
|
||||
the exact-string collision.
|
||||
"""
|
||||
try:
|
||||
return question_path.resolve(strict=False) == Path(proposal_path_value).resolve(
|
||||
strict=False
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return str(question_path) == str(proposal_path_value)
|
||||
|
||||
|
||||
def _recompute_content_hash(payload: Any) -> str | None:
|
||||
"""Recompute the producer's content address from the artifact body.
|
||||
|
||||
|
|
@ -190,7 +208,9 @@ def resolve_served_ask_handle(config: Any, handle: Any) -> AskHandleResolution:
|
|||
# Not the producer's content-addressed filename for this hash — the
|
||||
# handle does not name a producer-written artifact identity.
|
||||
return _rejected("handle_address_mismatch")
|
||||
if proposal_path_value is not None and str(question_path) == str(proposal_path_value):
|
||||
if proposal_path_value is not None and _paths_name_same_file(
|
||||
question_path, proposal_path_value
|
||||
):
|
||||
return _rejected("path_collision")
|
||||
|
||||
if not question_path.is_file():
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import ast
|
|||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from core.config import RuntimeConfig
|
||||
|
|
@ -267,6 +268,36 @@ def test_question_path_equal_to_proposal_path_fails_closed(tmp_path: Path) -> No
|
|||
assert resolution.reason == "path_collision"
|
||||
|
||||
|
||||
def test_relative_proposal_path_same_file_as_absolute_question_path_fails_closed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A relative ``proposal_path`` and an absolute ``question_path`` that name the
|
||||
same file must collide and fail closed — the check compares canonical paths,
|
||||
not raw string spellings, so differing spellings of one file cannot slip past.
|
||||
"""
|
||||
payload = _valid_payload()
|
||||
path, digest = _write_addressed_artifact(tmp_path / "questions", payload)
|
||||
assert path.is_absolute()
|
||||
|
||||
# A genuinely relative spelling (from cwd) of the very same artifact file.
|
||||
relative_proposal = Path(os.path.relpath(path, Path.cwd()))
|
||||
assert not relative_proposal.is_absolute()
|
||||
assert relative_proposal.resolve(strict=False) == path.resolve(strict=False)
|
||||
assert str(relative_proposal) != str(path)
|
||||
|
||||
handle = AskArtifactHandle(
|
||||
question_path=str(path),
|
||||
content_hash=digest,
|
||||
proposal_path=str(relative_proposal),
|
||||
)
|
||||
resolution = resolve_served_ask_handle(
|
||||
RuntimeConfig(ask_serving_enabled=True), handle
|
||||
)
|
||||
|
||||
assert resolution.resolved is False
|
||||
assert resolution.reason == "path_collision"
|
||||
|
||||
|
||||
def test_stale_or_replaced_artifact_fails_closed(tmp_path: Path) -> None:
|
||||
"""The file at the handle's path no longer re-hashes to the handle's address."""
|
||||
original = _valid_payload("How many crates are left?")
|
||||
|
|
|
|||
Loading…
Reference in a new issue