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.
Files changed (40) hide show
  1. {flashgate-0.7.2 → flashgate-0.8.0}/PKG-INFO +21 -7
  2. {flashgate-0.7.2 → flashgate-0.8.0}/README.md +20 -6
  3. flashgate-0.8.0/flashgate/backends.py +313 -0
  4. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/board.py +35 -0
  5. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/cli.py +36 -22
  6. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/swdsig.py +3 -2
  7. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/PKG-INFO +21 -7
  8. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/SOURCES.txt +2 -0
  9. {flashgate-0.7.2 → flashgate-0.8.0}/pyproject.toml +1 -1
  10. flashgate-0.8.0/tests/test_backends.py +307 -0
  11. {flashgate-0.7.2 → flashgate-0.8.0}/LICENSE +0 -0
  12. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/__init__.py +0 -0
  13. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/__main__.py +0 -0
  14. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/bench.py +0 -0
  15. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/bench_serve.py +0 -0
  16. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/flasher.py +0 -0
  17. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/gatestate.py +0 -0
  18. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/mcp_server.py +0 -0
  19. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/probes.py +0 -0
  20. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/records.py +0 -0
  21. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/results.py +0 -0
  22. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/serialmon.py +0 -0
  23. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate/sttools.py +0 -0
  24. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/dependency_links.txt +0 -0
  25. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/entry_points.txt +0 -0
  26. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/requires.txt +0 -0
  27. {flashgate-0.7.2 → flashgate-0.8.0}/flashgate.egg-info/top_level.txt +0 -0
  28. {flashgate-0.7.2 → flashgate-0.8.0}/setup.cfg +0 -0
  29. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_bench.py +0 -0
  30. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_bench_serve.py +0 -0
  31. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_board.py +0 -0
  32. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_cli.py +0 -0
  33. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_flasher.py +0 -0
  34. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_gatestate.py +0 -0
  35. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_hook_stop.py +0 -0
  36. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_mcp.py +0 -0
  37. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_probes.py +0 -0
  38. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_records.py +0 -0
  39. {flashgate-0.7.2 → flashgate-0.8.0}/tests/test_serialmon.py +0 -0
  40. {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.7.2
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
- serial adapter hints, banner template, probes, watch globs. The console-side
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), and the
262
- remote bench over device-connect — all implemented and validated on real
263
- hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
264
- bench, 2026-09-15). Windows-first; Linux/macOS untested.
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
- serial adapter hints, banner template, probes, watch globs. The console-side
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), and the
241
- remote bench over device-connect — all implemented and validated on real
242
- hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
243
- bench, 2026-09-15). Windows-first; Linux/macOS untested.
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, find_cubeprogrammer
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
- cli = find_cubeprogrammer()
113
- if cli:
114
- print(_green(f" programmer : {cli}"))
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("STM32CubeProgrammer CLI not found")
117
- print(_red(" programmer : NOT FOUND"))
118
-
119
- if cli:
120
- listing = flasher.list_stlink()
121
- sn_lines = [ln.strip() for ln in listing.splitlines() if "ST-LINK SN" in ln]
122
- if sn_lines:
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 ST-Link probe detected (check USB, power, driver)")
126
- print(_red(" ST-Link : none detected"))
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
- print(_cyan(f"[flash] {board.artifact.name} @ {board.flash_address} via {board.flash_connect}"))
223
- result = flasher.flash(board.artifact, board.flash_connect, board.flash_address)
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
- result = flasher.flash(board.artifact, board.flash_connect, board.flash_address,
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 flasher.write32(board.flash_connect, 0, board.sig_address):
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 flasher.start_app(board.flash_connect):
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.7.2
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
- serial adapter hints, banner template, probes, watch globs. The console-side
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), and the
262
- remote bench over device-connect — all implemented and validated on real
263
- hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
264
- bench, 2026-09-15). Windows-first; Linux/macOS untested.
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.2"
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