scientific-method-engine 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/PKG-INFO +15 -1
  2. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/README.md +14 -0
  3. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/pyproject.toml +1 -1
  4. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/cli.py +2 -0
  5. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/dispatch.py +1 -1
  6. scientific_method_engine-0.5.0/src/scientific_method_engine/x86/effect_order.py +95 -0
  7. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/reports.py +8 -3
  8. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/trace.py +97 -6
  9. scientific_method_engine-0.5.0/tests/test_effect_order.py +114 -0
  10. scientific_method_engine-0.5.0/tests/test_table_continuations.py +161 -0
  11. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/.gitignore +0 -0
  12. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/LICENSE +0 -0
  13. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/__init__.py +0 -0
  14. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/__main__.py +0 -0
  15. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ClearNoReturnFunctions.java +0 -0
  16. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/CreateFunctions.java +0 -0
  17. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ExportBoundedFlow.java +0 -0
  18. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ExportFunctionFingerprints.java +0 -0
  19. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ExportFunctionInventory.java +0 -0
  20. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/MergeFallThroughFragment.java +0 -0
  21. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/RecoverCitedFunctions.java +0 -0
  22. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/RepairReturningCallers.java +0 -0
  23. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportCallArguments.java +0 -0
  24. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportCallPaths.java +0 -0
  25. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportCallSitesWithScalars.java +0 -0
  26. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportCallsToRange.java +0 -0
  27. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportConstantFirstArgumentCalls.java +0 -0
  28. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportDataBytes.java +0 -0
  29. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportDecompileMatches.java +0 -0
  30. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportDecompileWindow.java +0 -0
  31. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportFilePatternInMemory.java +0 -0
  32. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportFirstArgumentCallSummary.java +0 -0
  33. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportFunctionScalarConstants.java +0 -0
  34. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportFunctionSummary.java +0 -0
  35. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportInstructionContext.java +0 -0
  36. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportInstructionWindow.java +0 -0
  37. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportMemoryBlockForFileOffset.java +0 -0
  38. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportMemoryBlocks.java +0 -0
  39. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportRandomnessCandidates.java +0 -0
  40. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportReferences.java +0 -0
  41. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportScalarConstants.java +0 -0
  42. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportStringReferences.java +0 -0
  43. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/ghidra/ReportSymbolReferences.java +0 -0
  44. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/__init__.py +0 -0
  45. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/image.py +0 -0
  46. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/machine.py +0 -0
  47. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/pe.py +0 -0
  48. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/result_flow.py +0 -0
  49. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/src/scientific_method_engine/x86/values.py +0 -0
  50. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/tests/test_dispatch.py +0 -0
  51. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/tests/test_pe.py +0 -0
  52. {scientific_method_engine-0.3.0 → scientific_method_engine-0.5.0}/tests/test_x86.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scientific-method-engine
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Bounded instruction-derived x86 evidence reports for segmented MZ/FBOV and PE32/i386 code.
5
5
  Project-URL: Source, https://github.com/kibertoad/refurbished-dinosaurs-toolkit/tree/main/packages/scientific-method-engine
6
6
  Author: kibertoad
