|
| 1 | +"""S2 slice 1 — the strict apply-input gate: validate the S1 validated-plan.json, bind |
| 2 | +it by hash to its candidates.json, cross-check every decision against its candidate, and |
| 3 | +guard the source file (SHA + root confinement). NOTHING here parses C# or writes a byte; |
| 4 | +it is the untrusted-input gate that must pass before the (later-slice) `Owen.CSharp.Rewriter` |
| 5 | +is allowed to touch source. |
| 6 | +
|
| 7 | +Two hash-bound inputs (arbiter): the validated-plan is the sole authority for `action`; |
| 8 | +the candidates bundle is the untrusted-but-hash-bound source of the syntax/semantic |
| 9 | +context (event / source / handler identity, containing type) the rewriter's span-node |
| 10 | +guard needs. `bundle_sha256(candidates) == validated_plan.input_bundle_sha256` binds them. |
| 11 | +A stale source, an escaping path, a decision that disagrees with its candidate, or a |
| 12 | +convert_acquire on a non-INotifyPropertyChanged contract is a hard refusal. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import hashlib |
| 18 | +from itertools import pairwise |
| 19 | +from typing import Any |
| 20 | + |
| 21 | +from ownlang.fix_candidates import ( |
| 22 | + S1_ACTIONS, |
| 23 | + CollectError, |
| 24 | + _field, |
| 25 | + _require_canonical_relpath, |
| 26 | + _require_finding_id, |
| 27 | + _require_sha256, |
| 28 | + _resolve_source, |
| 29 | + _validate_span, |
| 30 | + validate_candidates_bundle, |
| 31 | +) |
| 32 | +from ownlang.fix_plan import bundle_sha256 |
| 33 | + |
| 34 | +_PLAN_KEYS = { |
| 35 | + "version", "operation", "input_bundle_sha256", "target_api", |
| 36 | + "selection", "source_files", "decisions", |
| 37 | +} |
| 38 | +_DECISION_KEYS = {"finding_id", "action", "file", "acquire_span"} |
| 39 | + |
| 40 | + |
| 41 | +class ApplyError(Exception): |
| 42 | + """A malformed / unbound / stale apply input. A hard, controlled refusal — never a |
| 43 | + traceback, never a partial apply.""" |
| 44 | + |
| 45 | + |
| 46 | +def validate_validated_plan(plan: Any) -> None: |
| 47 | + """Validate the shape of an S1 validated-plan.json (fix_plan.validate_plan's output). |
| 48 | + Raises ApplyError on any violation.""" |
| 49 | + if not isinstance(plan, dict): |
| 50 | + raise ApplyError("validated-plan must be an object") |
| 51 | + unknown = sorted(set(plan) - _PLAN_KEYS) |
| 52 | + if unknown: |
| 53 | + raise ApplyError(f"validated-plan has unknown field(s): {unknown}") |
| 54 | + if plan.get("version") != 1 or isinstance(plan.get("version"), bool): |
| 55 | + raise ApplyError("validated-plan version must be integer 1") |
| 56 | + if plan.get("operation") != "fix-subscriptions": |
| 57 | + raise ApplyError("validated-plan operation must be 'fix-subscriptions'") |
| 58 | + try: |
| 59 | + _require_sha256( |
| 60 | + _field(plan, "input_bundle_sha256", "str", "validated-plan"), |
| 61 | + "validated-plan.input_bundle_sha256", |
| 62 | + ) |
| 63 | + _field(_field(plan, "target_api", "dict", "validated-plan"), "subscribe", "str", |
| 64 | + "validated-plan.target_api") |
| 65 | + sel = _field(plan, "selection", "dict", "validated-plan") |
| 66 | + at = _field(sel, "allowed_types", "list", "validated-plan.selection") |
| 67 | + if len(at) != 1: |
| 68 | + raise ApplyError("validated-plan.selection.allowed_types must have exactly one entry") |
| 69 | + sfs = _field(plan, "source_files", "list", "validated-plan") |
| 70 | + if len(sfs) != 1: |
| 71 | + raise ApplyError("validated-plan.source_files must have exactly one entry") |
| 72 | + sf = sfs[0] |
| 73 | + if not isinstance(sf, dict): |
| 74 | + raise ApplyError("validated-plan.source_files[0] must be an object") |
| 75 | + _require_canonical_relpath(_field(sf, "path", "str", "validated-plan.source_files[0]"), |
| 76 | + "validated-plan.source_files[0]") |
| 77 | + _require_sha256(_field(sf, "sha256", "str", "validated-plan.source_files[0]"), |
| 78 | + "validated-plan.source_files[0]") |
| 79 | + for i, d in enumerate(_field(plan, "decisions", "list", "validated-plan")): |
| 80 | + dctx = f"validated-plan.decisions[{i}]" |
| 81 | + if not isinstance(d, dict): |
| 82 | + raise ApplyError(f"{dctx}: must be an object") |
| 83 | + extra = sorted(set(d) - _DECISION_KEYS) |
| 84 | + if extra: |
| 85 | + raise ApplyError(f"{dctx}: unknown field(s) {extra}") |
| 86 | + _require_finding_id(_field(d, "finding_id", "str", dctx), dctx) |
| 87 | + action = _field(d, "action", "str", dctx) |
| 88 | + if action not in S1_ACTIONS: |
| 89 | + raise ApplyError(f"{dctx}: out-of-scope action {action!r}") |
| 90 | + _require_canonical_relpath(_field(d, "file", "str", dctx), dctx) |
| 91 | + _validate_span(d.get("acquire_span"), dctx) |
| 92 | + except CollectError as exc: |
| 93 | + raise ApplyError(f"validated-plan: {exc}") from exc |
| 94 | + |
| 95 | + |
| 96 | +def _sha_file(abs_path: str) -> str: |
| 97 | + with open(abs_path, "rb") as fh: |
| 98 | + return "sha256:" + hashlib.sha256(fh.read()).hexdigest() |
| 99 | + |
| 100 | + |
| 101 | +def validate_apply_inputs( |
| 102 | + validated_plan: dict[str, Any], candidates: dict[str, Any], root: str |
| 103 | +) -> dict[str, Any]: |
| 104 | + """Run the full S2 pre-apply gate over the two hash-bound inputs + the source root. |
| 105 | + Returns an apply CONTEXT (the convert_acquire targets with their candidate identities, |
| 106 | + the manual_review ids, the source path, the target API) once every guard passes. |
| 107 | + Raises ApplyError on any violation — no source is read for rewriting, only hashed.""" |
| 108 | + validate_validated_plan(validated_plan) |
| 109 | + try: |
| 110 | + validate_candidates_bundle(candidates) |
| 111 | + except CollectError as exc: |
| 112 | + raise ApplyError(f"invalid candidates bundle: {exc}") from exc |
| 113 | + |
| 114 | + # Hash binding: the candidates are the plan's own input. |
| 115 | + if bundle_sha256(candidates) != validated_plan["input_bundle_sha256"]: |
| 116 | + raise ApplyError("candidates sha256 does not match validated-plan.input_bundle_sha256") |
| 117 | + |
| 118 | + # The plan's materialized authority envelope must EQUAL the canonical projection of |
| 119 | + # the candidates' known keys — not a raw dict copy. This rejects a self-consistent |
| 120 | + # forged pair that smuggled extra nested fields, a wrong type/file, wrong constraints, |
| 121 | + # or mismatched selected_findings into the plan. |
| 122 | + the_type = candidates["selection"]["allowed_types"][0] |
| 123 | + the_source = candidates["source_files"][0] |
| 124 | + expected_target_api = {"subscribe": candidates["target_api"]["subscribe"]} |
| 125 | + expected_selection = { |
| 126 | + "allowed_types": [{"full_name": the_type["full_name"], "file": the_type["file"]}], |
| 127 | + "selected_findings": candidates["selection"].get("selected_findings"), |
| 128 | + "constraints": { |
| 129 | + "max_types_changed": 1, |
| 130 | + "max_files_changed": 1, |
| 131 | + "allow_helper_changes": False, |
| 132 | + "allow_config_changes": False, |
| 133 | + "allow_suppressions": False, |
| 134 | + }, |
| 135 | + } |
| 136 | + expected_source_files = [{"path": the_source["path"], "sha256": the_source["sha256"]}] |
| 137 | + if validated_plan["target_api"] != expected_target_api: |
| 138 | + raise ApplyError("validated-plan.target_api is not the canonical projection of candidates") |
| 139 | + if validated_plan["selection"] != expected_selection: |
| 140 | + raise ApplyError("validated-plan.selection is not the canonical projection of candidates") |
| 141 | + if validated_plan["source_files"] != expected_source_files: |
| 142 | + raise ApplyError("validated-plan.source_files is not the canonical projection") |
| 143 | + |
| 144 | + candidate_by_id = {c["finding_id"]: c for c in candidates["candidates"]} |
| 145 | + seen: set[str] = set() |
| 146 | + convert: list[dict[str, Any]] = [] |
| 147 | + manual: list[str] = [] |
| 148 | + for d in validated_plan["decisions"]: |
| 149 | + fid = d["finding_id"] |
| 150 | + if fid not in candidate_by_id: |
| 151 | + raise ApplyError(f"decision {fid!r} is not a candidate") |
| 152 | + if fid in seen: |
| 153 | + raise ApplyError(f"duplicate decision {fid!r}") |
| 154 | + seen.add(fid) |
| 155 | + c = candidate_by_id[fid] |
| 156 | + if d["file"] != c["file"]: |
| 157 | + raise ApplyError(f"{fid}: decision file != candidate file") |
| 158 | + if d["acquire_span"] != c["acquire_span"]: |
| 159 | + raise ApplyError(f"{fid}: decision acquire_span != candidate acquire_span") |
| 160 | + if d["action"] not in c["allowed_actions"]: |
| 161 | + raise ApplyError(f"{fid}: action {d['action']!r} not allowed by candidate") |
| 162 | + if d["action"] == "convert_acquire": |
| 163 | + if c["event_contract"] != "inotify_property_changed": |
| 164 | + raise ApplyError( |
| 165 | + f"{fid}: convert_acquire requires an inotify_property_changed contract" |
| 166 | + ) |
| 167 | + # The FULL identity contract the future span-node guard needs (not just the |
| 168 | + # human display). validate_candidates_bundle guarantees all are present + str, |
| 169 | + # so the direct index cannot KeyError. |
| 170 | + convert.append({ |
| 171 | + "finding_id": fid, |
| 172 | + "file": c["file"], |
| 173 | + "acquire_span": c["acquire_span"], |
| 174 | + "containing_type": c["containing_type"], |
| 175 | + "event": c["event"], |
| 176 | + "event_identity": c["event_identity"], |
| 177 | + "source": c["source"], |
| 178 | + "source_identity": c["source_identity"], |
| 179 | + "source_identity_kind": c["source_identity_kind"], |
| 180 | + "handler": c["handler"], |
| 181 | + "handler_identity": c["handler_identity"], |
| 182 | + "handler_identity_kind": c["handler_identity_kind"], |
| 183 | + }) |
| 184 | + else: |
| 185 | + manual.append(fid) |
| 186 | + missing = set(candidate_by_id) - seen |
| 187 | + if missing: |
| 188 | + raise ApplyError(f"missing decisions for: {sorted(missing)}") |
| 189 | + |
| 190 | + # convert_acquire edit spans must not overlap. |
| 191 | + ranges = sorted( |
| 192 | + (e["acquire_span"]["start"], e["acquire_span"]["start"] + e["acquire_span"]["length"]) |
| 193 | + for e in convert |
| 194 | + ) |
| 195 | + for (_, end_a), (start_b, _) in pairwise(ranges): |
| 196 | + if start_b < end_a: |
| 197 | + raise ApplyError("overlapping convert_acquire spans") |
| 198 | + |
| 199 | + # Source guard: confine to root, then match the pristine preimage SHA. A read that |
| 200 | + # fails between the confinement check and the hash (file vanished / permission / |
| 201 | + # replaced) is normalized to ApplyError, never a leaked OSError. |
| 202 | + source = candidates["source_files"][0] |
| 203 | + try: |
| 204 | + canonical, abs_path = _resolve_source(root, source["path"]) |
| 205 | + actual_sha = _sha_file(abs_path) |
| 206 | + except CollectError as exc: |
| 207 | + raise ApplyError(f"source path: {exc}") from exc |
| 208 | + except OSError as exc: |
| 209 | + raise ApplyError(f"cannot read source file: {exc}") from exc |
| 210 | + if actual_sha != source["sha256"]: |
| 211 | + raise ApplyError(f"STALE SOURCE / PREIMAGE MISMATCH for {canonical}") |
| 212 | + |
| 213 | + return { |
| 214 | + "source_file": canonical, |
| 215 | + "target_subscribe": validated_plan["target_api"]["subscribe"], |
| 216 | + "convert_acquire": convert, |
| 217 | + "manual_review": manual, |
| 218 | + } |
0 commit comments