diff --git a/CHANGELOG.md b/CHANGELOG.md index d405b36e..ca0d8f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,14 @@ All notable changes to vouch are documented here. Format follows in config.yaml (#476). ### Fixed +- `receipts.verify_receipt()` no longer reports `VERIFIED` for an + empty-quote, zero-length byte span. the guard only checked `quote is + None`, not an empty string, so `quote=""` with `byte_start == + byte_end` decoded to `""`, trivially equaled the empty quote, and + verified -- despite carrying no actual quoted text, contradicting the + function's own docstring. `locate_span` already refused to mint such + a receipt on the propose path; this closes the same gap on the + verify path, reachable via bundle import or sync. - approve/reject/expire record the audit event *before* moving the proposal to decided/. a crash between the two used to leave a durable decision with no authoritative history; it now leaves a pending diff --git a/src/vouch/receipts.py b/src/vouch/receipts.py index 00ac10a1..12416ff3 100644 --- a/src/vouch/receipts.py +++ b/src/vouch/receipts.py @@ -58,7 +58,7 @@ def verify_receipt(evidence: Evidence, source_bytes: bytes) -> ReceiptResult: when the decoded span equals the quote exactly. """ start, end, quote = evidence.byte_start, evidence.byte_end, evidence.quote - if start is None or end is None or quote is None: + if start is None or end is None or not quote: return ReceiptResult(ReceiptStatus.NO_RECEIPT, "no byte-offset span") if start > end or end > len(source_bytes): return ReceiptResult( @@ -184,7 +184,7 @@ def verify_evidence(store: KBStore, evidence: Evidence) -> ReceiptResult: """ from .storage import ArtifactNotFoundError - if evidence.byte_start is None or evidence.byte_end is None or evidence.quote is None: + if evidence.byte_start is None or evidence.byte_end is None or not evidence.quote: return ReceiptResult(ReceiptStatus.NO_RECEIPT, "no byte-offset span") try: source_bytes = store.read_source_content(evidence.source_id) diff --git a/tests/test_receipts.py b/tests/test_receipts.py index 23c30eb0..f1b0c259 100644 --- a/tests/test_receipts.py +++ b/tests/test_receipts.py @@ -98,6 +98,19 @@ def test_no_receipt_when_quote_absent() -> None: assert result.status is ReceiptStatus.NO_RECEIPT +def test_no_receipt_when_quote_is_empty_string() -> None: + # a zero-length span decodes to "" and trivially equals an empty quote -- + # the docstring promises NO_RECEIPT for "no quote to compare", and + # locate_span already refuses to mint an empty-quote receipt for the same + # reason (see test_locate_span_returns_none_for_empty_quote); verify_receipt + # must reject one landing on disk some other way (bundle import, sync), + # not report it VERIFIED. + ev = _ev(quote="", byte_start=0, byte_end=0) + result = verify_receipt(ev, SOURCE) + assert result.status is ReceiptStatus.NO_RECEIPT + assert result.verified is False + + def test_receipt_uses_byte_offsets_not_char_offsets() -> None: # "café — au lait": 'é' is 2 bytes (0xc3 0xa9), '—' is 3 bytes (em dash). # "au lait" starts at char index 7 but byte index 10. A char-offset @@ -160,6 +173,23 @@ def test_verify_evidence_not_verified_when_source_missing(store: KBStore) -> Non assert result.verified is False +def test_verify_evidence_no_receipt_for_empty_quote_and_missing_source( + store: KBStore, +) -> None: + # verify_evidence has its own pre-check ahead of the source-read (so a + # missing source doesn't mask a plain "nothing to compare" case) -- + # it must apply the same falsy-quote rule verify_receipt does, or an + # empty quote paired with a missing source reports FORGED instead of + # NO_RECEIPT, same bug as verify_receipt's, one call site up. + ev = Evidence( + id="e4", source_id="does-not-exist", + locator="x", quote="", byte_start=0, byte_end=0, + ) + result = verify_evidence(store, ev) + assert result.status is ReceiptStatus.NO_RECEIPT + assert result.verified is False + + # ---- the quote step: locate a span, or drop what cannot be quoted ----