@@ -108,3 +108,17 @@ Commands, inputs, limits and acceptance rules are in
108
108
  [the bounded evidence reporter guide](https://github.com/kibertoad/refurbished-dinosaurs-toolkit/blob/main/docs/bounded-evidence-reporters.md).
109
109
  The reader and engine check that they speak the same prepared-config protocol and refuse to run
110
110
  otherwise.
111
+
112
+ The `effects` command additionally emits path-local `effectOrdering` timelines,
113
+ pre-call write prefixes and bounded local restoration witnesses. Unknown modeled
114
+ or nested service effects stay separate; no return code establishes rollback or
115
+ transactionality. See [the full contract](https://github.com/kibertoad/refurbished-dinosaurs-toolkit/blob/main/docs/bounded-evidence-reporters.md#ordered-effect-path-summaries).
116
+
117
+ Evidenced segmented16 `indirectJumps` declarations also expose separate
118
+ `declaredContinuationPaths`. Ordinary paths retain their unresolved transfer;
119
+ conditional routes preserve prefix/child effects with target-choice, live-table
120
+ and selector assumptions. Effects summaries never mark those routes complete.
121
+ Concrete values/field addresses reject inconsistent rows; overlap and shared
122
+ path/step/visit/total/boundary limits remain explicit. Partial-table splits are
123
+ partial evidence, never complete dispatch or native-reachability claims. See the
124
+ [table contract](https://github.com/kibertoad/refurbished-dinosaurs-toolkit/blob/main/docs/bounded-evidence-reporters.md#evidenced-indirect-jump-tables).
@@ -96,3 +96,17 @@ Commands, inputs, limits and acceptance rules are in
96
96
  [the bounded evidence reporter guide](https://github.com/kibertoad/refurbished-dinosaurs-toolkit/blob/main/docs/bounded-evidence-reporters.md).
97
97
  The reader and engine check that they speak the same prepared-config protocol and refuse to run
98
98
  otherwise.
99
+
100
+ The `effects` command additionally emits path-local `effectOrdering` timelines,
101
+ pre-call write prefixes and bounded local restoration witnesses. Unknown modeled
102
+ or nested service effects stay separate; no return code establishes rollback or
103
+ transactionality. See [the full contract](https://github.com/kibertoad/refurbished-dinosaurs-toolkit/blob/main/docs/bounded-evidence-reporters.md#ordered-effect-path-summaries).
104
+
105
+ Evidenced segmented16 `indirectJumps` declarations also expose separate
106
+ `declaredContinuationPaths`. Ordinary paths retain their unresolved transfer;
107
+ conditional routes preserve prefix/child effects with target-choice, live-table
108
+ and selector assumptions. Effects summaries never mark those routes complete.
109
+ Concrete values/field addresses reject inconsistent rows; overlap and shared
110
+ path/step/visit/total/boundary limits remain explicit. Partial-table splits are
111
+ partial evidence, never complete dispatch or native-reachability claims. See the
112
+ [table contract](https://github.com/kibertoad/refurbished-dinosaurs-toolkit/blob/main/docs/bounded-evidence-reporters.md#evidenced-indirect-jump-tables).
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "scientific-method-engine"
7
7
  # The release workflow writes the published version from the package's release tag.
8
- version = "0.3.0"
8
+ version = "0.5.0"
9
9
  description = "Bounded instruction-derived x86 evidence reports for segmented MZ/FBOV and PE32/i386 code."
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.10"
@@ -9,6 +9,8 @@ CONFIG_LIMIT = 1024 * 1024
9
9
  PREPARED_CONFIG_LIMIT = 16 * 1024 * 1024
10
10
  USAGE = ("Usage: scientific-method-engine <operand|operand-candidates|target|bounds|owner|callees|trace|uses|arguments|"
11
11
  "effects|returns|memory|incoming|call-order|guards|allocation|dispatch> <config.json|->\n"
12
+ "effects includes ordered path writes/calls and local restoration witnesses; transactionality remains unestablished.\n"
13
+ "Declared table continuations are separate conditional paths; ordinary computed transfers remain stopped.\n"
12
14
  " scientific-method-engine ghidra-scripts")
13
15
 
14
16
 
@@ -1,4 +1,4 @@
1
- """Explicit, source-derived indirect jump tables for CFG discovery only."""
1
+ """Explicit, source-derived indirect jump tables for CFG discovery and explicitly conditional path continuations."""
2
2
  from capstone.x86 import X86_OP_IMM
3
3
  from .image import integer
4
4
 
@@ -0,0 +1,95 @@
1
+ """Ordered, path-local effect evidence; no transactional or native-execution claims."""
2
+ from bisect import bisect_right
3
+
4
+
5
+ KINDS = {"read", "write", "call", "call-return", "return", "branch", "compare", "flag-assumption",
6
+ "arithmetic", "value-transfer", "conversion", "flag-write", "flags-save", "flags-restore", "local-iret", "string-operation", "declared-jump-continuation"}
7
+
8
+
9
+ def _storage(event):
10
+ # Equal offsets alone cannot identify storage. Retain the segment/base expression
11
+ # and the complete access width; a narrower restore is a separate access.
12
+ if not all(k in event for k in ("segment", "offset", "width")):
13
+ return None
14
+ # Terms are hashable tuples; compare them directly instead of serializing each access.
15
+ return (event["segment"].get("expression"), event["offset"].get("expression"), event["width"],
16
+ event.get("segmentInterpretation"))
17
+
18
+
19
+ def effect_ordering(report):
20
+ """Add bounded path timelines and explicit local restoration witnesses.
21
+
22
+ The existing tracer bounds every event. Prefix counts index a path's write
23
+ list instead of copying that list for every call (linear report size). A
24
+ witness restores one read value only; it says nothing about other storage,
25
+ aliases, external effects, resources or transactionality.
26
+ """
27
+ summaries = []
28
+ conditional_summaries = []
29
+ combined = [(False, i, p) for i, p in enumerate(report["paths"])]
30
+ combined += [(True, i, p) for i, p in enumerate(report.get("declaredContinuationPaths", []))]
31
+ for conditional, index, path in combined:
32
+ timeline, writes, calls, witnesses = [], [], [], []
33
+ snapshots = {}
34
+ pending = {}
35
+ unknown_orders = []
36
+ last_write = {}
37
+ for event in path["events"]:
38
+ kind = event["kind"]
39
+ if kind in KINDS:
40
+ timeline.append(event)
41
+ if kind == "read":
42
+ key = _storage(event)
43
+ if key is not None:
44
+ snapshots.setdefault(key, {})[event["value"].get("expression")] = event
45
+ elif kind == "write":
46
+ key = _storage(event)
47
+ original = snapshots.get(key, {}).get(event["value"].get("expression"))
48
+ # An equal expression alone is not a restore: an independent constant store or a
49
+ # re-pushed return address can match it. The written value must derive from the read.
50
+ if (original is not None and last_write.get(key, -1) > original["order"]
51
+ and original["site"] in event["value"].get("producers", ())):
52
+ witnesses.append({"readOrder": original["order"], "restoreOrder": event["order"],
53
+ "entry": event["entry"], "width": event["width"],
54
+ "pathReturned": path["returned"],
55
+ "unknownEffectsBetweenCount": len(unknown_orders) - bisect_right(unknown_orders, original["order"]),
56
+ "meaning": "same complete storage/value expression restored locally; not transactionality"})
57
+ writes.append(event)
58
+ last_write[key] = event["order"]
59
+ elif kind == "call":
60
+ call = {"order": event["order"], "site": event["site"], "entry": event["entry"],
61
+ "depth": event["depth"], "target": event.get("target"),
62
+ "writesBeforeCount": len(writes), "status": "unresolved-or-stopped",
63
+ "unknownEffects": True, "continuation": None, "_unknownStart": len(unknown_orders)}
64
+ calls.append(call)
65
+ pending.setdefault((event["site"], event["depth"]), []).append(call)
66
+ elif kind == "call-return":
67
+ stack = pending.get((event["callSite"], event["depth"]), [])
68
+ if stack:
69
+ call = stack.pop()
70
+ modeled = event.get("modeled", False)
71
+ call.update(status="modeled-return" if modeled else "traced-return",
72
+ unknownEffects=bool(modeled or event.get("unknownMemoryEffects") or len(unknown_orders) > call.pop("_unknownStart")),
73
+ returnOrder=event["order"], writesAfterCount=len(writes),
74
+ continuation="assumes balanced returning service; its memory/flag effects are unknown" if modeled
75
+ else "local callee return reached within the instruction model")
76
+ if event.get("modeled") or event.get("unknownMemoryEffects"):
77
+ unknown_orders.append(event["order"])
78
+ for call in calls:
79
+ call.pop("_unknownStart", None)
80
+ boundary = None
81
+ if path.get("stop"):
82
+ boundary = {"site": path.get("stopSite"), "reason": path["stop"],
83
+ "writesBeforeCount": len(writes), "meaning": "later effects are not read"}
84
+ destination = conditional_summaries if conditional else summaries
85
+ destination.append({"path": index, "declaredJumpAssumptions": path.get("declaredJumpAssumptions", []), "returned": path["returned"], "stop": boundary,
86
+ "guards": path["guards"], "timeline": timeline,
87
+ "writeOrders": [w["order"] for w in writes], "calls": calls,
88
+ "localRestorationWitnesses": witnesses,
89
+ "effectCompleteWithinModel": bool(path["returned"] and not unknown_orders and not conditional),
90
+ "transactionality": "not established; local writes and result codes cannot prove external rollback"})
91
+ report["effectOrdering"] = {"paths": summaries, "declaredContinuationPaths": conditional_summaries,
92
+ "allPathsRead": report["completeWithinModel"],
93
+ "nativeReachability": "unconfirmed",
94
+ "meaning": "separate conditional paths; write prefixes index each path's writeOrders"}
95
+ return report
@@ -5,6 +5,7 @@ from capstone import CS_AC_READ, CS_AC_WRITE
5
5
  from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_OP_REG
6
6
  from .machine import State, StopPath, REGISTERS, ALIASES, segment_register
7
7
  from .values import unknown
8
+ from .effect_order import effect_ordering
8
9
  from .result_flow import return_flows
9
10
  from .image import Image, integer
10
11
  from .trace import (trace, walk, call_target, unsupported_transfer, uncovered, base_mnemonic, OVERLAP_REASON, CONTESTED_REASON,
@@ -225,7 +226,8 @@ def uses(image, config):
225
226
  for root in established[index:]:
226
227
  stops.setdefault(root, "entry not traced: entry or total instruction budget exhausted")
227
228
  break
228
- report = trace(image, {**config, "entry": at, "totalSteps": remaining, "stringIterations": string_remaining})
229
+ report = trace(image, {**config, "entry": at, "totalSteps": remaining, "stringIterations": string_remaining},
230
+ continue_declared_jumps=False)
229
231
  remaining -= report["stepsUsed"]
230
232
  string_remaining -= report["stringIterationsUsed"]
231
233
  if not report["completeWithinModel"]:
@@ -495,7 +497,7 @@ def dispatch(image, config):
495
497
  raise ValueError("Dispatch table mapping differs from PE source sections")
496
498
  for value in values:
497
499
  integer(value, 0, (1 << ALIASES[input_reg][2]) - 1, "input value")
498
- report = trace(image, {**config, "registers": {**config.get("registers", {}), input_reg: value}})
500
+ report = trace(image, {**config, "registers": {**config.get("registers", {}), input_reg: value}}, continue_declared_jumps=False)
499
501
  outcomes = []
500
502
  for path in report["paths"]:
501
503
  reached = bool(path["instructionPath"]) and path["instructionPath"][-1] == site
@@ -593,7 +595,8 @@ def allocations(report, config):
593
595
  "observedExtentBytes": capacity,
594
596
  "capacity": "conditional on evidenced extent units and pointer identity" if extent else "unresolved: request units and bounded writes do not establish allocated extent",
595
597
  "rollback": "unproven; failure returns do not undo earlier writes"})
596
- return {"allocations": results, "paths": report["paths"], "gaps": report["gaps"], "completeWithinModel": report["completeWithinModel"]}
598
+ return {"allocations": results, "paths": report["paths"], "declaredContinuationPaths": report["declaredContinuationPaths"],
599
+ "gaps": report["gaps"], "completeWithinModel": report["completeWithinModel"]}
597
600
 
598
601
 
599
602
  def operand_provenance(image, config):
@@ -1379,6 +1382,8 @@ def _run_report(image, config, command):
1379
1382
  report = near_pointer_provenance(report, config)
1380
1383
  if command == "allocation":
1381
1384
  return allocations(report, config)
1385
+ if command == "effects":
1386
+ report = effect_ordering(report)
1382
1387
  if command == "returns":
1383
1388
  report = return_flows(report, config)
1384
1389
  if command != "trace":
@@ -214,7 +214,14 @@ def snapshot(state):
214
214
  return {name: state.reg(name).report() for name in ALIASES}
215
215
 
216
216
 
217
- def trace(image, config):
217
+ def trace(image, config, continue_declared_jumps=True):
218
+ """Trace bounded paths, preserving declared-table continuations as separate conditional evidence.
219
+
220
+ Ordinary paths run first. A path stopped at a declared indirect jump is then
221
+ continued once per surviving table target, so the continuations never take
222
+ budget from an ordinary path. Callers that read only ordinary paths pass
223
+ continue_declared_jumps=False.
224
+ """
218
225
  entry = integer(config.get("entry"), 0, len(image.data) - 1, "entry")
219
226
  if not any(entry in r["entries"] for r in image.regions):
220
227
  raise ValueError("Trace entry must be an established region entry")
@@ -246,6 +253,11 @@ def trace(image, config):
246
253
  if r not in ALIASES or type(n) is not int or not 0 <= n < 1 << ALIASES[r][2]:
247
254
  raise ValueError("Invalid model register")
248
255
  pending, outputs, global_gaps = [State(entry, image, config)], [], []
256
+ conditional_outputs = []
257
+ # States stopped at a declared jump site, continued after the ordinary paths.
258
+ deferred = []
259
+ boundary_cache = {}
260
+ boundary_budget = integer(config.get("instructionLimit", 10000), 1, 100000, "instruction limit")
249
261
  created = 1
250
262
  total_steps = 0
251
263
  total_string_steps = 0
@@ -264,11 +276,88 @@ def trace(image, config):
264
276
  string_effect(s, ins, count, remaining)
265
277
 
266
278
  def finish(s, reason=None, returned=False):
267
- outputs.append({"returned": returned, "stop": reason, "stopSite": None if returned else s.at, "steps": s.steps,
268
- "instructionPath": s.path, "guards": s.guards, "events": s.events,
269
- "registers": snapshot(s), "conditionalModels": s.conditional})
279
+ path = {"returned": returned, "stop": reason, "stopSite": None if returned else s.at, "steps": s.steps,
280
+ "instructionPath": s.path, "guards": s.guards, "events": s.events,
281
+ "registers": snapshot(s), "conditionalModels": s.conditional}
282
+ assumptions = getattr(s, "declared_jump_assumptions", [])
283
+ if assumptions:
284
+ path["declaredJumpAssumptions"] = assumptions
285
+ conditional_outputs.append(path)
286
+ else:
287
+ outputs.append(path)
270
288
 
271
- while pending:
289
+ def declared_continuations(s):
290
+ # Each child follows one declared target under a recorded assumption. The operand
291
+ # keeps its traced value; no register or table word is assigned. s is a copy taken
292
+ # at the jump, so the operand read below never enters the stopped ordinary path.
293
+ nonlocal created, boundary_budget
294
+ declaration = image.indirect_jumps[s.at]
295
+ ins = image.decode(s.at)
296
+ root_entry = s.frames[-1]["entry"]
297
+ if root_entry not in boundary_cache:
298
+ if boundary_budget < 1:
299
+ global_gaps.append({"site": s.at, "reason": "conditional table boundary instruction limit"})
300
+ return
301
+ seen, walk_gaps, _, undecoded, contested = walk(image, [root_entry], boundary_budget)
302
+ boundary_budget -= max(1, len(seen) + len(undecoded) + len(contested))
303
+ # A truncated walk never saw the instructions that could contest a target start.
304
+ if any(g["reason"] == "instruction limit" for g in walk_gaps):
305
+ seen = {}
306
+ boundary_cache[root_entry] = seen
307
+ seen = boundary_cache[root_entry]
308
+ try:
309
+ value = s.get(ins, ins.operands[0], image)
310
+ address = s.address(ins, ins.operands[0]) if ins.operands[0].type == X86_OP_MEM else None
311
+ except StopPath as error:
312
+ global_gaps.append({"site": s.at, "reason": "conditional table operand unresolved: " + str(error)})
313
+ return
314
+ groups = {}
315
+ region = image.region(s.at)
316
+ choice_key = repr(value.term)
317
+ previous = getattr(s, "declared_jump_choices", {}).get(choice_key)
318
+ for row in declaration["rows"]:
319
+ if address is not None:
320
+ segment, offset, _ = address
321
+ # A table word inside another declared region uses that region's mapping; one
322
+ # outside every region is read as an extension of the jump's region.
323
+ row_region = image.region(row["operandSite"]) or region
324
+ row_offset = row_region["ip"] + row["operandSite"] - row_region["start"]
325
+ if segment.number is not None and offset.number is not None:
326
+ if not 0 <= row_offset <= 65534 or segment.number * 16 + offset.number != row_region["segment"] * 16 + row_offset:
327
+ continue
328
+ if value.number is not None and value.number != row["rawOffset"]:
329
+ continue
330
+ if previous is not None and row["rawOffset"] not in previous:
331
+ continue
332
+ groups.setdefault(row["target"], []).append(row)
333
+ for target, rows in groups.items():
334
+ if target not in seen:
335
+ global_gaps.append({"site": s.at, "target": target,
336
+ "reason": "conditional table target boundary is unresolved or instruction-limited"})
337
+ continue
338
+ if created >= max_paths:
339
+ global_gaps.append({"site": s.at, "reason": "path limit"})
340
+ break
341
+ child = deepcopy(s)
342
+ child.declared_jump_choices = {**getattr(child, "declared_jump_choices", {}),
343
+ choice_key: {r["rawOffset"] for r in rows}}
344
+ assumption = {"site": s.at, "target": target, "tableIndices": [r["index"] for r in rows],
345
+ "operandSites": [r["operandSite"] for r in rows], "exhaustive": declaration["exhaustive"],
346
+ "evidence": declaration["evidence"], "tableEvidence": declaration["table"]["evidence"],
347
+ "operand": value.report(),
348
+ "operandAddress": None if address is None else
349
+ {"segment": address[0].report(), "offset": address[1].report(), "segmentRegister": address[2]},
350
+ "meaning": "conditional target choice from source table; selector, live table contents and reachability unverified"}
351
+ child.declared_jump_assumptions = [*getattr(child, "declared_jump_assumptions", []), assumption]
352
+ child.event("declared-jump-continuation", **{k: v for k, v in assumption.items() if k != "site"})
353
+ child.at = target
354
+ pending.append(child)
355
+ created += 1
356
+
357
+ while pending or deferred:
358
+ if not pending:
359
+ declared_continuations(deferred.pop(0))
360
+ continue
272
361
  state = pending.pop()
273
362
  try:
274
363
  while True:
@@ -468,6 +557,8 @@ def trace(image, config):
468
557
  if m in ("jmp", "ljmp"):
469
558
  target, provenance = call_target(image, at, ins)
470
559
  if target is None:
560
+ if continue_declared_jumps and at in image.indirect_jumps:
561
+ deferred.append(deepcopy(state))
471
562
  raise StopPath("unresolved jump: " + provenance.get("reason", "outside mapped code"))
472
563
  if m == "ljmp":
473
564
  target_region = image.region(target)
@@ -519,7 +610,7 @@ def trace(image, config):
519
610
  state.at = following
520
611
  except StopPath as error:
521
612
  finish(state, str(error))
522
- return {"paths": outputs, "gaps": global_gaps,
613
+ return {"paths": outputs, "declaredContinuationPaths": conditional_outputs, "gaps": global_gaps,
523
614
  "completeWithinModel": not global_gaps and bool(outputs) and all(p["returned"] for p in outputs),
524
615
  "nativeReachability": "unconfirmed", "stepsUsed": total_steps, "stringIterationsUsed": total_string_steps,
525
616
  "limits": {"steps": max_steps, "paths": max_paths, "depth": max_depth}}
@@ -0,0 +1,114 @@
1
+ """Synthetic path evidence; no original bytes or claims."""
2
+ import unittest
3
+ from test_x86 import Code, report
4
+
5
+
6
+ class EffectOrderTests(unittest.TestCase):
7
+ def paths(self, result):
8
+ return result["effectOrdering"]["paths"]
9
+
10
+ def test_early_exit_has_distinct_write_contract_at_common_return(self):
11
+ c = Code().emit("85 c0").branch("74", "return").emit("c7 06 20 00 01 00").label("return").emit("c3")
12
+ r = report(c, "effects")
13
+ self.assertTrue(r["effectOrdering"]["allPathsRead"])
14
+ self.assertEqual(sorted(len(p["writeOrders"]) for p in self.paths(r)), [0, 1])
15
+ self.assertTrue(all(p["guards"] for p in self.paths(r)))
16
+ self.assertTrue(all(p["transactionality"].startswith("not established") for p in self.paths(r)))
17
+
18
+ def test_writes_before_modeled_failure_and_later_predicate_remain_separate(self):
19
+ c = Code().emit("c7 06 20 00 01 00").label("service").branch("e8", "serviceBody")
20
+ c.emit("83 3e 22 00 00").branch("74", "return").emit("c7 06 24 00 02 00").label("return").emit("c3")
21
+ c.label("serviceBody").emit("c3")
22
+ r = report(c, "effects", callModels=[{"site": c.labels["service"], "evidence": "synthetic conditional failure",
23
+ "cases": [{"registers": {"ax": 1}}]}])
24
+ for p in self.paths(r):
25
+ call = p["calls"][0]
26
+ self.assertEqual(call["writesBeforeCount"], 1)
27
+ self.assertEqual(call["status"], "modeled-return")
28
+ self.assertTrue(call["unknownEffects"])
29
+ self.assertFalse(p["effectCompleteWithinModel"])
30
+ branch = next(e for e in p["timeline"] if e["kind"] == "branch")
31
+ compare = next(e for e in p["timeline"] if e["kind"] == "compare")
32
+ self.assertGreater(compare["order"], call["returnOrder"])
33
+ self.assertGreater(branch["order"], compare["order"])
34
+ self.assertEqual(branch["flagProducer"], compare["site"])
35
+
36
+ def test_child_writes_are_not_lost_in_caller_bracket(self):
37
+ c = Code().emit("c7 06 20 00 01 00").branch("e8", "child").emit("c7 06 20 00 00 00 c3")
38
+ c.label("child").emit("c7 06 20 00 03 00 c3")
39
+ p = self.paths(report(c, "effects", registers={"ds": 0x2000, "ss": 0x3000, "sp": 0xff00}))[0]
40
+ writes = [e for e in p["timeline"] if e["kind"] == "write" and e.get("role") != "stack"]
41
+ self.assertTrue(any(e["entry"] == c.labels["child"] and e["value"]["value"] == 3 for e in writes))
42
+ self.assertEqual(p["calls"][0]["status"], "traced-return")
43
+ self.assertFalse(p["calls"][0]["unknownEffects"])
44
+
45
+ def test_unknown_nested_service_propagates_to_outer_call(self):
46
+ c = Code().branch("e8", "child").emit("c3").label("child").label("service").branch("e8", "external").emit("c3")
47
+ c.label("external").emit("c3")
48
+ r = report(c, "effects", callModels=[{"site": c.labels["service"], "evidence": "unknown child service", "cases": [{}]}])
49
+ p = self.paths(r)[0]
50
+ self.assertTrue(all(call["unknownEffects"] for call in p["calls"]))
51
+ self.assertFalse(p["effectCompleteWithinModel"])
52
+
53
+ def test_snapshot_restore_witness_does_not_cover_bypass_or_other_storage(self):
54
+ c = Code().emit("a1 20 00 c7 06 20 00 02 00 85 db").branch("74", "return")
55
+ c.emit("a3 20 00").label("return").emit("c3")
56
+ r = report(c, "effects")
57
+ self.assertEqual(sorted(len(p["localRestorationWitnesses"]) for p in self.paths(r)), [0, 1])
58
+ w = next(w for p in self.paths(r) for w in p["localRestorationWitnesses"])
59
+ self.assertEqual(w["width"], 2)
60
+ self.assertTrue(w["pathReturned"])
61
+ self.assertEqual(w["unknownEffectsBetweenCount"], 0)
62
+ for code in ["a1 20 00 c7 06 20 00 02 00 a3 22 00 c3", # other storage
63
+ "a1 20 00 c7 06 20 00 02 00 a2 20 00 c3", # partial width
64
+ "a1 20 00 c7 06 20 00 02 00 b8 03 00 a3 20 00 c3", # changed value
65
+ "a1 20 00 c7 06 20 00 02 00 26 a3 20 00 c3"]: # other segment
66
+ self.assertFalse(self.paths(report(code, "effects"))[0]["localRestorationWitnesses"])
67
+
68
+ def test_equal_value_without_read_provenance_is_not_a_restore(self):
69
+ # An independent immediate store equals the value read earlier.
70
+ p = self.paths(report("c7 06 20 00 00 00 83 3e 20 00 00 c7 06 20 00 05 00 c7 06 20 00 00 00 c3", "effects"))[0]
71
+ self.assertFalse(p["localRestorationWitnesses"])
72
+ # A loop re-pushes the same return address into a stack slot another call overwrote.
73
+ c = Code().emit("b9 02 00").label("top").branch("e8", "a").branch("e8", "b").emit("49").branch("75", "top").emit("c3")
74
+ c.label("a").emit("c3").label("b").emit("c3")
75
+ r = report(c, "effects", registers={"ss": 0x3000, "sp": 0xff00})
76
+ self.assertTrue(any(p["returned"] for p in self.paths(r)))
77
+ self.assertFalse([w for p in self.paths(r) for w in p["localRestorationWitnesses"]])
78
+
79
+ def test_timeline_keeps_flag_assumptions_that_split_paths(self):
80
+ r = report("b9 02 00 f3 a4 c3", "effects")
81
+ self.assertEqual(len(self.paths(r)), 2)
82
+ values = sorted(next(e for e in p["timeline"] if e["kind"] == "flag-assumption")["value"]["value"] for p in self.paths(r))
83
+ self.assertEqual(values, [0, 1])
84
+
85
+ def test_restore_prefix_is_not_a_completed_return(self):
86
+ p = self.paths(report("a1 20 00 c7 06 20 00 02 00 a3 20 00 ee", "effects"))[0]
87
+ self.assertTrue(p["localRestorationWitnesses"])
88
+ self.assertFalse(p["localRestorationWitnesses"][0]["pathReturned"])
89
+ self.assertFalse(p["effectCompleteWithinModel"])
90
+
91
+ def test_unknown_service_between_snapshot_and_restore_remains_visible(self):
92
+ c = Code().emit("a1 20 00 50 c7 06 20 00 02 00").label("service").branch("e8", "child")
93
+ c.emit("58 a3 20 00 c3").label("child").emit("c3")
94
+ r = report(c, "effects", callModels=[{"site": c.labels["service"], "evidence": "unknown external effects", "cases": [{}]}])
95
+ p = self.paths(r)[0]
96
+ self.assertFalse(p["effectCompleteWithinModel"])
97
+ # The model invalidates stack memory too; no surviving snapshot value is guessed.
98
+ self.assertFalse(p["localRestorationWitnesses"])
99
+
100
+ def test_port_stop_and_path_caps_cannot_prove_absent_later_effects(self):
101
+ p = self.paths(report("c7 06 20 00 01 00 ee c7 06 22 00 02 00 c3", "effects"))[0]
102
+ self.assertFalse(p["returned"])
103
+ self.assertEqual(p["stop"]["writesBeforeCount"], 1)
104
+ self.assertIn("port", p["stop"]["reason"].lower())
105
+ r = report("85 c0 74 06 c7 06 20 00 01 00 c3", "effects", maxPaths=1)
106
+ self.assertFalse(r["effectOrdering"]["allPathsRead"])
107
+ self.assertTrue(r["gaps"])
108
+ r = report("c7 06 20 00 01 00 c3", "effects", maxSteps=1)
109
+ self.assertFalse(self.paths(r)[0]["returned"])
110
+ self.assertEqual(self.paths(r)[0]["stop"]["writesBeforeCount"], 1)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ unittest.main()
@@ -0,0 +1,161 @@
1
+ """Conditional source-table routes never replace unresolved execution evidence."""
2
+ import copy
3
+ import unittest
4
+ from test_dispatch import fixture
5
+ from scientific_method_engine.x86.reports import run_report
6
+
7
+
8
+ class TableContinuationTests(unittest.TestCase):
9
+ def test_stopped_path_is_retained_beside_conditional_child_routes(self):
10
+ data, config = fixture()
11
+ r = run_report(data, config, "effects")
12
+ self.assertFalse(r["completeWithinModel"])
13
+ self.assertFalse(any(p["returned"] for p in r["paths"]))
14
+ routes = r["declaredContinuationPaths"]
15
+ self.assertEqual(len(routes), 2)
16
+ self.assertTrue(all(p["returned"] for p in routes))
17
+ self.assertTrue(all(p["declaredJumpAssumptions"] for p in routes))
18
+ self.assertTrue(any(e["kind"] == "call" for p in routes for e in p["events"]))
19
+ self.assertTrue(all(not p["effectCompleteWithinModel"] for p in r["effectOrdering"]["declaredContinuationPaths"]))
20
+
21
+ def test_partial_table_and_concrete_mismatch_remain_unresolved(self):
22
+ data, config = fixture()
23
+ config["indirectJumps"][0]["exhaustive"] = False
24
+ r = run_report(data, config, "trace")
25
+ self.assertTrue(r["declaredContinuationPaths"])
26
+ self.assertTrue(all(not p["declaredJumpAssumptions"][0]["exhaustive"] for p in r["declaredContinuationPaths"]))
27
+ r = run_report(data, {**config, "registers": {"bx": 20}}, "trace")
28
+ self.assertFalse(r["declaredContinuationPaths"])
29
+ self.assertFalse(r["completeWithinModel"])
30
+
31
+ def test_duplicate_targets_share_one_conditional_route(self):
32
+ data, config = fixture()
33
+ data = bytearray(data)
34
+ data[34:36] = data[32:34]
35
+ r = run_report(bytes(data), config, "trace")
36
+ self.assertEqual(len(r["declaredContinuationPaths"]), 1)
37
+ self.assertEqual(r["declaredContinuationPaths"][0]["declaredJumpAssumptions"][0]["tableIndices"], [0, 1])
38
+
39
+ def test_overlap_targets_cannot_establish_a_continuation(self):
40
+ data, config = fixture()
41
+ data = bytearray(data)
42
+ data[8:12] = bytes.fromhex("b8 e8 00 c3")
43
+ data[34:36] = bytes.fromhex("09 00")
44
+ r = run_report(bytes(data), config, "trace")
45
+ self.assertFalse(r["declaredContinuationPaths"])
46
+ self.assertTrue(any("boundary" in g["reason"] for g in r["gaps"]))
47
+
48
+ def test_limits_do_not_leave_a_positive_continuation_contract(self):
49
+ data, config = fixture()
50
+ for change in ({"maxPaths": 1}, {"maxSteps": 1}, {"totalSteps": 1}, {"instructionLimit": 1}):
51
+ r = run_report(data, {**config, **change}, "trace")
52
+ self.assertFalse(any(p["returned"] for p in r["declaredContinuationPaths"]))
53
+ self.assertFalse(r["completeWithinModel"])
54
+
55
+ def test_conditional_loop_keeps_visits_and_prefix_write(self):
56
+ data, config = fixture()
57
+ data = bytearray(data)
58
+ data[0:8] = bytes.fromhex("c7 06 20 00 01 00 ff e3")
59
+ data[16:18] = bytes.fromhex("eb fe")
60
+ config = copy.deepcopy(config)
61
+ config["indirectJumps"][0]["site"] = 6
62
+ r = run_report(bytes(data), {**config, "visitLimit": 1}, "effects")
63
+ self.assertTrue(any(p["returned"] for p in r["declaredContinuationPaths"]))
64
+ self.assertTrue(any(p["stop"] and "repeated" in p["stop"] for p in r["declaredContinuationPaths"]))
65
+ self.assertTrue(all(p["timeline"][0]["kind"] == "write" for p in r["effectOrdering"]["declaredContinuationPaths"]))
66
+
67
+ def test_repeated_operand_cannot_choose_a_contradictory_target(self):
68
+ data, config = fixture()
69
+ data = bytearray(data)
70
+ data[16:18] = bytes.fromhex("eb ee")
71
+ r = run_report(bytes(data), {**config, "visitLimit": 3}, "trace")
72
+ returned = [p for p in r["declaredContinuationPaths"] if p["returned"]]
73
+ self.assertTrue(returned)
74
+ self.assertTrue(all({a["target"] for a in p["declaredJumpAssumptions"]} == {8} for p in returned))
75
+ self.assertTrue(any(p["stop"] and "repeated" in p["stop"] for p in r["declaredContinuationPaths"]))
76
+
77
+ def test_resolved_memory_field_selects_only_its_declared_row(self):
78
+ data, config = fixture()
79
+ data = bytearray(data)
80
+ data[0:3] = bytes.fromhex("2e ff 27")
81
+ r = run_report(bytes(data), {**config, "registers": {"bx": 32}}, "trace")
82
+ self.assertEqual(len(r["declaredContinuationPaths"]), 1)
83
+ a = r["declaredContinuationPaths"][0]["declaredJumpAssumptions"][0]
84
+ self.assertEqual(a["tableIndices"], [0])
85
+ self.assertEqual(a["operandAddress"]["segmentRegister"], "cs")
86
+ r = run_report(bytes(data), {**config, "registers": {"bx": 36}}, "trace")
87
+ self.assertFalse(r["declaredContinuationPaths"])
88
+
89
+ def test_prefix_mutation_precedes_child_failure_and_bypasses_clear(self):
90
+ data = bytearray([0x90] * 80)
91
+ data[0:8] = bytes.fromhex("c7 06 20 00 01 00 ff e3")
92
+ data[16:27] = bytes.fromhex("e8 1d 00 85 c0 74 05 b8 ff ff c3")
93
+ data[28:35] = bytes.fromhex("c7 06 20 00 00 00 c3")
94
+ data[40:44] = bytes.fromhex("b8 00 00 c3")
95
+ data[48:52] = bytes.fromhex("b8 ff ff c3")
96
+ data[64:68] = bytes.fromhex("10 00 28 00")
97
+ config = {"entry": 0, "registers": {"ds": 0x2000, "ss": 0x3000, "sp": 0xff00},
98
+ "regions": [{"name": "synthetic", "start": 0, "end": 56, "ip": 0, "segment": 0x1000,
99
+ "entries": [0], "evidence": "constructed bounded code"}],
100
+ "indirectJumps": [{"site": 6, "exhaustive": True, "evidence": "synthetic BX choice",
101
+ "table": {"start": 64, "count": 2, "stride": 2, "evidence": "synthetic word targets"}}]}
102
+ r = run_report(bytes(data), config, "effects")
103
+ failure = next(p for p in r["declaredContinuationPaths"] if p["returned"] and p["registers"]["ax"]["value"] == 65535)
104
+ write = next(e for e in failure["events"] if e["kind"] == "write" and e["site"] == 0)
105
+ call = next(e for e in failure["events"] if e["kind"] == "call" and e["site"] == 16)
106
+ self.assertLess(write["order"], call["order"])
107
+ self.assertFalse(any(e["kind"] == "write" and e["site"] == 28 for e in failure["events"]))
108
+ self.assertFalse(r["completeWithinModel"])
109
+ self.assertTrue(all(not p["effectCompleteWithinModel"] for p in r["effectOrdering"]["declaredContinuationPaths"]))
110
+
111
+ def test_continuations_never_take_budget_from_ordinary_paths(self):
112
+ data = bytearray([0x90] * 52)
113
+ data[0:2] = bytes.fromhex("72 1e")
114
+ data[2:8] = bytes.fromhex("85 c0 74 01 c3 c3")
115
+ data[32:34] = bytes.fromhex("ff e3")
116
+ data[40:45] = bytes.fromhex("85 c9 74 00 c3")
117
+ data[48:52] = bytes.fromhex("28 00 2c 00")
118
+ config = {"entry": 0, "maxPaths": 3,
119
+ "regions": [{"name": "code", "start": 0, "end": 48, "segment": 4096, "ip": 0,
120
+ "entries": [0], "evidence": "constructed mappings"}],
121
+ "indirectJumps": [{"site": 32, "evidence": "constructed BX consumer", "exhaustive": True,
122
+ "table": {"start": 48, "count": 2, "stride": 2, "evidence": "constructed words"}}]}
123
+ declared = run_report(bytes(data), config, "trace")
124
+ plain = run_report(bytes(data), {k: v for k, v in config.items() if k != "indirectJumps"}, "trace")
125
+ self.assertEqual([(p["returned"], p["stop"]) for p in declared["paths"]],
126
+ [(p["returned"], p["stop"]) for p in plain["paths"]])
127
+ self.assertEqual(len(declared["paths"]), 3)
128
+ self.assertTrue(any(g["reason"] == "path limit" and g["site"] == 32 for g in declared["gaps"]))
129
+
130
+ def test_operand_read_stays_out_of_the_stopped_ordinary_path(self):
131
+ data, config = fixture()
132
+ data = bytearray(data)
133
+ data[0:3] = bytes.fromhex("2e ff 27")
134
+ r = run_report(bytes(data), {**config, "registers": {"bx": 32}}, "trace")
135
+ self.assertFalse(any(e["kind"] == "read" for e in r["paths"][0]["events"]))
136
+ self.assertTrue(any(e["kind"] == "read" for e in r["declaredContinuationPaths"][0]["events"]))
137
+
138
+ def test_truncated_boundary_walk_establishes_no_target(self):
139
+ data, config = fixture()
140
+ r = run_report(data, {**config, "instructionLimit": 2}, "trace")
141
+ self.assertFalse(r["declaredContinuationPaths"])
142
+ self.assertTrue(any("boundary" in g["reason"] for g in r["gaps"]))
143
+
144
+ def test_field_address_uses_the_table_region_mapping(self):
145
+ data, config = fixture()
146
+ data = bytearray(data)
147
+ data[0:3] = bytes.fromhex("2e ff 27")
148
+ config = copy.deepcopy(config)
149
+ config["regions"][0]["end"] = 32
150
+ config["regions"].append({"name": "table", "start": 32, "end": 40, "segment": 4096, "ip": 0x100,
151
+ "entries": [36], "evidence": "constructed table mapping"})
152
+ r = run_report(bytes(data), {**config, "registers": {"bx": 0x100}}, "trace")
153
+ self.assertEqual([p["declaredJumpAssumptions"][0]["tableIndices"] for p in r["declaredContinuationPaths"]], [[0]])
154
+ r = run_report(bytes(data), {**config, "registers": {"bx": 32}}, "trace")
155
+ self.assertFalse(r["declaredContinuationPaths"])
156
+
157
+ def test_allocation_retains_the_conditional_routes(self):
158
+ data, config = fixture()
159
+ allocation = run_report(data, {**config, "allocations": [{"site": 8, "unitBytes": 16, "unitEvidence": "constructed unit",
160
+ "requestRegister": "bx"}]}, "allocation")
161
+ self.assertEqual(len(allocation["declaredContinuationPaths"]), 2)