1010import shutil
1111import subprocess
1212import sys
13+ import tempfile
1314from pathlib import Path
1415from typing import Any
1516from uuid import uuid4
16- from .. import config , graphtrail_delta , localio , proc , receipt_signing
17+ from .. import config , graphtrail_delta , localio , proc , receipt_signing , runguard
1718from . import constants , helpers , ledger as ledger_mod
1819from . import reviews as reviews_mod
1920from . import scanners as scanners_mod
@@ -132,6 +133,7 @@ def _verify_execution_argv(argv: list[str], target: Path) -> list[str]:
132133_VERIFY_CANCELED_RC = 130
133134_VERIFY_INTERRUPTED_COMMAND_STATUS = "interrupted"
134135_VERIFY_CANCELED_RECEIPT_STATUS = "canceled"
136+ _VERIFY_RECEIPT_SCHEMA_VERSION = 2
135137
136138
137139def _verify_child_popen_kwargs () -> dict [str , Any ]:
@@ -576,9 +578,16 @@ def _write_verify_markdown(run_dir: Path, receipt: dict[str, Any]) -> None:
576578 f"- Started: { receipt .get ('started_at' )} " ,
577579 f"- Completed: { receipt .get ('completed_at' )} " ,
578580 "" ,
579- "## Commands" ,
580- "" ,
581581 ]
582+ lines .extend (_verify_identity_lines (receipt ))
583+ if lines [- 1 ] != "" :
584+ lines .append ("" )
585+ lines .extend (
586+ [
587+ "## Commands" ,
588+ "" ,
589+ ]
590+ )
582591 for command in receipt .get ("commands" , []):
583592 if not isinstance (command , dict ):
584593 continue
@@ -597,10 +606,56 @@ def _write_verify_markdown(run_dir: Path, receipt: dict[str, Any]) -> None:
597606 (run_dir / "summary.md" ).write_text ("\n " .join (lines ) + "\n " )
598607
599608
600- def _fingerprint_segment (hasher , label : str , data : bytes ) -> None :
601- encoded_label = label .encode ()
602- hasher .update (str (len (encoded_label )).encode () + b":" + encoded_label )
603- hasher .update (str (len (data )).encode () + b":" + data )
609+ def _verify_identity_binding (receipt : dict [str , Any ]) -> str | None :
610+ baseline = receipt .get ("baseline_commit" )
611+ fingerprint = receipt .get ("tree_fingerprint" )
612+ patch_hash = receipt .get ("changes_patch_sha256" )
613+ if not isinstance (baseline , str ) or not baseline :
614+ return None
615+ if not isinstance (fingerprint , str ) or not fingerprint :
616+ return None
617+ if not isinstance (patch_hash , str ) or not patch_hash :
618+ return None
619+ return f"verified tree { fingerprint } = baseline { baseline } + patch { patch_hash } "
620+
621+
622+ def _verify_identity_lines (receipt : dict [str , Any ]) -> list [str ]:
623+ baseline = receipt .get ("baseline_commit" )
624+ fingerprint = receipt .get ("tree_fingerprint" )
625+ patch_hash = receipt .get ("changes_patch_sha256" )
626+ if not isinstance (baseline , str ) or not baseline :
627+ return []
628+ if not isinstance (fingerprint , str ) or not fingerprint :
629+ return []
630+ if not isinstance (patch_hash , str ) or not patch_hash :
631+ return []
632+ binding = _verify_identity_binding (receipt )
633+ assert binding is not None
634+ return [
635+ f"- Baseline commit: `{ baseline } `" ,
636+ f"- Tree fingerprint: `{ fingerprint } `" ,
637+ f"- Patch hash: `{ patch_hash } `" ,
638+ f"- { binding } " ,
639+ ]
640+
641+
642+ def _git_with_index (target : Path , index_file : Path , * args : str ) -> subprocess .CompletedProcess [str ]:
643+ env = {** os .environ , "GIT_INDEX_FILE" : str (index_file )}
644+ try :
645+ return subprocess .run (
646+ ["git" , "-C" , str (target ), * args ],
647+ check = False ,
648+ stdout = subprocess .PIPE ,
649+ stderr = subprocess .PIPE ,
650+ text = True ,
651+ stdin = subprocess .DEVNULL ,
652+ timeout = 30 ,
653+ env = env ,
654+ )
655+ except subprocess .TimeoutExpired as exc :
656+ return subprocess .CompletedProcess (exc .cmd , 124 , stdout = "" , stderr = f"git timed out after { exc .timeout :g} s" )
657+ except OSError :
658+ return subprocess .CompletedProcess (["git" ], 127 , stdout = "" , stderr = "git unavailable" )
604659
605660
606661def _stamp_harness_session (receipt : dict [str , Any ]) -> None :
@@ -611,30 +666,55 @@ def _stamp_harness_session(receipt: dict[str, Any]) -> None:
611666
612667
613668def _tree_fingerprint (target : Path ) -> str | None :
614- """Content hash of HEAD + tracked diff + untracked files. None outside git ."""
669+ """Git tree object for HEAD plus tracked and non-ignored untracked files."""
615670 try :
616- head = helpers ._git (target , "rev-parse" , "HEAD" )
617- if head .returncode != 0 :
618- return None
619- diff = helpers ._git (target , "diff" , "HEAD" )
620- untracked = helpers ._git (target , "ls-files" , "--others" , "--exclude-standard" )
621- if diff .returncode != 0 or untracked .returncode != 0 :
622- return None
671+ with tempfile .TemporaryDirectory () as tmpdir :
672+ index_file = Path (tmpdir ) / "index"
673+ read_tree = _git_with_index (target , index_file , "read-tree" , "HEAD" )
674+ if read_tree .returncode != 0 :
675+ return None
676+ add_all = _git_with_index (target , index_file , "add" , "-A" )
677+ if add_all .returncode != 0 :
678+ return None
679+ write_tree = _git_with_index (target , index_file , "write-tree" )
680+ if write_tree .returncode != 0 :
681+ return None
682+ value = write_tree .stdout .strip ()
683+ return value or None
623684 except OSError :
624- # helpers._git only catches TimeoutExpired; a missing git binary (e.g. a
625- # test that restricts PATH) raises FileNotFoundError, an OSError subclass.
626685 return None
627- hasher = hashlib .sha256 ()
628- _fingerprint_segment (hasher , "head" , head .stdout .encode ())
629- _fingerprint_segment (hasher , "diff" , diff .stdout .encode ())
630- for name in sorted (untracked .stdout .splitlines ()):
631- path = target / name
632- try :
633- data = path .read_bytes ()
634- except OSError :
635- return None
636- _fingerprint_segment (hasher , f"untracked:{ name } " , data )
637- return hasher .hexdigest ()
686+
687+
688+ def _capture_verify_identity (target : Path , run_dir : Path ) -> dict [str , Any ]:
689+ unavailable : dict [str , Any ] = {
690+ "schema_version" : _VERIFY_RECEIPT_SCHEMA_VERSION ,
691+ "baseline_commit" : None ,
692+ "tree_fingerprint" : None ,
693+ "changes_patch_sha256" : None ,
694+ }
695+ patch_path = run_dir / "changes.patch"
696+ try :
697+ baseline_commit = helpers ._git_value (target , "rev-parse" , "HEAD" )
698+ tree_fingerprint = _tree_fingerprint (target )
699+ if baseline_commit is None or tree_fingerprint is None :
700+ return unavailable
701+ runguard .collect_changes_patch (target , patch_path )
702+ patch_bytes = patch_path .read_bytes ()
703+ if (
704+ helpers ._git_value (target , "rev-parse" , "HEAD" ) != baseline_commit
705+ or _tree_fingerprint (target ) != tree_fingerprint
706+ ):
707+ patch_path .unlink (missing_ok = True )
708+ return unavailable
709+ except (OSError , runguard .RunGuardError ):
710+ patch_path .unlink (missing_ok = True )
711+ return unavailable
712+ return {
713+ "schema_version" : _VERIFY_RECEIPT_SCHEMA_VERSION ,
714+ "baseline_commit" : baseline_commit ,
715+ "tree_fingerprint" : tree_fingerprint ,
716+ "changes_patch_sha256" : hashlib .sha256 (patch_bytes ).hexdigest (),
717+ }
638718
639719
640720def _run_verify_commands (
@@ -654,6 +734,7 @@ def _run_verify_commands(
654734 finalized = False
655735 try :
656736 run_dir .mkdir (parents = True , exist_ok = False )
737+ identity = _capture_verify_identity (target , run_dir )
657738 receipt = {
658739 "run_id" : run_id ,
659740 "target" : str (target ),
@@ -663,9 +744,9 @@ def _run_verify_commands(
663744 "path" : str (run_dir ),
664745 "evidence" : _verification_evidence_payload (target ),
665746 "commands" : [],
666- "tree_fingerprint" : _tree_fingerprint (target ),
667747 "planned_commands" : _planned_commands_display (commands ),
668748 }
749+ receipt .update (identity )
669750 _stamp_harness_session (receipt )
670751 try :
671752 graph_delta_before = graphtrail_delta .capture_before (target , run_dir , timeout = graphtrail_timeout )
@@ -796,7 +877,6 @@ def _run_verify_commands(
796877def _write_reused_receipt (
797878 target : Path ,
798879 latest : dict [str , Any ],
799- fingerprint : str | None ,
800880 planned_display : list [str ],
801881 timeout : int ,
802882) -> tuple [dict [str , Any ], int ]:
@@ -805,6 +885,7 @@ def _write_reused_receipt(
805885 run_id = f"{ started .strftime ('%Y%m%d-%H%M%S' )} -work-verify-{ uuid4 ().hex [:6 ]} "
806886 run_dir = helpers ._verify_runs_root (target ) / run_id
807887 run_dir .mkdir (parents = True , exist_ok = False )
888+ identity = _capture_verify_identity (target , run_dir )
808889 completed_at = helpers ._now ()
809890 receipt : dict [str , Any ] = {
810891 "run_id" : run_id ,
@@ -817,9 +898,9 @@ def _write_reused_receipt(
817898 "path" : str (run_dir ),
818899 "commands" : copy .deepcopy (latest .get ("commands" , [])),
819900 "reused_from" : latest .get ("run_id" ),
820- "tree_fingerprint" : fingerprint ,
821901 "planned_commands" : planned_display ,
822902 }
903+ receipt .update (identity )
823904 _stamp_harness_session (receipt )
824905 git = _receipt_git_snapshot (target )
825906 if git is not None :
@@ -1082,7 +1163,7 @@ def verify_run(
10821163 and latest .get ("tree_fingerprint" ) == fingerprint
10831164 and latest .get ("planned_commands" ) == planned_display
10841165 ):
1085- receipt , rc = _write_reused_receipt (target , latest , fingerprint , planned_display , timeout )
1166+ receipt , rc = _write_reused_receipt (target , latest , planned_display , timeout )
10861167 if receipt is None :
10871168 receipt , rc = _run_verify_commands (
10881169 target , planned , timeout , graphtrail_timeout = effective_graphtrail_timeout
@@ -1175,6 +1256,23 @@ def verify_show(*, target: Path, run_id: str, json_output: bool = False) -> int:
11751256 print (f"target: { run .get ('target' )} " )
11761257 print (f"started: { run .get ('started_at' )} " )
11771258 print (f"completed: { run .get ('completed_at' )} " )
1259+ baseline = run .get ("baseline_commit" )
1260+ fingerprint = run .get ("tree_fingerprint" )
1261+ patch_hash = run .get ("changes_patch_sha256" )
1262+ if (
1263+ isinstance (baseline , str )
1264+ and baseline
1265+ and isinstance (fingerprint , str )
1266+ and fingerprint
1267+ and isinstance (patch_hash , str )
1268+ and patch_hash
1269+ ):
1270+ print (f"baseline commit: { baseline } " )
1271+ print (f"tree fingerprint: { fingerprint } " )
1272+ print (f"patch hash: { patch_hash } " )
1273+ binding = _verify_identity_binding (run )
1274+ if binding :
1275+ print (binding )
11781276 for command in run .get ("commands" , []):
11791277 if isinstance (command , dict ):
11801278 print (f"- { command .get ('command' )} [{ command .get ('status' )} ] exit={ command .get ('exit_code' )} " )
0 commit comments