Skip to content

Commit 25a3b25

Browse files
authored
Merge pull request #287: S2 slice 1 — apply-input gate (validate + hash binding + guards)
S2 slice 1 — the apply-input gate (validate + hash binding + source guards). The first slice of S2 (deterministic apply). Pure Python; nothing parses C# or writes a byte. It is the untrusted-input gate that must pass before the load-bearing next slice (Owen.CSharp.Rewriter) is allowed to touch source. `validate_apply_inputs(validated_plan, candidates, root)` binds the two inputs by hash (bundle_sha256(candidates) == validated_plan.input_bundle_sha256), requires the plan's authority envelope to equal the canonical projection of the candidates, cross-checks every decision against its candidate (bijection, exact file+span, action ∈ allowed_actions, convert_acquire only on an inotify_property_changed contract), refuses overlapping spans, confines the source path to the root, and matches its bytes to the pristine preimage SHA. The shared candidates validator now requires the full identity set so the apply context can hand the future Roslyn span-node guard the real identity contract. Every violation is a controlled ApplyError. Arbiter: S2 slice 1 ACCEPTED / CLOSED at ed575e0. Carry-forward: the rewriter must itself re-guarantee the pristine SHA immediately before opening the file (no TOCTOU gap).
2 parents 139a756 + ed575e0 commit 25a3b25

3 files changed

Lines changed: 497 additions & 1 deletion

File tree

‎ownlang/fix_apply.py‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
}

‎ownlang/fix_candidates.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,10 @@ def validate_candidates_bundle(bundle: Any) -> None:
416416
raise CollectError(f"{cctx}: containing_type must be {type_name!r}")
417417
if _field(c, "file", "str", cctx) != source_path:
418418
raise CollectError(f"{cctx}: file must be {source_path!r}")
419-
for k in ("event", "source", "handler"):
419+
# Display + full identity set — required so downstream (S2 apply) can index them
420+
# without a KeyError and the span-node guard gets the real identity contract.
421+
for k in ("event", "source", "handler", "event_identity", "source_identity",
422+
"source_identity_kind", "handler_identity", "handler_identity_kind"):
420423
_field(c, k, "str", cctx)
421424
contract = _field(c, "event_contract", "str", cctx)
422425
if contract not in _CONTRACTS:

0 commit comments

Comments
 (0)