2222
2323import json
2424import os
25+ import re
2526import shutil
2627import stat
2728import subprocess
2829import tempfile
30+ import threading
2931from typing import Any , cast
3032
3133from ownlang .fix_delta import (
@@ -121,6 +123,42 @@ def load_authority(plan_bytes: bytes, candidates_bytes: bytes) -> tuple[Any, Any
121123 return auth , plan , candidates
122124
123125
126+ _DELTA_TOP_KEYS = frozenset ({
127+ "schema" , "operation" , "status" , "analysis_scope" , "input_hashes" , "gate_binding" ,
128+ "toolchain_fingerprint" , "reference_closure" , "target_api" , "expected" , "baseline" ,
129+ "postimage" , "delta" , "semantic_idempotence" , "checks" ,
130+ })
131+ _DELTA_IH_KEYS = ("input_bundle_sha256" , "validated_plan_sha256" , "candidates_sha256" ,
132+ "apply_manifest_sha256" , "patch_sha256" , "pre_sha256" , "post_sha256" )
133+ _DELTA_RID_KEYS = ("framework_name" , "tfm" , "requested_framework_version" ,
134+ "selected_framework_version" , "runtime_manifest_sha256" )
135+
136+
137+ def _bind_delta_shapes (d : dict [str , Any ], cat : str ) -> None :
138+ """Validate the exact frozen shapes of the Step 10 delta fields Step 11 consumes, so a
139+ malformed/missing field is DELTA_BINDING rather than a KeyError/INFRASTRUCTURE (H4)."""
140+ ih = d ["input_hashes" ]
141+ if not isinstance (ih , dict ) or not all (isinstance (ih .get (k ), str ) for k in _DELTA_IH_KEYS ):
142+ raise TargetError (cat , "delta-result.json input_hashes shape is wrong" )
143+ scope = d ["analysis_scope" ]
144+ if not isinstance (scope , dict ) or not isinstance (scope .get ("source_file" ), str ) \
145+ or not isinstance (scope .get ("target_file_identity" ), str ):
146+ raise TargetError (cat , "delta-result.json analysis_scope shape is wrong" )
147+ tfp = d ["toolchain_fingerprint" ]
148+ rid = tfp .get ("resolved_runtime_identity" ) if isinstance (tfp , dict ) else None
149+ if not isinstance (rid , dict ) or not all (isinstance (rid .get (k ), str ) for k in _DELTA_RID_KEYS ):
150+ raise TargetError (cat , "delta-result.json resolved_runtime_identity shape is wrong" )
151+ tapi = d ["target_api" ]
152+ if not isinstance (tapi , dict ) or not isinstance (tapi .get ("subscribe" ), str ):
153+ raise TargetError (cat , "delta-result.json target_api shape is wrong" )
154+ exp = d ["expected" ]
155+ if not isinstance (exp , dict ) or not isinstance (exp .get ("convert_acquire_ids" ), list ) \
156+ or not isinstance (exp .get ("manual_review_ids" ), list ):
157+ raise TargetError (cat , "delta-result.json expected shape is wrong" )
158+ if not isinstance (d ["reference_closure" ], list ):
159+ raise TargetError (cat , "delta-result.json reference_closure shape is wrong" )
160+
161+
124162def bind_delta (delta_bytes : bytes , auth : Any , plan_bytes : bytes ,
125163 candidates_bytes : bytes ) -> dict [str , Any ]:
126164 """Bind the Step 10 delta-result.json as the upstream authority (canonical bytes, exact
@@ -134,6 +172,9 @@ def bind_delta(delta_bytes: bytes, auth: Any, plan_bytes: bytes,
134172 raise TargetError (cat , f"delta-result.json is not valid JSON ({ exc } )" ) from exc
135173 if _canonical_bytes (d ) != delta_bytes :
136174 raise TargetError (cat , "delta-result.json is not canonical bytes" )
175+ if not isinstance (d , dict ) or set (d ) != _DELTA_TOP_KEYS :
176+ raise TargetError (cat , "delta-result.json has unknown or missing top-level keys" )
177+ _bind_delta_shapes (d , cat )
137178 if d .get ("schema" ) != 1 or d .get ("operation" ) != "verify-subscription-analyzer-delta" \
138179 or d .get ("status" ) != "pass" :
139180 raise TargetError (cat , "delta-result.json schema/operation/status is wrong" )
@@ -268,7 +309,6 @@ def resolve_probe_runtime(dll_dst: str, dotnet_host: str,
268309def _peel_handler (handler : str ) -> str :
269310 """The frozen Step 8 handler peel + whitespace normalization, mirrored for bind-params:
270311 `new H(M)` / `new(M)` -> M, then collapse whitespace."""
271- import re
272312 s = handler .strip ()
273313 while True :
274314 m = re .fullmatch (r"new\s+[^\s(]+\s*\(\s*(.*)\s*\)" , s ) or re .fullmatch (
@@ -303,9 +343,63 @@ def build_bind_params(candidates: Any, convert_ids: list[str], rel: str) -> dict
303343 14 : WRAPPER_RUNTIME_UNSUPPORTED }
304344
305345
346+ def _run_child (argv : list [str ], cwd : str , env : dict [str , str ],
347+ timeout : int ) -> tuple [int , bytes , bytes , str | None ]:
348+ """One shared bounded runner for the bind and probe children (H4). It enforces the fixed
349+ timeout, caps each of stdout/stderr at _OUT_LIMIT bytes WHILE the child runs, kills the DIRECT
350+ child on timeout or overflow (no descendant-containment claim), and returns bounded diagnostics.
351+ Returns (returncode, stdout, stderr, reason) where reason is None on a clean exit, else
352+ 'timeout' / 'stdout_overflow' / 'stderr_overflow'."""
353+ proc = subprocess .Popen (argv , cwd = cwd , env = env , stdout = subprocess .PIPE , stderr = subprocess .PIPE )
354+ bufs = {"stdout" : bytearray (), "stderr" : bytearray ()}
355+ over : dict [str , str | None ] = {"which" : None }
356+ lock = threading .Lock ()
357+
358+ def pump (name : str , pipe : Any ) -> None :
359+ try :
360+ while True :
361+ chunk = pipe .read (4096 )
362+ if not chunk :
363+ return
364+ with lock :
365+ buf = bufs [name ]
366+ room = _OUT_LIMIT - len (buf )
367+ if room > 0 :
368+ buf .extend (chunk [:room ])
369+ if len (chunk ) > room :
370+ over ["which" ] = over ["which" ] or name
371+ try :
372+ proc .kill ()
373+ except OSError :
374+ pass
375+ return
376+ except (OSError , ValueError ):
377+ return
378+
379+ threads = [threading .Thread (target = pump , args = (n , p ), daemon = True )
380+ for n , p in (("stdout" , proc .stdout ), ("stderr" , proc .stderr ))]
381+ for t in threads :
382+ t .start ()
383+ reason : str | None = None
384+ try :
385+ proc .wait (timeout = timeout )
386+ except subprocess .TimeoutExpired :
387+ reason = "timeout"
388+ try :
389+ proc .kill ()
390+ except OSError :
391+ pass
392+ proc .wait ()
393+ for t in threads :
394+ t .join (2 )
395+ if reason is None and over ["which" ]:
396+ reason = f"{ over ['which' ]} _overflow"
397+ return proc .returncode , bytes (bufs ["stdout" ]), bytes (bufs ["stderr" ]), reason
398+
399+
306400def run_bind (work : str , dotnet_host : str , probe_dll : str , selected_ver : str , rel : str ,
307- preimage : str , postimage : str , slots_dir : str , target : str ,
308- selected_class : str , bind_params : dict [str , Any ]) -> dict [str , Any ]:
401+ preimage : str , postimage : str , slots_dir : str , target : str , selected_class : str ,
402+ bind_params : dict [str , Any ], convert_ids : list [ str ]) -> dict [str , Any ]:
309403 core = os .path .join (work , "bind" )
310404 os .makedirs (core , exist_ok = True )
311405 pre_path = os .path .join (core , "pre.cs" )
@@ -322,24 +416,92 @@ def run_bind(work: str, dotnet_host: str, probe_dll: str, selected_ver: str, rel
322416 probe_dll , "bind" , "--preimage" , pre_path , "--postimage" , post_path ,
323417 "--slots-dir" , slots_dir , "--target" , target , "--selected-class" , selected_class ,
324418 "--source-file" , rel , "--bind-params" , params_path , "--out" , out_path ]
325- proc = subprocess .run (argv , cwd = core , env = _probe_env (work , core ),
326- capture_output = True , text = True , check = False )
327- if proc .returncode in _BIND_EXIT :
328- raise TargetError (_BIND_EXIT [proc .returncode ], f"bind: { proc .stderr .strip ()[:300 ]} " )
329- if proc .returncode != 0 :
330- raise TargetError (INFRASTRUCTURE , f"bind failed (rc={ proc .returncode } ): "
331- f"{ proc .stderr .strip ()[:300 ]} " )
419+ rc , _out , err , reason = _run_child (argv , core , _probe_env (work , core ), _CHILD_TIMEOUT_SECONDS )
420+ if reason is not None :
421+ raise TargetError (INFRASTRUCTURE , f"bind { reason } " )
422+ err_text = err .decode ("utf-8" , "replace" ).strip ()[:300 ]
423+ if rc in _BIND_EXIT :
424+ raise TargetError (_BIND_EXIT [rc ], f"bind: { err_text } " )
425+ if rc != 0 :
426+ raise TargetError (INFRASTRUCTURE , f"bind failed (rc={ rc } ): { err_text } " )
332427 try :
333428 with open (out_path , "rb" ) as fh :
334429 raw = fh .read ()
335- binding = json .loads (raw )
336- except (OSError , ValueError ) as exc :
430+ except OSError as exc :
337431 raise TargetError (INFRASTRUCTURE , f"binding-result.json unreadable ({ exc } )" ) from exc
432+ if len (raw ) > _OUT_LIMIT :
433+ raise TargetError (INFRASTRUCTURE , "binding-result.json too large" )
434+ try :
435+ binding = json .loads (raw )
436+ except ValueError as exc :
437+ raise TargetError (INFRASTRUCTURE , f"binding-result.json is not valid JSON ({ exc } )" ) from exc
338438 if _canonical_bytes (binding ) != raw :
339439 raise TargetError (INFRASTRUCTURE , "binding-result.json is not canonical bytes" )
440+ _validate_binding_result (binding , convert_ids )
340441 return cast ("dict[str, Any]" , binding )
341442
342443
444+ _BINDING_KEYS = ("version" , "operation" , "converted_callsites" , "derived_wrapper_ordinal" ,
445+ "resolved_wrapper" , "callsite_binding" , "callsites" )
446+ _BINDING_RW_KEYS = ("assembly_simple_name" , "module_mvid" , "metadata_token" , "resolved_signature" )
447+ _BINDING_CB_KEYS = ("all_callsites_same_symbol" , "target_is_source_defined" )
448+ _BINDING_CS_KEYS = ("finding_id" , "preimage_span" , "postimage_span" , "assembly_simple_name" ,
449+ "module_mvid" , "metadata_token" , "resolved_signature" )
450+
451+
452+ def _is_int (x : Any ) -> bool :
453+ return isinstance (x , int ) and not isinstance (x , bool )
454+
455+
456+ def _validate_binding_result (obj : Any , convert_ids : list [str ]) -> None :
457+ """Strict canonical binding-result.json validation (H4). A malformed shape/type is
458+ INFRASTRUCTURE; a well-formed but semantically incomplete / non-bijective binding is
459+ CALLSITE_BINDING."""
460+ inf = INFRASTRUCTURE
461+ if not isinstance (obj , dict ) or set (obj ) != set (_BINDING_KEYS ):
462+ raise TargetError (inf , "binding-result.json is not the exact schema" )
463+ if obj ["version" ] != 1 or obj ["operation" ] != "weak-target-bind" :
464+ raise TargetError (inf , "binding-result.json version/operation wrong" )
465+ if not _is_int (obj ["converted_callsites" ]):
466+ raise TargetError (inf , "converted_callsites must be an int" )
467+ if not _is_int (obj ["derived_wrapper_ordinal" ]) or obj ["derived_wrapper_ordinal" ] < 0 :
468+ raise TargetError (inf , "derived_wrapper_ordinal must be a non-negative int" )
469+ rw = obj ["resolved_wrapper" ]
470+ if not isinstance (rw , dict ) or set (rw ) != set (_BINDING_RW_KEYS ) \
471+ or not all (isinstance (rw [k ], str ) for k in _BINDING_RW_KEYS ):
472+ raise TargetError (inf , "resolved_wrapper is not the exact schema" )
473+ cb = obj ["callsite_binding" ]
474+ if not isinstance (cb , dict ) or set (cb ) != set (_BINDING_CB_KEYS ) \
475+ or not all (isinstance (cb [k ], bool ) for k in _BINDING_CB_KEYS ):
476+ raise TargetError (inf , "callsite_binding is not the exact schema" )
477+ cs = obj ["callsites" ]
478+ if not isinstance (cs , list ):
479+ raise TargetError (inf , "callsites must be a list" )
480+ fids , spans = [], []
481+ for c in cs :
482+ if not isinstance (c , dict ) or set (c ) != set (_BINDING_CS_KEYS ):
483+ raise TargetError (inf , "a callsite is not the exact schema" )
484+ if not isinstance (c ["finding_id" ], str ):
485+ raise TargetError (inf , "callsite finding_id must be a string" )
486+ for sk in ("preimage_span" , "postimage_span" ):
487+ sp = c [sk ]
488+ if not isinstance (sp , list ) or len (sp ) != 2 \
489+ or not all (_is_int (v ) and v >= 0 for v in sp ):
490+ raise TargetError (inf , f"callsite { sk } must be two non-negative ints" )
491+ if any (c [k ] != rw [k ] for k in _BINDING_RW_KEYS ):
492+ raise TargetError (inf , "a callsite identity does not equal resolved_wrapper" )
493+ fids .append (c ["finding_id" ])
494+ spans .append ((c ["postimage_span" ][0 ], c ["postimage_span" ][1 ]))
495+ if fids != sorted (fids ):
496+ raise TargetError (inf , "callsites are not sorted by finding_id" )
497+ want = set (convert_ids )
498+ if obj ["converted_callsites" ] != len (want ) or len (cs ) != len (want ) \
499+ or set (fids ) != want or len (set (fids )) != len (fids ):
500+ raise TargetError (CALLSITE_BINDING , "callsites are not a bijection onto the converted ids" )
501+ if len (set (spans )) != len (spans ):
502+ raise TargetError (CALLSITE_BINDING , "two callsites share a postimage span" )
503+
504+
343505def _probe_env (work : str , cwd_dir : str ) -> dict [str , str ]:
344506 env : dict [str , str ] = {}
345507 for k in ("SystemRoot" , "SYSTEMROOT" , "windir" , "PATH" , "LANG" , "LC_ALL" ):
@@ -377,29 +539,49 @@ def run_probe_attempt(work: str, dotnet_host: str, probe_dll: str, selected_ver:
377539 probe_dll , "probe" , "--wrapper-ordinal" , str (wrapper_ordinal ),
378540 "--slots-dir" , slots_dir , "--runtime-dir" , runtime_dir , "--attempt" , str (attempt ),
379541 "--target" , target , "--out" , out_path ]
380- try :
381- proc = subprocess .run (argv , cwd = os .path .join (work , "probe" ), env = _probe_env (work , adir ),
382- capture_output = True , timeout = _CHILD_TIMEOUT_SECONDS , check = False )
383- except subprocess .TimeoutExpired :
384- raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } timed out" ) from None
385- if len (proc .stdout ) > _OUT_LIMIT or len (proc .stderr ) > _OUT_LIMIT :
386- raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } output overflow" )
387- if proc .returncode == 10 :
542+ rc , _out , _err , reason = _run_child (argv , os .path .join (work , "probe" ),
543+ _probe_env (work , adir ), _CHILD_TIMEOUT_SECONDS )
544+ if reason is not None :
545+ raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } { reason } " )
546+ if rc not in (0 , 10 ):
547+ raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } rc={ rc } " )
548+ obj = _read_probe_json (out_path , attempt )
549+ if rc == 10 :
550+ _validate_unsupported_result (obj , attempt ) # exit 10 needs the exact unsupported schema
388551 return 10 , None
389- if proc .returncode != 0 :
390- raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } rc={ proc .returncode } " )
552+ _validate_probe_result (obj , attempt )
553+ return 0 , obj
554+
555+
556+ def _read_probe_json (out_path : str , attempt : int ) -> Any :
391557 try :
392558 with open (out_path , "rb" ) as fh :
393559 raw = fh .read ()
394- if len (raw ) > _OUT_LIMIT :
395- raise TargetError (INFRASTRUCTURE , "probe-result.json too large" )
560+ except OSError as exc :
561+ raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } result unreadable ({ exc } )" ) \
562+ from exc
563+ if len (raw ) > _OUT_LIMIT :
564+ raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } result too large" )
565+ try :
396566 obj = json .loads (raw )
397- except (OSError , ValueError ) as exc :
398- raise TargetError (INFRASTRUCTURE , f"probe-result.json unreadable ({ exc } )" ) from exc
567+ except ValueError as exc :
568+ raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } result not JSON ({ exc } )" ) \
569+ from exc
399570 if _canonical_bytes (obj ) != raw :
400- raise TargetError (INFRASTRUCTURE , "probe-result.json is not canonical bytes" )
401- _validate_probe_result (obj , attempt )
402- return 0 , obj
571+ raise TargetError (INFRASTRUCTURE , f"probe attempt { attempt } result not canonical" )
572+ return obj
573+
574+
575+ _UNSUPPORTED_KEYS = ("version" , "operation" , "attempt" , "runtime_unsupported" , "reason" )
576+
577+
578+ def _validate_unsupported_result (obj : Any , attempt : int ) -> None :
579+ if not isinstance (obj , dict ) or set (obj ) != set (_UNSUPPORTED_KEYS ):
580+ raise TargetError (INFRASTRUCTURE , "runtime-unsupported result is not the exact schema" )
581+ if obj ["version" ] != 1 or obj ["operation" ] != "weak-target-probe" or obj ["attempt" ] != attempt :
582+ raise TargetError (INFRASTRUCTURE , "runtime-unsupported result version/operation/attempt" )
583+ if obj ["runtime_unsupported" ] is not True or not isinstance (obj ["reason" ], str ):
584+ raise TargetError (INFRASTRUCTURE , "runtime-unsupported result flag/reason wrong" )
403585
404586
405587def _validate_probe_result (obj : Any , attempt : int ) -> None :
@@ -412,11 +594,24 @@ def _validate_probe_result(obj: Any, attempt: int) -> None:
412594 "threw_on_post_collection_raise" ):
413595 if not isinstance (obj [k ], bool ):
414596 raise TargetError (INFRASTRUCTURE , f"probe-result.{ k } must be a boolean" )
415- if not isinstance (obj ["delivered_count" ], int ) or isinstance ( obj [ "delivered_count" ], bool ):
597+ if not _is_int (obj ["delivered_count" ]):
416598 raise TargetError (INFRASTRUCTURE , "probe-result.delivered_count must be an int" )
417599 rw = obj ["resolved_wrapper" ]
418600 if not isinstance (rw , dict ) or set (rw ) != set (_RESOLVED_KEYS ):
419601 raise TargetError (INFRASTRUCTURE , "probe-result.resolved_wrapper is not the exact schema" )
602+ if not _is_int (rw ["ordinal" ]) or rw ["ordinal" ] < 0 :
603+ raise TargetError (INFRASTRUCTURE , "resolved_wrapper.ordinal must be a non-negative int" )
604+ for k in ("slot_sha256" , "assembly_simple_name" , "module_mvid" , "metadata_token" ,
605+ "resolved_signature" ):
606+ if not isinstance (rw [k ], str ):
607+ raise TargetError (INFRASTRUCTURE , f"resolved_wrapper.{ k } must be a string" )
608+ if not re .fullmatch (r"sha256:[0-9a-f]{64}" , rw ["slot_sha256" ]):
609+ raise TargetError (INFRASTRUCTURE , "resolved_wrapper.slot_sha256 is not a lowercase sha256" )
610+ if not re .fullmatch (r"0x[0-9a-f]{8}" , rw ["metadata_token" ]):
611+ raise TargetError (INFRASTRUCTURE , "resolved_wrapper.metadata_token is malformed" )
612+ if not re .fullmatch (r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" ,
613+ rw ["module_mvid" ]):
614+ raise TargetError (INFRASTRUCTURE , "resolved_wrapper.module_mvid is not a GUID" )
420615
421616
422617def _attempt_verdict (p : dict [str , Any ]) -> str :
@@ -739,9 +934,7 @@ def run_verify_target(bundle: str, root: str, plan_path: str, candidates_path: s
739934 dotnet_host , dotnet_host_sha ) # before bind
740935 binding = run_bind (work , dotnet_host , probe_dll_dst , selected_ver , rel ,
741936 bundle_info ["preimage" ], bundle_info ["postimage" ], slots_root ,
742- target , class_fqn , bind_params )
743- if binding ["converted_callsites" ] != len (convert_ids ):
744- raise TargetError (CALLSITE_BINDING , "bound callsite count != converted candidates" )
937+ target , class_fqn , bind_params , convert_ids )
745938 if not (0 <= wrapper_ordinal < len (slot_evidence )):
746939 raise TargetError (INPUT_LAYOUT , "--wrapper-ordinal is out of range" )
747940 if binding ["derived_wrapper_ordinal" ] != wrapper_ordinal :
0 commit comments