flashgate 0.7.2__tar.gz → 0.8.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.
- {flashgate-0.7.2 → flashgate-0.8.0}/PKG-INFO +21 -7
- {flashgate-0.7.2 → flashgate-0.8.0}/README.md +20 -6
- flashgate-0.8.0/flashgate/backends.py +313 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/board.py +35 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/cli.py +36 -22
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/swdsig.py +3 -2
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/PKG-INFO +21 -7
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/SOURCES.txt +2 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/pyproject.toml +1 -1
- flashgate-0.8.0/tests/test_backends.py +307 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/LICENSE +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/__init__.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/__main__.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/bench.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/bench_serve.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/flasher.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/gatestate.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/mcp_server.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/probes.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/records.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/results.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/serialmon.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/sttools.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/dependency_links.txt +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/entry_points.txt +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/requires.txt +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/top_level.txt +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/setup.cfg +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_bench.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_bench_serve.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_board.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_cli.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_flasher.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_gatestate.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_hook_stop.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_mcp.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_probes.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_records.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_serialmon.py +0 -0
- {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_swdsig.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Lion-1209/flashgate
|
|
@@ -244,10 +244,24 @@ Stop-hook escalation — plus the full session of a real Claude Code agent
|
|
|
244
244
|
getting blocked, diagnosing the firmware↔profile contract, fixing both
|
|
245
245
|
sides, and passing on hardware ([24 MB GIF, release asset](https://github.com/Lion-1209/flashgate/releases/download/v0.3.0/5-agent-blocked.gif)).
|
|
246
246
|
|
|
247
|
+
## Debug backends (Phase 2)
|
|
248
|
+
|
|
249
|
+
The verify pipeline is backend-agnostic: `flash.adapter:` in the board
|
|
250
|
+
profile picks the probe tool — `cubeprogrammer` (default, the original
|
|
251
|
+
implementation), `openocd` (same ST-Link, no ST toolchain needed, and
|
|
252
|
+
the standard route to Linux/ARM64 bench hosts like a Raspberry Pi), or
|
|
253
|
+
`fake` (scripted, for tests). Validated on the same Apollo board:
|
|
254
|
+
swapping to `openocd` is a one-line profile change — verify stays green
|
|
255
|
+
with identical records, zero upper-layer edits, and runs slightly
|
|
256
|
+
faster. OpenOCD is discovered via PATH or `$OPENOCD_BIN`; the target
|
|
257
|
+
script maps from the MCU family (`STM32H7*` -> `stm32h7x`, override
|
|
258
|
+
with `flash.openocd_target`).
|
|
259
|
+
|
|
247
260
|
## Board profiles
|
|
248
261
|
|
|
249
|
-
One yaml per board (`boards/`): build command, artifact, flash address
|
|
250
|
-
|
|
262
|
+
One yaml per board (`boards/`): build command, artifact, flash address
|
|
263
|
+
(`flash.adapter:` picks the debug backend), serial adapter hints,
|
|
264
|
+
banner template, probes, watch globs. The console-side
|
|
251
265
|
USB adapter is a property of your bench, not the board — port resolution
|
|
252
266
|
goes explicit `serial.port` / `FLASHGATE_SERIAL_PORT`, then VID/PID hint,
|
|
253
267
|
then the sole serial port, with the banner match as the final identity
|
|
@@ -258,10 +272,10 @@ full profile field reference.
|
|
|
258
272
|
## Status
|
|
259
273
|
|
|
260
274
|
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
|
|
261
|
-
verification records (per-check evidence with artifact hashes),
|
|
262
|
-
remote bench over device-connect
|
|
263
|
-
|
|
264
|
-
|
|
275
|
+
verification records (per-check evidence with artifact hashes), the
|
|
276
|
+
remote bench over device-connect (validated cross-host), and a second
|
|
277
|
+
debug backend (OpenOCD, validated on the same board) — all on real
|
|
278
|
+
hardware. Windows-first; Linux/macOS untested.
|
|
265
279
|
|
|
266
280
|
## License
|
|
267
281
|
|
|
@@ -223,10 +223,24 @@ Stop-hook escalation — plus the full session of a real Claude Code agent
|
|
|
223
223
|
getting blocked, diagnosing the firmware↔profile contract, fixing both
|
|
224
224
|
sides, and passing on hardware ([24 MB GIF, release asset](https://github.com/Lion-1209/flashgate/releases/download/v0.3.0/5-agent-blocked.gif)).
|
|
225
225
|
|
|
226
|
+
## Debug backends (Phase 2)
|
|
227
|
+
|
|
228
|
+
The verify pipeline is backend-agnostic: `flash.adapter:` in the board
|
|
229
|
+
profile picks the probe tool — `cubeprogrammer` (default, the original
|
|
230
|
+
implementation), `openocd` (same ST-Link, no ST toolchain needed, and
|
|
231
|
+
the standard route to Linux/ARM64 bench hosts like a Raspberry Pi), or
|
|
232
|
+
`fake` (scripted, for tests). Validated on the same Apollo board:
|
|
233
|
+
swapping to `openocd` is a one-line profile change — verify stays green
|
|
234
|
+
with identical records, zero upper-layer edits, and runs slightly
|
|
235
|
+
faster. OpenOCD is discovered via PATH or `$OPENOCD_BIN`; the target
|
|
236
|
+
script maps from the MCU family (`STM32H7*` -> `stm32h7x`, override
|
|
237
|
+
with `flash.openocd_target`).
|
|
238
|
+
|
|
226
239
|
## Board profiles
|
|
227
240
|
|
|
228
|
-
One yaml per board (`boards/`): build command, artifact, flash address
|
|
229
|
-
|
|
241
|
+
One yaml per board (`boards/`): build command, artifact, flash address
|
|
242
|
+
(`flash.adapter:` picks the debug backend), serial adapter hints,
|
|
243
|
+
banner template, probes, watch globs. The console-side
|
|
230
244
|
USB adapter is a property of your bench, not the board — port resolution
|
|
231
245
|
goes explicit `serial.port` / `FLASHGATE_SERIAL_PORT`, then VID/PID hint,
|
|
232
246
|
then the sole serial port, with the banner match as the final identity
|
|
@@ -237,10 +251,10 @@ full profile field reference.
|
|
|
237
251
|
## Status
|
|
238
252
|
|
|
239
253
|
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
|
|
240
|
-
verification records (per-check evidence with artifact hashes),
|
|
241
|
-
remote bench over device-connect
|
|
242
|
-
|
|
243
|
-
|
|
254
|
+
verification records (per-check evidence with artifact hashes), the
|
|
255
|
+
remote bench over device-connect (validated cross-host), and a second
|
|
256
|
+
debug backend (OpenOCD, validated on the same board) — all on real
|
|
257
|
+
hardware. Windows-first; Linux/macOS untested.
|
|
244
258
|
|
|
245
259
|
## License
|
|
246
260
|
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Debug backends: the adapter layer between the verify pipeline and the
|
|
2
|
+
physical debug probe (design doc §5.6, Phase 2).
|
|
3
|
+
|
|
4
|
+
A backend owns EVERYTHING probe-specific: discovery, flashing, memory
|
|
5
|
+
access. The verify pipeline — and everything above it: records, bench,
|
|
6
|
+
MCP — is backend-agnostic. Swapping CubeProgrammer for OpenOCD is a
|
|
7
|
+
one-line board-profile change (`flash.adapter:`) and zero upper-layer
|
|
8
|
+
edits; that substitution property is the Phase 2 acceptance criterion.
|
|
9
|
+
|
|
10
|
+
Semantics every backend must honor (they are the gate's, not the
|
|
11
|
+
adapter's):
|
|
12
|
+
- flash(start=False) writes and verifies but does NOT run: the caller
|
|
13
|
+
wipes the boot signature in between, then start_app() — so a stale
|
|
14
|
+
RAM signature from a previous boot can never lie about identity.
|
|
15
|
+
- write32/read_mem are the wipe/poll primitives behind that contract.
|
|
16
|
+
- The `connect` string is opaque and backend-specific (CubeProgrammer
|
|
17
|
+
syntax like "port=SWD"; ignored by OpenOCD, which targets via its own
|
|
18
|
+
config derived from the board's MCU).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import flasher, swdsig
|
|
30
|
+
from .sttools import augmented_env, find_cubeprogrammer
|
|
31
|
+
|
|
32
|
+
# OpenOCD target scripts by MCU family prefix. Explicit override lives in
|
|
33
|
+
# the board profile (`flash.openocd_target`) for anything unmapped.
|
|
34
|
+
_OPENOCD_TARGETS: tuple[tuple[str, str], ...] = (
|
|
35
|
+
("STM32H7", "stm32h7x"),
|
|
36
|
+
("STM32F7", "stm32f7x"),
|
|
37
|
+
("STM32F4", "stm32f4x"),
|
|
38
|
+
("STM32F1", "stm32f1x"),
|
|
39
|
+
("STM32L4", "stm32l4x"),
|
|
40
|
+
("STM32G0", "stm32g0x"),
|
|
41
|
+
("STM32G4", "stm32g4x"),
|
|
42
|
+
("STM32U5", "stm32u5x"),
|
|
43
|
+
("STM32WB", "stm32wbx"),
|
|
44
|
+
("STM32WL", "stm32wlx"),
|
|
45
|
+
)
|
|
46
|
+
_OPENOCD_TIMEOUT_S = 60
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def openocd_target_for(mcu: str) -> str | None:
|
|
50
|
+
mcu = (mcu or "").upper()
|
|
51
|
+
for prefix, target in _OPENOCD_TARGETS:
|
|
52
|
+
if mcu.startswith(prefix):
|
|
53
|
+
return target
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_openocd() -> Path | None:
|
|
58
|
+
"""openocd.exe via PATH, $OPENOCD_BIN, or the xpack layout."""
|
|
59
|
+
exe = "openocd.exe" if shutil.os.name == "nt" else "openocd"
|
|
60
|
+
found = shutil.which(exe, path=augmented_env().get("PATH", ""))
|
|
61
|
+
if found:
|
|
62
|
+
return Path(found)
|
|
63
|
+
import os
|
|
64
|
+
for env_name in ("OPENOCD_BIN", "OPENOCD_HOME"):
|
|
65
|
+
base = os.environ.get(env_name)
|
|
66
|
+
if base:
|
|
67
|
+
cand = Path(base)
|
|
68
|
+
if cand.is_file():
|
|
69
|
+
return cand
|
|
70
|
+
hit = cand / "bin" / exe
|
|
71
|
+
if hit.is_file():
|
|
72
|
+
return hit
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DebugBackend(ABC):
|
|
77
|
+
"""The probe-facing surface the verify pipeline depends on."""
|
|
78
|
+
|
|
79
|
+
name: str = "abstract"
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def available(self) -> str | None:
|
|
83
|
+
"""Usable executable path (doctor shows it), or None."""
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def discover(self) -> str:
|
|
87
|
+
"""Human-readable listing of attached probes (doctor)."""
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def flash(self, bin_path: Path, connect: str, address: str,
|
|
91
|
+
start: bool = True) -> flasher.FlashResult: ...
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def write32(self, connect: str, value: int, address: int) -> bool: ...
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def start_app(self, connect: str) -> bool: ...
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def read_mem(self, connect: str, address: int, size: int) -> bytes | None: ...
|
|
101
|
+
|
|
102
|
+
def probe_detected(self, discover_output: str) -> bool:
|
|
103
|
+
"""Did discover() actually see a probe? Backend-specific markers —
|
|
104
|
+
each backend knows its own tool's output format."""
|
|
105
|
+
return bool(discover_output.strip())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CubeProgrammerBackend(DebugBackend):
|
|
109
|
+
"""STM32CubeProgrammer CLI over ST-Link — the original implementation,
|
|
110
|
+
unchanged semantics, now behind the interface."""
|
|
111
|
+
|
|
112
|
+
name = "cubeprogrammer"
|
|
113
|
+
|
|
114
|
+
def available(self) -> str | None:
|
|
115
|
+
cli = find_cubeprogrammer()
|
|
116
|
+
return str(cli) if cli else None
|
|
117
|
+
|
|
118
|
+
def discover(self) -> str:
|
|
119
|
+
return flasher.list_stlink()
|
|
120
|
+
|
|
121
|
+
def flash(self, bin_path, connect, address, start=True):
|
|
122
|
+
return flasher.flash(bin_path, connect, address, start=start)
|
|
123
|
+
|
|
124
|
+
def write32(self, connect, value, address) -> bool:
|
|
125
|
+
return flasher.write32(connect, value, address)
|
|
126
|
+
|
|
127
|
+
def start_app(self, connect) -> bool:
|
|
128
|
+
return flasher.start_app(connect)
|
|
129
|
+
|
|
130
|
+
def read_mem(self, connect, address, size):
|
|
131
|
+
return swdsig.read_ram(connect, address, size)
|
|
132
|
+
|
|
133
|
+
def probe_detected(self, discover_output: str) -> bool:
|
|
134
|
+
return "ST-LINK SN" in discover_output
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class OpenOcdBackend(DebugBackend):
|
|
138
|
+
"""OpenOCD over ST-Link (or any adapter OpenOCD speaks). The second
|
|
139
|
+
flash implementation that validates the abstraction — and the
|
|
140
|
+
standard route to Linux/ARM64 bench hosts (e.g. Raspberry Pi), where
|
|
141
|
+
CubeProgrammer is the awkward dependency.
|
|
142
|
+
|
|
143
|
+
`connect` is ignored (it carries CubeProgrammer syntax); targeting
|
|
144
|
+
comes from the board's MCU mapped to an OpenOCD target script, or an
|
|
145
|
+
explicit `flash.openocd_target` in the profile. Interface defaults
|
|
146
|
+
to ST-Link; override with `flash.openocd_interface`.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
name = "openocd"
|
|
150
|
+
|
|
151
|
+
def __init__(self, mcu: str = "",
|
|
152
|
+
interface: str = "interface/stlink-dap.cfg",
|
|
153
|
+
target: str | None = None):
|
|
154
|
+
self._interface = interface or "interface/stlink-dap.cfg"
|
|
155
|
+
self._target = target or openocd_target_for(mcu)
|
|
156
|
+
|
|
157
|
+
def available(self) -> str | None:
|
|
158
|
+
exe = find_openocd()
|
|
159
|
+
return str(exe) if exe else None
|
|
160
|
+
|
|
161
|
+
def _run(self, commands: list[str]) -> tuple[int, str]:
|
|
162
|
+
exe = find_openocd()
|
|
163
|
+
if self._target is None:
|
|
164
|
+
return -1, ("no OpenOCD target mapping for this MCU — set "
|
|
165
|
+
"flash.openocd_target in the board profile")
|
|
166
|
+
if exe is None:
|
|
167
|
+
return -1, "openocd not found (install it or set OPENOCD_BIN)"
|
|
168
|
+
cmd = [str(exe)]
|
|
169
|
+
# xpack layout keeps scripts outside bin/ — pass the search dir
|
|
170
|
+
# explicitly so `-f target/...` resolves regardless of build.
|
|
171
|
+
scripts = exe.parent.parent / "openocd" / "scripts"
|
|
172
|
+
if scripts.is_dir():
|
|
173
|
+
cmd += ["-s", str(scripts)]
|
|
174
|
+
# target names map into the target/ script dir; profile overrides
|
|
175
|
+
# may carry a path or extension already
|
|
176
|
+
t = self._target
|
|
177
|
+
target_cfg = t if ("/" in t or t.endswith(".cfg")) else f"target/{t}.cfg"
|
|
178
|
+
i = self._interface
|
|
179
|
+
interface_cfg = i if ("/" in i or i.endswith(".cfg")) else f"interface/{i}.cfg"
|
|
180
|
+
cmd += ["-c", "adapter speed 4000",
|
|
181
|
+
"-f", interface_cfg, "-f", target_cfg,
|
|
182
|
+
# mdw/mww/reset need an initialized session; `program`
|
|
183
|
+
# inits internally and tolerates the explicit one.
|
|
184
|
+
"-c", "init"]
|
|
185
|
+
for c in commands:
|
|
186
|
+
cmd += ["-c", c]
|
|
187
|
+
cmd += ["-c", "shutdown"]
|
|
188
|
+
try:
|
|
189
|
+
proc = subprocess.run(
|
|
190
|
+
cmd, capture_output=True, text=True, timeout=_OPENOCD_TIMEOUT_S,
|
|
191
|
+
encoding="utf-8", errors="replace",
|
|
192
|
+
# Neutral cwd (F3): OpenOCD resolves relative -f paths
|
|
193
|
+
# against ITS cwd first — running from the gated repo
|
|
194
|
+
# would let a checked-in target/*.cfg shadow the stock
|
|
195
|
+
# scripts.
|
|
196
|
+
cwd=str(exe.parent),
|
|
197
|
+
)
|
|
198
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
199
|
+
return -1, str(exc)
|
|
200
|
+
return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
|
|
201
|
+
|
|
202
|
+
def discover(self) -> str:
|
|
203
|
+
rc, out = self._run(["adapter list"])
|
|
204
|
+
if rc == 0:
|
|
205
|
+
return out.strip()
|
|
206
|
+
return f"(openocd probe listing unavailable: {out[-300:]})"
|
|
207
|
+
|
|
208
|
+
def probe_detected(self, discover_output: str) -> bool:
|
|
209
|
+
# a connected ST-Link shows up as VID:PID 0483:... plus a DPIDR
|
|
210
|
+
# line once init reaches the target
|
|
211
|
+
return ("VID:PID 0483:" in discover_output
|
|
212
|
+
or "DPIDR" in discover_output)
|
|
213
|
+
|
|
214
|
+
def flash(self, bin_path: Path, connect: str, address: str,
|
|
215
|
+
start: bool = True) -> flasher.FlashResult:
|
|
216
|
+
if not Path(bin_path).is_file():
|
|
217
|
+
return flasher.FlashResult(False, f"artifact not found: {bin_path}")
|
|
218
|
+
# Tcl eats backslashes as escapes — hand OpenOCD forward slashes,
|
|
219
|
+
# braced so nothing else gets substituted. Braces have NO escape
|
|
220
|
+
# mechanism: a path containing '{' or '}' cannot be passed safely
|
|
221
|
+
# and is refused outright (F2).
|
|
222
|
+
tcl_bin = str(Path(bin_path)).replace("\\", "/")
|
|
223
|
+
if "{" in tcl_bin or "}" in tcl_bin:
|
|
224
|
+
return flasher.FlashResult(
|
|
225
|
+
False, f"artifact path contains '{{' or '}}' — cannot be "
|
|
226
|
+
f"passed to OpenOCD safely: {tcl_bin}")
|
|
227
|
+
steps = [f"program {{{tcl_bin}}} {address} verify"]
|
|
228
|
+
if start:
|
|
229
|
+
steps.append("reset run")
|
|
230
|
+
rc, out = self._run(steps)
|
|
231
|
+
# OpenOCD prints '** Programming Finished **' on success
|
|
232
|
+
ok = rc == 0 and "** Programming Finished **" in out
|
|
233
|
+
return flasher.FlashResult(ok, out.strip()[-1200:])
|
|
234
|
+
|
|
235
|
+
def write32(self, connect: str, value: int, address: int) -> bool:
|
|
236
|
+
rc, out = self._run([f"mww 0x{address:08x} 0x{value:08x}"])
|
|
237
|
+
return rc == 0
|
|
238
|
+
|
|
239
|
+
def start_app(self, connect: str) -> bool:
|
|
240
|
+
rc, out = self._run(["reset run"])
|
|
241
|
+
return rc == 0
|
|
242
|
+
|
|
243
|
+
def read_mem(self, connect: str, address: int, size: int) -> bytes | None:
|
|
244
|
+
"""mdw-based read; returns None on any failure (poll semantics)."""
|
|
245
|
+
words = (size + 3) // 4
|
|
246
|
+
rc, out = self._run([f"mdw 0x{address:08x} {words}"])
|
|
247
|
+
if rc != 0:
|
|
248
|
+
return None
|
|
249
|
+
got: list[int] = []
|
|
250
|
+
# OpenOCD mdw format: "0x2001ff00: f1a5c0de 00010001 ..." —
|
|
251
|
+
# the ADDRESS carries 0x, the words do not.
|
|
252
|
+
for m in re.finditer(r"0x[0-9a-fA-F]+:\s+((?:[0-9a-fA-F]{8}\s*)+)",
|
|
253
|
+
out):
|
|
254
|
+
for w in re.finditer(r"[0-9a-fA-F]{8}", m.group(1)):
|
|
255
|
+
got.append(int(w.group(0), 16))
|
|
256
|
+
if len(got) < words:
|
|
257
|
+
return None
|
|
258
|
+
blob = b"".join(w.to_bytes(4, "little") for w in got[:words])
|
|
259
|
+
return blob[:size]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class FakeBackend(DebugBackend):
|
|
263
|
+
"""CI adapter: no probe, no hardware — scripts decide what happens
|
|
264
|
+
(design doc §14: build fail / flash timeout / stale identity / probe
|
|
265
|
+
assertion fail, all in-process)."""
|
|
266
|
+
|
|
267
|
+
name = "fake"
|
|
268
|
+
|
|
269
|
+
def __init__(self, *, flash_ok: bool = True, signature: bytes | None = None):
|
|
270
|
+
self.flash_ok = flash_ok
|
|
271
|
+
self.signature = signature
|
|
272
|
+
self.calls: list[str] = []
|
|
273
|
+
|
|
274
|
+
def available(self) -> str | None:
|
|
275
|
+
return "fake"
|
|
276
|
+
|
|
277
|
+
def discover(self) -> str:
|
|
278
|
+
return "(fake backend)"
|
|
279
|
+
|
|
280
|
+
def flash(self, bin_path, connect, address, start=True):
|
|
281
|
+
self.calls.append(f"flash:{bin_path}@{address}:start={start}")
|
|
282
|
+
return flasher.FlashResult(self.flash_ok, "fake flash")
|
|
283
|
+
|
|
284
|
+
def write32(self, connect, value, address):
|
|
285
|
+
self.calls.append(f"write32:{address:#x}={value:#x}")
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
def start_app(self, connect):
|
|
289
|
+
self.calls.append("start_app")
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
def read_mem(self, connect, address, size):
|
|
293
|
+
self.calls.append(f"read:{address:#x}+{size}")
|
|
294
|
+
return self.signature[:size] if self.signature else None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
_BACKENDS: dict[str, type] = {
|
|
298
|
+
CubeProgrammerBackend.name: CubeProgrammerBackend,
|
|
299
|
+
OpenOcdBackend.name: OpenOcdBackend,
|
|
300
|
+
FakeBackend.name: FakeBackend,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def known_backends() -> list[str]:
|
|
305
|
+
return sorted(_BACKENDS)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def get_backend(name: str, **kwargs) -> DebugBackend:
|
|
309
|
+
try:
|
|
310
|
+
return _BACKENDS[name](**kwargs)
|
|
311
|
+
except KeyError:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"unknown debug backend {name!r}; known: {known_backends()}") from None
|
|
@@ -28,6 +28,9 @@ class Board:
|
|
|
28
28
|
artifact: Path
|
|
29
29
|
flash_connect: str
|
|
30
30
|
flash_address: str
|
|
31
|
+
flash_adapter: str
|
|
32
|
+
openocd_target: str
|
|
33
|
+
openocd_interface: str
|
|
31
34
|
serial_port: str
|
|
32
35
|
usb_vid: int
|
|
33
36
|
usb_pids: tuple[int, ...]
|
|
@@ -81,6 +84,35 @@ def load_board(yaml_path: Path) -> Board:
|
|
|
81
84
|
|
|
82
85
|
fw = raw.get("firmware") or {}
|
|
83
86
|
flash = raw.get("flash") or {}
|
|
87
|
+
import re as _re
|
|
88
|
+
flash_adapter = str(flash.get("adapter", "cubeprogrammer")).lower()
|
|
89
|
+
if flash_adapter not in ("cubeprogrammer", "openocd", "fake"):
|
|
90
|
+
raise BoardError(
|
|
91
|
+
f"board profile {yaml_path.name}: flash.adapter must be one of "
|
|
92
|
+
f"cubeprogrammer|openocd|fake, got {flash_adapter!r}")
|
|
93
|
+
# Tcl-injection surface (adversarial review F1/F2): flash.address is a
|
|
94
|
+
# VALUE inside an OpenOCD command line where ';' separates commands
|
|
95
|
+
# and '}' escapes braces; target/interface strings reach -f scripts.
|
|
96
|
+
# These are enumerations/paths, not free text — whitelist them.
|
|
97
|
+
address = str(flash.get("address", "0x08000000"))
|
|
98
|
+
if not _re.fullmatch(r"0x[0-9a-fA-F]{1,10}", address):
|
|
99
|
+
raise BoardError(
|
|
100
|
+
f"board profile {yaml_path.name}: flash.address must be "
|
|
101
|
+
f"0x-prefixed hex (got {address!r})")
|
|
102
|
+
_SAFE_CFG = _re.compile(r"[A-Za-z0-9_./+-]+")
|
|
103
|
+
for key in ("openocd_target", "openocd_interface"):
|
|
104
|
+
value = str(flash.get(key, ""))
|
|
105
|
+
if value and not _SAFE_CFG.fullmatch(value):
|
|
106
|
+
raise BoardError(
|
|
107
|
+
f"board profile {yaml_path.name}: flash.{key} may only "
|
|
108
|
+
f"contain [A-Za-z0-9_./+-] (got {value!r})")
|
|
109
|
+
if (flash_adapter == "fake"
|
|
110
|
+
and os.environ.get("FLASHGATE_ALLOW_FAKE") != "1"):
|
|
111
|
+
raise BoardError(
|
|
112
|
+
f"board profile {yaml_path.name}: flash.adapter 'fake' is a "
|
|
113
|
+
"test adapter — set FLASHGATE_ALLOW_FAKE=1 to use it "
|
|
114
|
+
"(a fake flash never writes hardware and can green-light a "
|
|
115
|
+
"board that was never programmed)")
|
|
84
116
|
ser = raw.get("serial") or {}
|
|
85
117
|
ev = raw.get("evidence") or {}
|
|
86
118
|
evidence_mode = str(ev.get("mode", "auto")).lower()
|
|
@@ -116,6 +148,9 @@ def load_board(yaml_path: Path) -> Board:
|
|
|
116
148
|
build_command=fw["build"],
|
|
117
149
|
artifact=(base / fw["dir"] / fw["artifact"]).resolve(),
|
|
118
150
|
flash_connect=flash.get("connect", "port=SWD"),
|
|
151
|
+
flash_adapter=flash_adapter,
|
|
152
|
+
openocd_target=str(flash.get("openocd_target", "")),
|
|
153
|
+
openocd_interface=str(flash.get("openocd_interface", "")),
|
|
119
154
|
flash_address=str(flash.get("address", "0x08000000")),
|
|
120
155
|
serial_port=str(ser.get("port", "") or ""),
|
|
121
156
|
usb_vid=int(str(ser.get("vid", "0x1A86")), 0),
|
|
@@ -26,8 +26,8 @@ import yaml
|
|
|
26
26
|
|
|
27
27
|
from . import __version__
|
|
28
28
|
from .board import Board, BoardError, default_board_path, load_board
|
|
29
|
-
from . import flasher, gatestate, probes as probe_mod, records, serialmon, swdsig
|
|
30
|
-
from .sttools import augmented_env
|
|
29
|
+
from . import backends, flasher, gatestate, probes as probe_mod, records, serialmon, swdsig
|
|
30
|
+
from .sttools import augmented_env
|
|
31
31
|
|
|
32
32
|
EXIT_OK = 0
|
|
33
33
|
EXIT_BUILD = 1
|
|
@@ -94,6 +94,16 @@ def _resolve_board(args: argparse.Namespace) -> Board:
|
|
|
94
94
|
return load_board(Path(path))
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
def _board_backend(board: Board) -> backends.DebugBackend:
|
|
98
|
+
"""The debug backend a board profile selects (Phase 2)."""
|
|
99
|
+
if board.flash_adapter == "openocd":
|
|
100
|
+
return backends.OpenOcdBackend(
|
|
101
|
+
mcu=board.mcu,
|
|
102
|
+
target=board.openocd_target or None,
|
|
103
|
+
interface=board.openocd_interface or None)
|
|
104
|
+
return backends.get_backend(board.flash_adapter)
|
|
105
|
+
|
|
106
|
+
|
|
97
107
|
def _run(cmd: str, cwd: Path) -> tuple[int, str]:
|
|
98
108
|
proc = subprocess.run(
|
|
99
109
|
cmd, shell=True, cwd=cwd, capture_output=True, text=True,
|
|
@@ -109,21 +119,21 @@ def cmd_doctor(board: Board) -> int:
|
|
|
109
119
|
print(f" artifact : {board.artifact}")
|
|
110
120
|
problems: list[str] = []
|
|
111
121
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
122
|
+
backend = _board_backend(board)
|
|
123
|
+
exe = backend.available()
|
|
124
|
+
if exe:
|
|
125
|
+
print(_green(f" backend : {backend.name} ({exe})"))
|
|
115
126
|
else:
|
|
116
|
-
problems.append("
|
|
117
|
-
print(_red("
|
|
118
|
-
|
|
119
|
-
if
|
|
120
|
-
listing =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
print(_green(f" ST-Link : {sn_lines[0]}"))
|
|
127
|
+
problems.append(f"backend {backend.name!r} executable not found")
|
|
128
|
+
print(_red(f" backend : {backend.name} — EXECUTABLE NOT FOUND"))
|
|
129
|
+
|
|
130
|
+
if exe:
|
|
131
|
+
listing = backend.discover()
|
|
132
|
+
if backend.probe_detected(listing):
|
|
133
|
+
print(_green(" probe : detected"))
|
|
124
134
|
else:
|
|
125
|
-
problems.append("no
|
|
126
|
-
print(_red("
|
|
135
|
+
problems.append("no debug probe detected (check USB, power, driver)")
|
|
136
|
+
print(_red(" probe : none detected"))
|
|
127
137
|
|
|
128
138
|
port, why = serialmon.resolve_console_port(board.serial_port, board.usb_vid, board.usb_pids)
|
|
129
139
|
if port:
|
|
@@ -147,7 +157,8 @@ def cmd_doctor(board: Board) -> int:
|
|
|
147
157
|
# Live SWD signature: what the board is running RIGHT NOW, no serial needed
|
|
148
158
|
try:
|
|
149
159
|
info, _ = swdsig.wait_for_signature(
|
|
150
|
-
board.flash_connect, board.sig_address, board.sig_size, timeout_s=2.0
|
|
160
|
+
board.flash_connect, board.sig_address, board.sig_size, timeout_s=2.0,
|
|
161
|
+
read_fn=backend.read_mem)
|
|
151
162
|
if info:
|
|
152
163
|
print(_green(f" on-board : git={info['git']} build={info['build']} (SWD signature)"))
|
|
153
164
|
else:
|
|
@@ -219,8 +230,10 @@ def cmd_build(board: Board) -> int:
|
|
|
219
230
|
|
|
220
231
|
|
|
221
232
|
def cmd_flash(board: Board) -> int:
|
|
222
|
-
|
|
223
|
-
|
|
233
|
+
backend = _board_backend(board)
|
|
234
|
+
print(_cyan(f"[flash] {board.artifact.name} @ {board.flash_address} via "
|
|
235
|
+
f"{backend.name}:{board.flash_connect}"))
|
|
236
|
+
result = backend.flash(board.artifact, board.flash_connect, board.flash_address)
|
|
224
237
|
if not result.ok:
|
|
225
238
|
print(_red("[flash] FAILED"))
|
|
226
239
|
print(result.detail[-1200:])
|
|
@@ -390,7 +403,8 @@ def _verify_swd(board: Board, probe_names: list[str] | None,
|
|
|
390
403
|
|
|
391
404
|
# Flash WITHOUT starting, wipe the stale signature, then start: RAM is
|
|
392
405
|
# not cleared by reset, so a surviving old-boot signature would lie.
|
|
393
|
-
|
|
406
|
+
backend = _board_backend(board)
|
|
407
|
+
result = backend.flash(board.artifact, board.flash_connect, board.flash_address,
|
|
394
408
|
start=False)
|
|
395
409
|
if not result.ok:
|
|
396
410
|
print(_red("[flash] FAILED"))
|
|
@@ -398,11 +412,11 @@ def _verify_swd(board: Board, probe_names: list[str] | None,
|
|
|
398
412
|
j.check("flash", "failed", result.detail[-300:].strip())
|
|
399
413
|
return EXIT_FLASH
|
|
400
414
|
wipe_note = ""
|
|
401
|
-
if not
|
|
415
|
+
if not backend.write32(board.flash_connect, 0, board.sig_address):
|
|
402
416
|
wipe_note = "; WARNING: stale signature could not be wiped"
|
|
403
417
|
print(_yellow("[verify] warning: could not wipe the old signature "
|
|
404
418
|
"(stale-identity false-pass window)"))
|
|
405
|
-
if not
|
|
419
|
+
if not backend.start_app(board.flash_connect):
|
|
406
420
|
print(_red("[flash] FAILED to start the application"))
|
|
407
421
|
j.check("flash", "failed", "start_app failed")
|
|
408
422
|
return EXIT_FLASH
|
|
@@ -412,7 +426,7 @@ def _verify_swd(board: Board, probe_names: list[str] | None,
|
|
|
412
426
|
print(_cyan(f"[verify] polling signature @ {board.sig_address:#010x} via {board.flash_connect}"))
|
|
413
427
|
info, err = swdsig.wait_for_signature(
|
|
414
428
|
board.flash_connect, board.sig_address, board.sig_size,
|
|
415
|
-
timeout_s=board.banner_timeout_s)
|
|
429
|
+
timeout_s=board.banner_timeout_s, read_fn=backend.read_mem)
|
|
416
430
|
if info is None:
|
|
417
431
|
if "not supported" in err:
|
|
418
432
|
print(_red(f"[verify] SIGNATURE LAYOUT MISMATCH: {err}"))
|
|
@@ -81,7 +81,8 @@ def parse_signature(buf: bytes) -> dict | None:
|
|
|
81
81
|
|
|
82
82
|
|
|
83
83
|
def wait_for_signature(
|
|
84
|
-
connect: str, address: int, size: int = 64, timeout_s: float = 15.0
|
|
84
|
+
connect: str, address: int, size: int = 64, timeout_s: float = 15.0,
|
|
85
|
+
read_fn=None,
|
|
85
86
|
) -> tuple[dict | None, str]:
|
|
86
87
|
"""Poll the signature until valid or timeout. Returns (info, last_error).
|
|
87
88
|
|
|
@@ -92,7 +93,7 @@ def wait_for_signature(
|
|
|
92
93
|
last = "no valid signature yet"
|
|
93
94
|
while time.monotonic() < deadline:
|
|
94
95
|
try:
|
|
95
|
-
buf = read_ram(connect, address, size)
|
|
96
|
+
buf = (read_fn or read_ram)(connect, address, size)
|
|
96
97
|
except SwdError as exc:
|
|
97
98
|
last = str(exc)
|
|
98
99
|
time.sleep(1.0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashgate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Lion-1209/flashgate
|
|
@@ -244,10 +244,24 @@ Stop-hook escalation — plus the full session of a real Claude Code agent
|
|
|
244
244
|
getting blocked, diagnosing the firmware↔profile contract, fixing both
|
|
245
245
|
sides, and passing on hardware ([24 MB GIF, release asset](https://github.com/Lion-1209/flashgate/releases/download/v0.3.0/5-agent-blocked.gif)).
|
|
246
246
|
|
|
247
|
+
## Debug backends (Phase 2)
|
|
248
|
+
|
|
249
|
+
The verify pipeline is backend-agnostic: `flash.adapter:` in the board
|
|
250
|
+
profile picks the probe tool — `cubeprogrammer` (default, the original
|
|
251
|
+
implementation), `openocd` (same ST-Link, no ST toolchain needed, and
|
|
252
|
+
the standard route to Linux/ARM64 bench hosts like a Raspberry Pi), or
|
|
253
|
+
`fake` (scripted, for tests). Validated on the same Apollo board:
|
|
254
|
+
swapping to `openocd` is a one-line profile change — verify stays green
|
|
255
|
+
with identical records, zero upper-layer edits, and runs slightly
|
|
256
|
+
faster. OpenOCD is discovered via PATH or `$OPENOCD_BIN`; the target
|
|
257
|
+
script maps from the MCU family (`STM32H7*` -> `stm32h7x`, override
|
|
258
|
+
with `flash.openocd_target`).
|
|
259
|
+
|
|
247
260
|
## Board profiles
|
|
248
261
|
|
|
249
|
-
One yaml per board (`boards/`): build command, artifact, flash address
|
|
250
|
-
|
|
262
|
+
One yaml per board (`boards/`): build command, artifact, flash address
|
|
263
|
+
(`flash.adapter:` picks the debug backend), serial adapter hints,
|
|
264
|
+
banner template, probes, watch globs. The console-side
|
|
251
265
|
USB adapter is a property of your bench, not the board — port resolution
|
|
252
266
|
goes explicit `serial.port` / `FLASHGATE_SERIAL_PORT`, then VID/PID hint,
|
|
253
267
|
then the sole serial port, with the banner match as the final identity
|
|
@@ -258,10 +272,10 @@ full profile field reference.
|
|
|
258
272
|
## Status
|
|
259
273
|
|
|
260
274
|
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
|
|
261
|
-
verification records (per-check evidence with artifact hashes),
|
|
262
|
-
remote bench over device-connect
|
|
263
|
-
|
|
264
|
-
|
|
275
|
+
verification records (per-check evidence with artifact hashes), the
|
|
276
|
+
remote bench over device-connect (validated cross-host), and a second
|
|
277
|
+
debug backend (OpenOCD, validated on the same board) — all on real
|
|
278
|
+
hardware. Windows-first; Linux/macOS untested.
|
|
265
279
|
|
|
266
280
|
## License
|
|
267
281
|
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
flashgate/__init__.py
|
|
5
5
|
flashgate/__main__.py
|
|
6
|
+
flashgate/backends.py
|
|
6
7
|
flashgate/bench.py
|
|
7
8
|
flashgate/bench_serve.py
|
|
8
9
|
flashgate/board.py
|
|
@@ -22,6 +23,7 @@ flashgate.egg-info/dependency_links.txt
|
|
|
22
23
|
flashgate.egg-info/entry_points.txt
|
|
23
24
|
flashgate.egg-info/requires.txt
|
|
24
25
|
flashgate.egg-info/top_level.txt
|
|
26
|
+
tests/test_backends.py
|
|
25
27
|
tests/test_bench.py
|
|
26
28
|
tests/test_bench_serve.py
|
|
27
29
|
tests/test_board.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flashgate"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Debug-backend adapters (Phase 2): registry, targeting, memory-parse,
|
|
2
|
+
profile selection, and the read_fn injection that decouples swdsig from
|
|
3
|
+
CubeProgrammer."""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from flashgate import backends
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestRegistry:
|
|
11
|
+
def test_known_and_default(self):
|
|
12
|
+
assert backends.known_backends() == ["cubeprogrammer", "fake", "openocd"]
|
|
13
|
+
assert backends.get_backend("cubeprogrammer").name == "cubeprogrammer"
|
|
14
|
+
|
|
15
|
+
def test_unknown_rejected(self):
|
|
16
|
+
with pytest.raises(ValueError, match="unknown debug backend"):
|
|
17
|
+
backends.get_backend("jlink-someday")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestTargetMapping:
|
|
21
|
+
def test_mcu_prefix_map(self):
|
|
22
|
+
assert backends.openocd_target_for("STM32H743IIT6") == "stm32h7x"
|
|
23
|
+
assert backends.openocd_target_for("stm32f407") == "stm32f4x"
|
|
24
|
+
assert backends.openocd_target_for("LPC55S69") is None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestReadMemParsing:
|
|
28
|
+
def test_mdw_words_without_0x_prefix(self, monkeypatch):
|
|
29
|
+
b = backends.OpenOcdBackend(mcu="STM32H743IIT6")
|
|
30
|
+
out = ("Info : blah\n"
|
|
31
|
+
"0x2001ff00: f1a5c0de 01000100 37323463 \n"
|
|
32
|
+
"0x2001ff10: 2d656337 \n"
|
|
33
|
+
"shutdown command invoked\n")
|
|
34
|
+
monkeypatch.setattr(b, "_run", lambda cmds: (0, out))
|
|
35
|
+
blob = b.read_mem("x", 0x2001FF00, 8)
|
|
36
|
+
assert blob == bytes.fromhex("dec0a5f1" "00010001")
|
|
37
|
+
|
|
38
|
+
def test_failed_run_returns_none(self, monkeypatch):
|
|
39
|
+
b = backends.OpenOcdBackend(mcu="STM32H743IIT6")
|
|
40
|
+
monkeypatch.setattr(b, "_run", lambda cmds: (1, "boom"))
|
|
41
|
+
assert b.read_mem("x", 0, 64) is None
|
|
42
|
+
|
|
43
|
+
def test_unparsed_output_returns_none(self, monkeypatch):
|
|
44
|
+
b = backends.OpenOcdBackend(mcu="STM32H743IIT6")
|
|
45
|
+
monkeypatch.setattr(b, "_run", lambda cmds: (0, "no memory lines"))
|
|
46
|
+
assert b.read_mem("x", 0, 64) is None
|
|
47
|
+
|
|
48
|
+
def test_no_target_mapping_reports_clearly(self):
|
|
49
|
+
b = backends.OpenOcdBackend(mcu="LPC55S69")
|
|
50
|
+
rc, out = b._run(["init"])
|
|
51
|
+
assert rc == -1 and "openocd_target" in out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestSwdsigReadInjection:
|
|
55
|
+
def test_wait_uses_injected_reader(self):
|
|
56
|
+
from flashgate import swdsig
|
|
57
|
+
import struct
|
|
58
|
+
import zlib
|
|
59
|
+
calls = []
|
|
60
|
+
|
|
61
|
+
# valid v1 signature: magic+ver+flags(8) | git@0x08(16) |
|
|
62
|
+
# build@0x18(24) | crc32 of first 0x30 bytes @0x30
|
|
63
|
+
buf = bytearray(64)
|
|
64
|
+
struct.pack_into("<IHH", buf, 0, swdsig.SIG_MAGIC, 1, 0)
|
|
65
|
+
buf[0x08:0x0F] = b"c4277ce"
|
|
66
|
+
buf[0x18:0x2C] = b"2026-01-01T00:00:00Z"
|
|
67
|
+
struct.pack_into("<I", buf, 0x30,
|
|
68
|
+
zlib.crc32(bytes(buf[:0x30])) & 0xFFFFFFFF)
|
|
69
|
+
blob = bytes(buf)
|
|
70
|
+
|
|
71
|
+
def fake_read(connect, address, size):
|
|
72
|
+
calls.append((address, size))
|
|
73
|
+
return blob[:size]
|
|
74
|
+
|
|
75
|
+
info, err = swdsig.wait_for_signature(
|
|
76
|
+
"c", 0x2001FF00, 64, timeout_s=1.0, read_fn=fake_read)
|
|
77
|
+
assert calls, "injected reader must be used"
|
|
78
|
+
assert info is not None and info["version"] == 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestFakeBackend:
|
|
82
|
+
def test_scripts_the_pipeline(self):
|
|
83
|
+
b = backends.FakeBackend(flash_ok=False)
|
|
84
|
+
assert b.flash("x", "c", "0x08000000").ok is False
|
|
85
|
+
assert b.write32("c", 0, 0x2001FF00) is True
|
|
86
|
+
assert b.read_mem("c", 0, 64) is None
|
|
87
|
+
assert b.calls[0].startswith("flash:")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestFakeBackendPipeline:
|
|
91
|
+
"""The design doc §14 simulated-integration list, driven end-to-end
|
|
92
|
+
through cmd_verify with a scripted fake backend: flash failure,
|
|
93
|
+
signature timeout, stale identity, happy path — no probe attached."""
|
|
94
|
+
|
|
95
|
+
@pytest.fixture(autouse=True)
|
|
96
|
+
def _allow_fake(self, monkeypatch):
|
|
97
|
+
monkeypatch.setenv("FLASHGATE_ALLOW_FAKE", "1")
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _board(tmp_path, extra=""):
|
|
101
|
+
from flashgate.board import load_board
|
|
102
|
+
fw = tmp_path / "fw"
|
|
103
|
+
fw.mkdir(exist_ok=True)
|
|
104
|
+
p = tmp_path / "b.yaml"
|
|
105
|
+
p.write_text(
|
|
106
|
+
"board: t\nmcu: STM32H743\nfirmware:\n dir: fw\n"
|
|
107
|
+
" build: ninja\n artifact: fw.bin\n"
|
|
108
|
+
f"flash:\n adapter: fake\n{extra}"
|
|
109
|
+
"evidence:\n mode: swd\n"
|
|
110
|
+
"serial:\n banner: 'BOOT {git}'\n"
|
|
111
|
+
" banner_timeout_s: 0.3\n", encoding="utf-8")
|
|
112
|
+
(fw / "fw.bin").write_bytes(b"\x00" * 16)
|
|
113
|
+
return load_board(p)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _sig(git: bytes = b"aaaaaaa"):
|
|
117
|
+
import struct
|
|
118
|
+
import zlib
|
|
119
|
+
from flashgate import swdsig as sg
|
|
120
|
+
buf = bytearray(64)
|
|
121
|
+
struct.pack_into("<IHH", buf, 0, sg.SIG_MAGIC, 1, 0)
|
|
122
|
+
buf[0x08:0x08 + len(git)] = git
|
|
123
|
+
struct.pack_into("<I", buf, 0x30,
|
|
124
|
+
zlib.crc32(bytes(buf[:0x30])) & 0xFFFFFFFF)
|
|
125
|
+
return bytes(buf)
|
|
126
|
+
|
|
127
|
+
def _run(self, tmp_path, fake, monkeypatch, extra=""):
|
|
128
|
+
from flashgate import cli
|
|
129
|
+
board = self._board(tmp_path, extra)
|
|
130
|
+
monkeypatch.setattr(cli, "_build", lambda b, j=None: cli.EXIT_OK)
|
|
131
|
+
monkeypatch.setattr(cli, "_board_backend", lambda b: fake)
|
|
132
|
+
return cli.cmd_verify(board, None)
|
|
133
|
+
|
|
134
|
+
def test_flash_failure_exit_2(self, tmp_path, monkeypatch):
|
|
135
|
+
from flashgate import backends, cli
|
|
136
|
+
rc = self._run(tmp_path, backends.FakeBackend(flash_ok=False),
|
|
137
|
+
monkeypatch)
|
|
138
|
+
assert rc == cli.EXIT_FLASH
|
|
139
|
+
|
|
140
|
+
def test_signature_timeout_exit_3(self, tmp_path, monkeypatch):
|
|
141
|
+
from flashgate import backends, cli
|
|
142
|
+
rc = self._run(tmp_path, backends.FakeBackend(signature=None),
|
|
143
|
+
monkeypatch)
|
|
144
|
+
assert rc == cli.EXIT_BANNER_TIMEOUT
|
|
145
|
+
|
|
146
|
+
def test_stale_identity_exit_5(self, tmp_path, monkeypatch):
|
|
147
|
+
from flashgate import backends, cli
|
|
148
|
+
from flashgate.board import Board
|
|
149
|
+
fake = backends.FakeBackend(signature=self._sig(b"bbbbbbb"))
|
|
150
|
+
monkeypatch.setattr(Board, "head_sha",
|
|
151
|
+
lambda self: "aaaaaaa-dirty")
|
|
152
|
+
rc = self._run(tmp_path, fake, monkeypatch)
|
|
153
|
+
assert rc == cli.EXIT_SHA_MISMATCH
|
|
154
|
+
|
|
155
|
+
def test_happy_path_exit_0(self, tmp_path, monkeypatch):
|
|
156
|
+
from flashgate import backends, cli
|
|
157
|
+
from flashgate.board import Board
|
|
158
|
+
fake = backends.FakeBackend(
|
|
159
|
+
signature=self._sig(b"aaaaaaa-dirty")) # matches head_sha
|
|
160
|
+
monkeypatch.setattr(Board, "head_sha", lambda self: "aaaaaaa-dirty")
|
|
161
|
+
rc = self._run(tmp_path, fake, monkeypatch)
|
|
162
|
+
assert rc == cli.EXIT_OK
|
|
163
|
+
|
|
164
|
+
def test_fake_adapter_selectable_from_profile(self, tmp_path, monkeypatch):
|
|
165
|
+
monkeypatch.setenv("FLASHGATE_ALLOW_FAKE", "1")
|
|
166
|
+
board = self._board(tmp_path)
|
|
167
|
+
assert board.flash_adapter == "fake"
|
|
168
|
+
|
|
169
|
+
def test_fake_requires_explicit_opt_in(self, tmp_path, monkeypatch):
|
|
170
|
+
# F4: without the env gate, a fake profile must not even load —
|
|
171
|
+
# a simulated flash can green-light a board never programmed.
|
|
172
|
+
import pytest as _pytest
|
|
173
|
+
from flashgate.board import BoardError
|
|
174
|
+
monkeypatch.delenv("FLASHGATE_ALLOW_FAKE", raising=False)
|
|
175
|
+
with _pytest.raises(BoardError, match="FLASHGATE_ALLOW_FAKE"):
|
|
176
|
+
self._board(tmp_path)
|
|
177
|
+
|
|
178
|
+
def test_unknown_adapter_rejected_at_load(self, tmp_path):
|
|
179
|
+
import pytest
|
|
180
|
+
from flashgate.board import BoardError
|
|
181
|
+
with pytest.raises(BoardError, match="flash.adapter"):
|
|
182
|
+
self._board(tmp_path, extra=" adapter: jlink\n")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestPhase2AuditFixes:
|
|
186
|
+
"""Runtime-audit gaps: openocd_target wiring, per-backend probe
|
|
187
|
+
detection, doctor's on-board read through the backend."""
|
|
188
|
+
|
|
189
|
+
def test_openocd_target_override_wired_from_profile(self, tmp_path):
|
|
190
|
+
from flashgate.board import load_board
|
|
191
|
+
fw = tmp_path / "fw"; fw.mkdir()
|
|
192
|
+
p = tmp_path / "b.yaml"
|
|
193
|
+
p.write_text(
|
|
194
|
+
"board: t\nmcu: STM32H743\nfirmware:\n dir: fw\n"
|
|
195
|
+
" build: ninja\n artifact: fw.bin\n"
|
|
196
|
+
"flash:\n adapter: openocd\n"
|
|
197
|
+
" openocd_target: my_custom_target\n"
|
|
198
|
+
"evidence:\n mode: swd\nserial:\n banner: 'B {git}'\n",
|
|
199
|
+
encoding="utf-8")
|
|
200
|
+
board = load_board(p)
|
|
201
|
+
assert board.openocd_target == "my_custom_target"
|
|
202
|
+
from flashgate import backends
|
|
203
|
+
b = backends.OpenOcdBackend(
|
|
204
|
+
mcu=board.mcu, target=board.openocd_target or None)
|
|
205
|
+
assert b._target == "my_custom_target"
|
|
206
|
+
|
|
207
|
+
def test_probe_detection_markers(self):
|
|
208
|
+
cp = backends.CubeProgrammerBackend()
|
|
209
|
+
assert cp.probe_detected("--- ST-LINK SN : 56FF ---") is True
|
|
210
|
+
assert cp.probe_detected("(CubeProgrammer CLI not found)") is False
|
|
211
|
+
oc = backends.OpenOcdBackend(mcu="STM32H743")
|
|
212
|
+
assert oc.probe_detected("Info : STLINK V2 (API v2) VID:PID 0483:3748") is True
|
|
213
|
+
assert oc.probe_detected("SWD DPIDR 0x6ba02477") is True
|
|
214
|
+
assert oc.probe_detected("(openocd probe listing unavailable: x)") is False
|
|
215
|
+
fake = backends.FakeBackend()
|
|
216
|
+
assert fake.probe_detected("(fake backend)") is True
|
|
217
|
+
assert fake.probe_detected("") is False
|
|
218
|
+
|
|
219
|
+
def test_board_backend_passes_overrides(self, tmp_path, monkeypatch):
|
|
220
|
+
from flashgate.board import load_board
|
|
221
|
+
from flashgate import cli
|
|
222
|
+
fw = tmp_path / "fw"; fw.mkdir()
|
|
223
|
+
p = tmp_path / "b.yaml"
|
|
224
|
+
p.write_text(
|
|
225
|
+
"board: t\nmcu: STM32H743\nfirmware:\n dir: fw\n"
|
|
226
|
+
" build: ninja\n artifact: fw.bin\n"
|
|
227
|
+
"flash:\n adapter: openocd\n"
|
|
228
|
+
" openocd_interface: interface/cmsis-dap.cfg\n"
|
|
229
|
+
"evidence:\n mode: swd\nserial:\n banner: 'B {git}'\n",
|
|
230
|
+
encoding="utf-8")
|
|
231
|
+
board = load_board(p)
|
|
232
|
+
b = cli._board_backend(board)
|
|
233
|
+
assert isinstance(b, backends.OpenOcdBackend)
|
|
234
|
+
assert b._interface == "interface/cmsis-dap.cfg"
|
|
235
|
+
assert b._target == "stm32h7x" # MCU mapping still applies
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class TestInjectionAndDelegation:
|
|
239
|
+
"""Q4/Q6 mutation survivors + F1/F2 injection rejections."""
|
|
240
|
+
|
|
241
|
+
def test_program_command_is_forward_slashed_and_braced(self, monkeypatch):
|
|
242
|
+
b = backends.OpenOcdBackend(mcu="STM32H743")
|
|
243
|
+
seen = {}
|
|
244
|
+
monkeypatch.setattr(b, "_run",
|
|
245
|
+
lambda cmds: (seen.update(cmds=cmds) or (0, "")))
|
|
246
|
+
import pathlib
|
|
247
|
+
fake_bin = pathlib.PureWindowsPath(r"E:\dir\na me\App.bin")
|
|
248
|
+
monkeypatch.setattr(backends.Path, "is_file",
|
|
249
|
+
lambda self: True, raising=False)
|
|
250
|
+
b.flash(fake_bin, "c", "0x08000000")
|
|
251
|
+
cmd = seen["cmds"][0]
|
|
252
|
+
assert cmd == "program {E:/dir/na me/App.bin} 0x08000000 verify"
|
|
253
|
+
|
|
254
|
+
def test_brace_in_artifact_path_refused(self, tmp_path):
|
|
255
|
+
evil = tmp_path / "we}ird.bin"
|
|
256
|
+
evil.write_bytes(b"x")
|
|
257
|
+
b = backends.OpenOcdBackend(mcu="STM32H743")
|
|
258
|
+
res = b.flash(evil, "c", "0x08000000")
|
|
259
|
+
assert res.ok is False and "cannot be passed" in res.detail
|
|
260
|
+
|
|
261
|
+
def test_address_with_tcl_separator_rejected_at_load(self, tmp_path):
|
|
262
|
+
import pytest as _pytest
|
|
263
|
+
from flashgate.board import BoardError, load_board
|
|
264
|
+
fw = tmp_path / "fw"; fw.mkdir()
|
|
265
|
+
p = tmp_path / "b.yaml"
|
|
266
|
+
p.write_text("board: t\nmcu: STM32H743\nfirmware:\n dir: fw\n"
|
|
267
|
+
" build: ninja\n artifact: fw.bin\n"
|
|
268
|
+
'flash:\n address: "0x08000000; shutdown"\n'
|
|
269
|
+
"serial:\n banner: 'B {git}'\n", encoding="utf-8")
|
|
270
|
+
with _pytest.raises(BoardError, match="flash.address"):
|
|
271
|
+
load_board(p)
|
|
272
|
+
|
|
273
|
+
def test_openocd_target_with_brace_rejected_at_load(self, tmp_path):
|
|
274
|
+
import pytest as _pytest
|
|
275
|
+
from flashgate.board import BoardError, load_board
|
|
276
|
+
fw = tmp_path / "fw"; fw.mkdir()
|
|
277
|
+
p = tmp_path / "b.yaml"
|
|
278
|
+
p.write_text("board: t\nmcu: STM32H743\nfirmware:\n dir: fw\n"
|
|
279
|
+
" build: ninja\n artifact: fw.bin\n"
|
|
280
|
+
"flash:\n adapter: openocd\n"
|
|
281
|
+
" openocd_target: 'x}; echo PWNED; #'\n"
|
|
282
|
+
"serial:\n banner: 'B {git}'\n", encoding="utf-8")
|
|
283
|
+
with _pytest.raises(BoardError, match="openocd_target"):
|
|
284
|
+
load_board(p)
|
|
285
|
+
|
|
286
|
+
def test_cubeprogrammer_read_mem_delegates_in_order(self, monkeypatch):
|
|
287
|
+
from flashgate import swdsig as swsig
|
|
288
|
+
seen = {}
|
|
289
|
+
monkeypatch.setattr(swsig, "read_ram",
|
|
290
|
+
lambda c, a, s: seen.update(c=c, a=a, s=s) or b"")
|
|
291
|
+
b = backends.CubeProgrammerBackend()
|
|
292
|
+
b.read_mem("port=SWD", 0x2001FF00, 64)
|
|
293
|
+
assert seen == {"c": "port=SWD", "a": 0x2001FF00, "s": 64}
|
|
294
|
+
|
|
295
|
+
def test_interface_short_name_gets_prefix(self, monkeypatch):
|
|
296
|
+
b = backends.OpenOcdBackend(mcu="STM32H743",
|
|
297
|
+
interface="cmsis-dap")
|
|
298
|
+
seen = {}
|
|
299
|
+
monkeypatch.setattr(b, "_run",
|
|
300
|
+
lambda cmds: (seen.update(cmds=cmds) or (0, "")))
|
|
301
|
+
b.write32("c", 0, 0x2001FF00)
|
|
302
|
+
argv = seen["cmds"] # not full argv; assert via prefixing behavior
|
|
303
|
+
assert b._interface == "cmsis-dap"
|
|
304
|
+
# prefixing happens in _run's cmd construction; assert through flash
|
|
305
|
+
import flashgate.backends as bm
|
|
306
|
+
src = open(bm.__file__, encoding="utf-8").read()
|
|
307
|
+
assert 'f"interface/{i}.cfg"' in src
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|