tan-cli 0.7.0__py3-none-any.whl

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 (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/core/flash_plan.py ADDED
@@ -0,0 +1,2501 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pure planning for ``tan flash`` -- the decision + argv-building half.
3
+
4
+ Port of ``crates/tan-core/src/flash/`` (``mod.rs`` / ``args.rs`` /
5
+ ``builders.rs`` / ``registry.rs`` / ``storage.rs``) plus the manifest reader in
6
+ ``crates/tan-core/src/system_manifest.rs``. Every string, argv, filter and
7
+ per-backend command shape lives here with NO IO; the subprocess / filesystem /
8
+ temp-file half is ``tan.commands.flash_cmd``.
9
+
10
+ The flow mirrors ``alp_flash.dispatch`` + ``_flash_entry``: walk the manifest's
11
+ ``boot_order`` (or the sorted slice ``core_id``s when empty), map each step to
12
+ its slice, append the helper MCUs after, then dispatch each entry's
13
+ ``flash_method`` to a backend plan-builder.
14
+
15
+ **Strict ``flash_args`` reading.** A whole ``flash_args`` that is not a mapping
16
+ (the AEN701 helper's ``flash_args: TBD`` string) reads as an empty map -- but a
17
+ sub-key that IS present is read STRICTLY: every behaviour-affecting bool/int
18
+ (``erase``, ``use_openocd``, ``reset``, ``base``, ``baud``, ...) goes through a
19
+ ``_checked`` accessor that hard-errors on a wrong-type scalar rather than
20
+ silently defaulting, since a wrong flash is worse than a refused one. Do not
21
+ reintroduce a tolerant bool/int reader here.
22
+
23
+ **No hardware facts (I-26 / ADR-0017).** Nothing in this module names a SKU, an
24
+ address, a pin, an I2C address, a probe serial or a vendor branch. Every such
25
+ value arrives in ``flash_args``, passed through from alp-sdk ``metadata/``.
26
+ tan-cli#732 removed the module's last two exceptions along with the
27
+ ``swd_probe`` backend they existed for -- ``_DEFAULT_JLINK_DEVICE`` (a part
28
+ number) and ``_DEFAULT_BASE`` (a flash base address), both inherited verbatim
29
+ from the Rust oracle and reachable only from ``plan_swd_probe`` when a
30
+ manifest omitted the corresponding ``flash_args`` key. Neither default has any
31
+ other caller. If a future backend needs one, restate the count here rather
32
+ than adding it silently -- a docstring that undercounts its own debt is how
33
+ the next one gets added without argument.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import posixpath
39
+ from collections.abc import Sequence
40
+ from dataclasses import dataclass, replace
41
+ from typing import Any, Callable
42
+
43
+ from tan.core.pending import PENDING_PLACEHOLDER as PENDING_SENTINEL, is_pending_placeholder
44
+
45
+ #: The system-manifest schema major this command consumes. A different value is
46
+ #: REFUSED rather than read as if it were v1 -- mirrors
47
+ #: `system_manifest.rs::SYSTEM_MANIFEST_SCHEMA_VERSION`.
48
+ SYSTEM_MANIFEST_SCHEMA_VERSION = 1
49
+
50
+ _DEFAULT_JLINK_SPEED = 4000
51
+ _JLINK_BINARIES = ("JLinkExe", "JLink")
52
+
53
+
54
+ class ManifestError(Exception):
55
+ """`build/system-manifest.yaml` could not be consumed. `message` is the
56
+ human text; the caller pairs it with `flash.manifest-invalid`."""
57
+
58
+
59
+ class FlashPlanError(Exception):
60
+ """A backend refused to build a plan -- the `Err(String)` arm of every
61
+ `plan_*` builder in `builders.rs`/`storage.rs`. The message is reported
62
+ verbatim as the entry's `message`."""
63
+
64
+
65
+ # ── manifest reading ────────────────────────────────────────────────────────
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Slice:
70
+ """One per-core image from the manifest's `slices[]`. Tolerant reader: only
71
+ the fields `tan flash` consumes are modeled and unknown additive-v1 keys are
72
+ ignored, per the stability policy in `system_manifest.rs`."""
73
+
74
+ core_id: str
75
+ os: str
76
+ status: str = ""
77
+ output_artefact: str | None = None
78
+ flash_method: str | None = None
79
+ flash_args: Any = None
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class HelperMcu:
84
+ """One on-module helper MCU from `helper_mcus[]`."""
85
+
86
+ name: str
87
+ firmware_path: str | None = None
88
+ flash_method: str | None = None
89
+ flash_args: Any = None
90
+ update_channel: str | None = None
91
+ #: tan-cli#611. WHO may invoke `flash_method`, and WHEN -- the fact neither
92
+ #: `update_channel` (how a device is updated in the FIELD) nor
93
+ #: `flash_method` (by what transport) carries. See [`FLASH_POLICY_FACTORY`]
94
+ #: for the whole argument; `None` means the SoM preset declared nothing and
95
+ #: every gate below behaves exactly as it did before this field existed.
96
+ flash_policy: str | None = None
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Manifest:
101
+ sku: str = ""
102
+ slices: tuple[Slice, ...] = ()
103
+ helper_mcus: tuple[HelperMcu, ...] = ()
104
+ boot_order: tuple[Any, ...] = ()
105
+
106
+
107
+ def _opt_str(raw: Any) -> str | None:
108
+ """A manifest string field, or `None`. A non-string scalar reads as absent
109
+ rather than being coerced: `serde` would have failed the whole document, and
110
+ `str(4)` here would silently invent a path/method name."""
111
+ return raw if isinstance(raw, str) else None
112
+
113
+
114
+ def parse_system_manifest(text: str) -> Manifest:
115
+ """Parse + version-guard a `system-manifest.yaml` document.
116
+
117
+ Raises `ManifestError` for: PyYAML unavailable, malformed YAML, a non-mapping
118
+ document, a `schema_version` that is not 1, or a `slices[]`/`helper_mcus[]`
119
+ entry missing a field the Rust struct declares non-`Option` (`core_id`/`os`
120
+ for a slice, `name`/`chip` for a helper) -- serde fails the ENTIRE parse in
121
+ that last case, so a partial read here would flash against a manifest the
122
+ oracle rejects.
123
+
124
+ tan ships no YAML dependency of its own (`python/pyproject.toml`), so PyYAML
125
+ is imported lazily. Its absence is FATAL here, unlike in `debug-config`
126
+ where the manifest is a best-effort enrichment: `flash` cannot pick a target
127
+ or an artefact without it, and silently flashing nothing would be the worse
128
+ outcome.
129
+ """
130
+ try:
131
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
132
+ except ImportError as err:
133
+ raise ManifestError(
134
+ "reading a system-manifest needs PyYAML, which is not importable "
135
+ f"({err}); install it (`pip install pyyaml`) or run tan from a "
136
+ "bootstrapped workspace"
137
+ ) from err
138
+ try:
139
+ doc = yaml.safe_load(text)
140
+ except Exception as err: # noqa: BLE001 -- the SDK's output, not ours
141
+ raise ManifestError(f"system-manifest is not valid YAML: {err}") from err
142
+ if doc is None or not isinstance(doc, dict):
143
+ raise ManifestError(
144
+ "system-manifest is not valid YAML: expected a mapping at the "
145
+ f"document root, got {type(doc).__name__}"
146
+ )
147
+ version = doc.get("schema_version")
148
+ if version != SYSTEM_MANIFEST_SCHEMA_VERSION:
149
+ raise ManifestError(
150
+ f"unsupported system-manifest schema_version {version} (this CLI "
151
+ f"consumes v{SYSTEM_MANIFEST_SCHEMA_VERSION}); upgrade the CLI or "
152
+ "the SDK so the versions match"
153
+ )
154
+
155
+ hw_info = doc.get("hw_info")
156
+ sku = ""
157
+ if isinstance(hw_info, dict) and isinstance(hw_info.get("sku"), str):
158
+ sku = hw_info["sku"]
159
+
160
+ slices: list[Slice] = []
161
+ for raw in _seq(doc.get("slices")):
162
+ if not isinstance(raw, dict):
163
+ raise ManifestError("system-manifest is not valid YAML: slices[] entry is not a mapping")
164
+ core_id, os_name = raw.get("core_id"), raw.get("os")
165
+ if not isinstance(core_id, str) or not isinstance(os_name, str):
166
+ raise ManifestError(
167
+ "system-manifest is not valid YAML: every slices[] entry needs a "
168
+ "string `core_id` and `os`"
169
+ )
170
+ slices.append(
171
+ Slice(
172
+ core_id=core_id,
173
+ os=os_name,
174
+ status=raw["status"] if isinstance(raw.get("status"), str) else "",
175
+ output_artefact=_opt_str(raw.get("output_artefact")),
176
+ flash_method=_opt_str(raw.get("flash_method")),
177
+ flash_args=raw.get("flash_args"),
178
+ )
179
+ )
180
+
181
+ helpers: list[HelperMcu] = []
182
+ for raw in _seq(doc.get("helper_mcus")):
183
+ if not isinstance(raw, dict):
184
+ raise ManifestError(
185
+ "system-manifest is not valid YAML: helper_mcus[] entry is not a mapping"
186
+ )
187
+ name, chip = raw.get("name"), raw.get("chip")
188
+ if not isinstance(name, str) or not isinstance(chip, str):
189
+ raise ManifestError(
190
+ "system-manifest is not valid YAML: every helper_mcus[] entry needs "
191
+ "a string `name` and `chip`"
192
+ )
193
+ helpers.append(
194
+ HelperMcu(
195
+ name=name,
196
+ firmware_path=_opt_str(raw.get("firmware_path")),
197
+ flash_method=_opt_str(raw.get("flash_method")),
198
+ flash_args=raw.get("flash_args"),
199
+ update_channel=_opt_str(raw.get("update_channel")),
200
+ flash_policy=_opt_str(raw.get("flash_policy")),
201
+ )
202
+ )
203
+
204
+ return Manifest(
205
+ sku=sku,
206
+ slices=tuple(slices),
207
+ helper_mcus=tuple(helpers),
208
+ boot_order=tuple(_seq(doc.get("boot_order"))),
209
+ )
210
+
211
+
212
+ def _seq(raw: Any) -> list[Any]:
213
+ """A manifest list field. `#[serde(default)]` means a missing key is an
214
+ empty list; a key present with a NON-list value is a shape error serde would
215
+ reject, so it is not silently treated as empty here either -- `[]` is
216
+ returned only for genuinely absent/null."""
217
+ if raw is None:
218
+ return []
219
+ if not isinstance(raw, list):
220
+ raise ManifestError(
221
+ f"system-manifest is not valid YAML: expected a sequence, got {type(raw).__name__}"
222
+ )
223
+ return raw
224
+
225
+
226
+ # ── target selection ────────────────────────────────────────────────────────
227
+
228
+ SLICE = "slice"
229
+ HELPER = "helper"
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class FlashTarget:
234
+ """One manifest entry selected for flashing, in dispatch order."""
235
+
236
+ kind: str
237
+ id: str
238
+ flash_method: str | None
239
+ flash_args: Any
240
+ output_artefact: str | None = None
241
+ firmware_path: str | None = None
242
+ update_channel: str | None = None
243
+ #: tan-cli#611 -- carried from `HelperMcu.flash_policy`; always `None` for a
244
+ #: slice, which is an application core and a genuine customer flash target.
245
+ flash_policy: str | None = None
246
+ #: tan-cli#1509 -- further `alif_mram_jlink` slices of the same board folded into
247
+ #: this one write, so ONE ATOC names them all (see `merge_flow_d_targets`).
248
+ co_slices: tuple["FlashTarget", ...] = ()
249
+
250
+
251
+ def _signable_flow_d(t: FlashTarget) -> bool:
252
+ """A slice `tan flash` would sign for itself via Flow D: no `atoc`/`atoc_map`/
253
+ `atoc_address` of the customer's own, and no unresolved `TBD`."""
254
+ if t.kind != SLICE or select_flash_method(t) != FLOW_D_METHOD:
255
+ return False
256
+ fa = t.flash_args
257
+ if flash_args_has_tbd(fa) or not t.output_artefact:
258
+ return False
259
+ try:
260
+ return all(fa_str_checked(fa, k, True) is None for k in ("atoc", "atoc_map", "atoc_address"))
261
+ except FlashPlanError:
262
+ return False
263
+
264
+
265
+ #: The keys that say WHICH board a Flow D slice targets; slices that differ on any are
266
+ #: separate writes (separate ATOCs), never one.
267
+ _BOARD_KEYS = ("jlink_flash_device", "jlink_serial", "jlink_device", "expect_dpidr")
268
+
269
+ #: Keys read from the LEAD slice only (the combined entry has one preflight, one confirm
270
+ #: gate, one device config, one ATOC acknowledgement). A co-slice that disagrees is
271
+ #: refused rather than silently ignored.
272
+ _LEAD_ONLY_KEYS = (
273
+ "confirm", "resident_atoc_entries", "setools_device_config", "atoc_unqueryable",
274
+ "setools_dir",
275
+ )
276
+
277
+
278
+ def check_co_slice_args(lead_id: str, lead_args: Any, co_id: str, co_args: Any) -> None:
279
+ """tan-cli#1509: refuse a co-slice whose `_LEAD_ONLY_KEYS` differ from the lead's."""
280
+
281
+ def _get(fa: Any, key: str) -> Any:
282
+ value = fa.get(key) if hasattr(fa, "get") else None
283
+ return bool(value) if key in ("confirm", "atoc_unqueryable") else value
284
+
285
+ for key in _LEAD_ONLY_KEYS:
286
+ if _get(lead_args, key) != _get(co_args, key):
287
+ raise FlashPlanError(
288
+ f"{FLOW_D_METHOD}: slices '{lead_id}' and '{co_id}' are written in ONE "
289
+ f"ATOC, but flash_args.{key} differs between them "
290
+ f"({_get(lead_args, key)!r} vs {_get(co_args, key)!r}); only '{lead_id}''s "
291
+ f"would be honoured. Set flash_args.{key} identically on both."
292
+ )
293
+
294
+
295
+ def merge_flow_d_targets(targets: Sequence[FlashTarget]) -> tuple[FlashTarget, ...]:
296
+ """tan-cli#1509: fold every signable `alif_mram_jlink` slice that shares one
297
+ `jlink_flash_device` into the FIRST one's `co_slices`, so a single ATOC names
298
+ them all. A second ATOC at the same package address would delist the first
299
+ core (the loader replaces the whole table). Anything else, and a lone slice,
300
+ passes through untouched."""
301
+ groups: dict[tuple[str | None, ...], list[FlashTarget]] = {}
302
+ for t in targets:
303
+ if _signable_flow_d(t):
304
+ try:
305
+ # One board = one device profile, one probe, one expected SW-DP ID.
306
+ key = tuple(
307
+ fa_str_checked(t.flash_args, k, k == "expect_dpidr")
308
+ for k in _BOARD_KEYS
309
+ )
310
+ except FlashPlanError:
311
+ continue
312
+ groups.setdefault(key, []).append(t)
313
+ folded = {id(c) for g in groups.values() if len(g) > 1 for c in g[1:]}
314
+ heads = {id(g[0]): g[1:] for g in groups.values() if len(g) > 1}
315
+ return tuple(
316
+ replace(t, co_slices=tuple(heads[id(t)])) if id(t) in heads else t
317
+ for t in targets
318
+ if id(t) not in folded
319
+ )
320
+
321
+
322
+ def left_out_flow_d(manifest: Manifest, targets: Sequence[FlashTarget]) -> tuple[str, ...]:
323
+ """Ids of the manifest's `alif_mram_jlink` slices that THIS run does not flash
324
+ (a `--core` filter) -- the ones the lone ATOC will delist. Only reported when
325
+ the run has exactly one Flow D write (a combined one names them already)."""
326
+ ours = [t for t in targets if t.kind == SLICE and select_flash_method(t) == FLOW_D_METHOD]
327
+ if len(ours) != 1 or ours[0].co_slices:
328
+ return ()
329
+ ran = {ours[0].id}
330
+ return tuple(
331
+ s.core_id
332
+ for s in manifest.slices
333
+ if s.core_id and s.core_id not in ran
334
+ and select_flash_method(
335
+ FlashTarget(SLICE, s.core_id, s.flash_method, s.flash_args)
336
+ ) == FLOW_D_METHOD
337
+ )
338
+
339
+
340
+ @dataclass(frozen=True)
341
+ class TargetPlan:
342
+ targets: tuple[FlashTarget, ...]
343
+ warnings: tuple[str, ...]
344
+ refused: tuple[str, ...]
345
+ #: The subset of "status not ok" refusals that are explained away rather
346
+ #: than a real build problem: a slice `status: "skipped"` -- i.e. `tan
347
+ #: build` itself declined to build this slice under `executionPolicy.
348
+ #: missingTool`/`.nullCommand` (a host with no `bitbake`, say) -- or a
349
+ #: slice declared `os: "off"` in `board.yaml` (tan-cli#699), which `tan
350
+ #: build`'s `iter_buildable_slices` never touches at all, so its manifest
351
+ #: entry never advances off whatever status the plan-time emit left it at
352
+ #: (`pending`, today). Both were decisions already made before `tan
353
+ #: flash` ever ran; refusing to flash a never-built
354
+ #: artefact is still correct (there is nothing to flash), but it must not
355
+ #: ALSO read as a flash failure for a slice the manifest already
356
+ #: explained away. `refused` (a `"failed"`/`"pending"`/other status on a
357
+ #: slice that is NOT `os: "off"`) is the opposite: `tan build` tried and
358
+ #: the slice is broken or was never reconciled, which must keep failing
359
+ #: `tan flash`. Callers surface this bucket as a WARNING and must not
360
+ #: fold it into a failure count -- see `refused` for the error-severity,
361
+ #: exit-code-affecting bucket.
362
+ #:
363
+ #: **DIVERGED from the Rust oracle, before the oracle was retired.**
364
+ #: `crates/tan-core/src/flash/mod.rs`'s `plan_flash_targets` had no
365
+ #: `refused_skipped` bucket at all -- a `status: skipped` slice/helper
366
+ #: landed in the ONE `refused` list alongside `failed`/`pending`/anything
367
+ #: else non-`ok`, and the CLI seeded `failed` from `refused.len()` before
368
+ #: the dispatch loop even ran, so the oracle FAILED the run on a
369
+ #: `status: skipped` slice exactly like any other bad status -- and had no
370
+ #: `os: "off"` carve-out either, so it also failed on that shape. This
371
+ #: split (and the caller's
372
+ #: warning-only, exit-0 treatment when something else DID flash) was a
373
+ #: deliberate product improvement on top of the port, not a porting bug --
374
+ #: but the caller (`tan.commands.flash_cmd.flash`) MUST still fail the run
375
+ #: when every match was a `refused_skipped` entry and nothing flashed
376
+ #: (`flash.nothing-flashed`), or this bucket reintroduces the exact
377
+ #: silent-success class `refused` exists to prevent, just inverted.
378
+ #: `crates/` and `tests/parity/test_flash_oracle_parity.py` (which
379
+ #: deliberately carried no `status: skipped` nor `os: "off"` case, for the
380
+ #: reasons above) were both deleted in 2883cdf -- there is no longer a
381
+ #: second implementation to diff against.
382
+ refused_skipped: tuple[str, ...] = ()
383
+
384
+
385
+
386
+ def plan_flash_targets(
387
+ manifest: Manifest, core: str | None = None, helper: str | None = None
388
+ ) -> TargetPlan:
389
+ """Build the ordered flash target list + any `boot_order` warnings/refusals.
390
+
391
+ - Empty `boot_order`: one step per slice `core_id`, sorted ascending.
392
+ - Non-empty `boot_order`: walked in order; a step naming a `core_id` not in
393
+ `slices` is dropped and surfaced as a warning.
394
+ - A slice whose `status` is not `ok` is REFUSED, not flashed and not silently
395
+ dropped: `overlay_run_results` PRESERVES the plan-time `output_artefact`
396
+ when a later run has no artefact for that core, so a run-1 success followed
397
+ by a run-2 failure/skip leaves run-1's elf on disk under a manifest
398
+ reporting a broken slice. Flashing that stale elf and silently dropping the
399
+ slice are the same silent-failure class. A `status: skipped` refusal, or
400
+ one for a slice declared `os: "off"` in `board.yaml` (tan-cli#699), is
401
+ split into `refused_skipped` rather than `refused`: `tan build` already
402
+ decided (via `executionPolicy`, or by design for an `off` core) that
403
+ this slice was never going to build -- e.g. no `bitbake` on an MCU-only
404
+ checkout, or a core that is deliberately off -- and that is not a flash
405
+ failure, it is `tan flash` agreeing with a decision already made.
406
+ `os: "off"` is checked on the slice's `os` field, not its
407
+ `status`: an `off` core's manifest entry never advances off whatever
408
+ status the plan-time emit left it at, so it must be recognised
409
+ regardless of that value. A genuinely broken or unbuilt slice
410
+ (`status: failed`, `status: pending` on an `os` that is NOT `"off"`, or
411
+ any other non-`ok`/non-`skipped` value) stays in `refused`.
412
+ - Helpers always come AFTER all slices.
413
+ - `core` flashes only that slice and skips every helper; `helper` skips every
414
+ slice and flashes only that helper.
415
+
416
+ Callers MUST surface both `refused` and `refused_skipped`: those entries
417
+ never enter `targets`, so a caller that only reports `targets`/`warnings`
418
+ would show a clean run while a stale/never-built artefact stayed unflashed.
419
+ Only `refused` (not `refused_skipped`) may fail the overall run -- see
420
+ `TargetPlan.refused_skipped`.
421
+ """
422
+ targets: list[FlashTarget] = []
423
+ warnings: list[str] = []
424
+ refused: list[str] = []
425
+ refused_skipped: list[str] = []
426
+
427
+ def find_slice(cid: str) -> Slice | None:
428
+ # Non-empty core_id only, matching the Python dict-comprehension guard
429
+ # `alp_flash` used and the `!s.core_id.is_empty()` filter in Rust.
430
+ for s in manifest.slices:
431
+ if s.core_id and s.core_id == cid:
432
+ return s
433
+ return None
434
+
435
+ if not manifest.boot_order:
436
+ steps = sorted(s.core_id for s in manifest.slices if s.core_id)
437
+ else:
438
+ steps = []
439
+ for step in manifest.boot_order:
440
+ if not isinstance(step, dict):
441
+ continue
442
+ named = step.get("core")
443
+ if isinstance(named, str) and named:
444
+ steps.append(named)
445
+
446
+ # A slice present in `slices` but never named by a `boot_order` step used to
447
+ # be dropped with NO warning at all -- a heterogeneous system silently
448
+ # flashed a strict subset of its cores and reported success. Only warn on the
449
+ # unfiltered default run: `--core` deliberately narrows the slice set and
450
+ # `--helper` deliberately suppresses every slice.
451
+ if manifest.boot_order and helper is None and core is None:
452
+ for s in manifest.slices:
453
+ if s.core_id and s.core_id not in steps:
454
+ warnings.append(f"flash: slice '{s.core_id}' has no boot_order entry; not flashed")
455
+
456
+ if helper is None:
457
+ for cid in steps:
458
+ if core is not None and cid != core:
459
+ continue
460
+ found = find_slice(cid)
461
+ if found is None:
462
+ warnings.append(
463
+ f"flash: boot_order references core '{cid}' not in slices; skipping"
464
+ )
465
+ continue
466
+ if not slice_should_flash(found.status):
467
+ # `found.os == "off"` is checked BEFORE `found.status ==
468
+ # "skipped"` below on purpose: an off core never receives a
469
+ # `SliceRunResult` (see the comment inside this branch), so
470
+ # its `status` can only ever be the plan-time `pending`
471
+ # default -- never `failed` -- making this ordering safe
472
+ # today. If that ever changed and an off core's manifest
473
+ # entry could carry `status: failed`, checking `os` first
474
+ # would silently downgrade a real failure to a warning; the
475
+ # `os` check would need to move after the `status` checks
476
+ # (or gain its own `status == "failed"` guard) at that point.
477
+ if found.os == "off":
478
+ # tan-cli#699: `board.yaml` declares this core `os: "off"`
479
+ # -- `tan build`'s `iter_buildable_slices` (`tan/planner/
480
+ # orchestrator.py:36`) correctly skips it, so it never
481
+ # receives a `SliceRunResult` and the manifest's plan-time
482
+ # `status: pending` default is never overlaid to anything
483
+ # else. That is NOT a policy skip (`executionPolicy` never
484
+ # ran for this core) and NOT an incomplete/failed build --
485
+ # it is the manifest correctly recording a core that is
486
+ # declared off, so `tan build` never builds it. (The
487
+ # manifest can still name an `app:`/`board:`/`toolchain:`
488
+ # for an off core -- `board.yaml` may declare those
489
+ # fields per-core independent of `os:` -- so this must
490
+ # not be read as "no app, no board".)
491
+ # Checked on `found.os`, not `found.status`: unlike the
492
+ # `skipped` branch below, this must catch the slice
493
+ # regardless of which never-advanced status the emitter
494
+ # happens to have left behind.
495
+ refused_skipped.append(
496
+ f"flash: slice '{found.core_id}' is declared `os: \"off\"` "
497
+ "in board.yaml -- tan build never builds a core "
498
+ "declared off, by design. There is nothing to flash; "
499
+ "this is expected, not an error."
500
+ )
501
+ elif found.status == "skipped":
502
+ # A policy decision `tan build` already made and reported
503
+ # (`executionPolicy.missingTool`/`.nullCommand`), not a
504
+ # broken build -- "stale, rebuild it" is wrong on both
505
+ # counts: nothing was ever built, so nothing is stale, and
506
+ # rebuilding ON THIS HOST hits the same policy skip again.
507
+ refused_skipped.append(
508
+ f"flash: slice '{found.core_id}' build status is 'skipped' -- "
509
+ "tan build already declined to build it under executionPolicy "
510
+ "(a missing tool or a null command on this host); there is "
511
+ "nothing to flash. Rebuilding on this same host will skip it "
512
+ "again -- it needs a host where that tool resolves."
513
+ )
514
+ else:
515
+ refused.append(
516
+ f"flash: slice '{found.core_id}' build status is "
517
+ f"'{found.status}' (not 'ok'); refusing to flash its artefact "
518
+ "-- it may be stale from a previous successful build. "
519
+ "Rebuild it first."
520
+ )
521
+ continue
522
+ targets.append(
523
+ FlashTarget(
524
+ kind=SLICE,
525
+ id=found.core_id,
526
+ flash_method=found.flash_method,
527
+ flash_args=found.flash_args,
528
+ output_artefact=found.output_artefact,
529
+ )
530
+ )
531
+
532
+ if core is None:
533
+ for h in manifest.helper_mcus:
534
+ if not h.name:
535
+ continue
536
+ if helper is not None and h.name != helper:
537
+ continue
538
+ targets.append(
539
+ FlashTarget(
540
+ kind=HELPER,
541
+ id=h.name,
542
+ flash_method=h.flash_method,
543
+ flash_args=h.flash_args,
544
+ firmware_path=h.firmware_path,
545
+ update_channel=h.update_channel,
546
+ flash_policy=h.flash_policy,
547
+ )
548
+ )
549
+
550
+
551
+ return TargetPlan(
552
+ tuple(targets), tuple(warnings), tuple(refused), tuple(refused_skipped)
553
+ )
554
+
555
+
556
+ # ── who may flash a helper, and when (tan-cli#611) ──────────────────────────
557
+
558
+ #: A genuine customer flash target. Explicit rather than merely-absent so a SoM
559
+ #: preset can SAY so; absent is treated the same way (see [`helper_flash_gate`]),
560
+ #: because every preset written before this field existed means exactly that.
561
+ FLASH_POLICY_CUSTOMER = "customer"
562
+
563
+ #: Programmed by Alp Lab in production. Never a customer flash target, and there
564
+ #: is no recovery path declared for it.
565
+ #:
566
+ #: This is the fact tan-cli#611 found nothing carried. `update_channel` answers
567
+ #: how a device is updated in the FIELD; `flash_method` answers by what
568
+ #: TRANSPORT it is written. Neither answers WHO may write it, so `flash` was
569
+ #: inferring the first from the second: it honoured `update_channel` only for a
570
+ #: helper that declared no `flash_method` at all, which mirrored an alp-sdk
571
+ #: schema rule making the two mutually exclusive. That XOR is the modelling
572
+ #: defect -- a helper can legitimately have BOTH an OTA channel for the normal
573
+ #: case and a flash method for the abnormal one -- and this field is what
574
+ #: replaces the inference.
575
+ FLASH_POLICY_FACTORY = "factory"
576
+
577
+ #: Programmed by Alp Lab in production, and customer-flashable ONLY to recover a
578
+ #: bricked device, with Alp Lab-supplied binaries.
579
+ #:
580
+ #: An unconditional skip would be WRONG for this value: it would remove the one
581
+ #: path that matters at the one moment it matters. So the gate declines it on an
582
+ #: ordinary run and keeps it reachable through a DELIBERATE action -- see
583
+ #: [`helper_flash_gate`]'s `recovery_armed`/`helper_filter` pair, which together
584
+ #: mean a recovery write cannot be reached by a bare `tan flash`.
585
+ FLASH_POLICY_RECOVERY_ONLY = "recovery_only"
586
+
587
+ #: Every policy this CLI understands, in the order the diagnostics list them.
588
+ FLASH_POLICIES = (
589
+ FLASH_POLICY_CUSTOMER,
590
+ FLASH_POLICY_FACTORY,
591
+ FLASH_POLICY_RECOVERY_ONLY,
592
+ )
593
+
594
+ #: How a policy-declined helper is named in the human transcript.
595
+ _PRODUCTION_PROGRAMMED = "is programmed by Alp Lab in production"
596
+
597
+
598
+ def _channel_clause(channel: str) -> str:
599
+ """The `update_channel` sentence, or nothing.
600
+
601
+ "Mention the update channel only where one exists" is the point: the old
602
+ message asserted an OTA mechanism as the REASON a helper was skipped, which
603
+ is only ever true where a channel is declared -- and is not the reason even
604
+ then. The reason is who programs it; the channel is a separate fact about
605
+ its life afterwards."""
606
+ return f" Field updates arrive over update_channel: {channel}." if channel else ""
607
+
608
+
609
+ def _under_declared_skip_message(kind: str, entry_id: str, method: str, channel: str) -> str:
610
+ """A helper carrying BOTH halves and no `flash_policy`.
611
+
612
+ Legal only once the upstream XOR is relaxed, and under-declared when it
613
+ happens: the preset author gave the entry both an update channel and a flash
614
+ method, and said nothing about who may write it. Declining is fail-safe AND
615
+ visible -- silently flashing it would be the tan-cli#611 defect one field
616
+ over."""
617
+ return (
618
+ f"flash: {kind} '{entry_id}' declares both flash_method '{method}' and "
619
+ f"update_channel '{channel}' but no flash_policy, so nothing says who may "
620
+ f"flash it; skipping. Add flash_policy ({' / '.join(FLASH_POLICIES)}) to "
621
+ "the helper_firmware entry in the SoM preset."
622
+ )
623
+
624
+
625
+ def _recovery_only_skip_message(
626
+ kind: str, entry_id: str, channel: str, *, recovery_armed: bool
627
+ ) -> str:
628
+ """The decline for a `recovery_only` helper this run may not write.
629
+
630
+ The tail names the EXACT re-run rather than the flag alone, and distinguishes
631
+ the two ways to be here: `--recover` not given at all, versus given on a run
632
+ that was never narrowed to this entry. An operator whose device is bricked
633
+ has no second channel to work anything out from."""
634
+ if recovery_armed:
635
+ tail = (
636
+ " --recover was given but this run is not narrowed to it; a recovery "
637
+ "flash must name its single target. Re-run with "
638
+ f"`--helper {entry_id} --recover`."
639
+ )
640
+ else:
641
+ tail = (
642
+ " To recover a bricked device deliberately, re-run with "
643
+ f"`--helper {entry_id} --recover`."
644
+ )
645
+ return (
646
+ f"flash: {kind} '{entry_id}' {_PRODUCTION_PROGRAMMED} and is "
647
+ "customer-flashable only to recover a bricked device, with Alp "
648
+ f"Lab-supplied binaries; skipping.{_channel_clause(channel)}{tail}"
649
+ )
650
+
651
+
652
+ def helper_flash_gate(
653
+ target: FlashTarget,
654
+ *,
655
+ recovery_armed: bool = False,
656
+ helper_filter: str | None = None,
657
+ ) -> str | None:
658
+ """The `flash_policy` decision for one target: a skip message, or `None` to
659
+ let dispatch continue. Pure -- no IO, no `flash_args`, no PATH.
660
+
661
+ Called ABOVE `_flash_entry`'s `if not raw_method:` guard, which is the whole
662
+ of tan-cli#611's tan-side half: the old code consulted the only non-customer
663
+ declaration there was (`update_channel`) exclusively INSIDE that guard, so a
664
+ helper declaring both it and a `flash_method` had the declaration silently
665
+ dropped and was flashed like any other target.
666
+
667
+ `recovery_armed` (`--recover`) and `helper_filter` (`--helper NAME`) are BOTH
668
+ required to reach a `recovery_only` write: the flag alone would let a
669
+ whole-manifest run sweep a recovery helper in. An UNRECOGNISED policy skips
670
+ -- a restriction this build does not understand must not become permission."""
671
+ policy = (target.flash_policy or "").strip()
672
+ channel = (target.update_channel or "").strip()
673
+ method = (target.flash_method or "").strip()
674
+ kind, entry_id = target.kind, target.id
675
+
676
+ if not policy:
677
+ if method and channel:
678
+ return _under_declared_skip_message(kind, entry_id, method, channel)
679
+ # Every shape that predates this field, unchanged -- including the
680
+ # `update_channel`-without-`flash_method` skip, still worded by
681
+ # `_flash_entry` exactly as before.
682
+ return None
683
+ if policy == FLASH_POLICY_CUSTOMER:
684
+ return None
685
+ if policy == FLASH_POLICY_FACTORY:
686
+ return (
687
+ f"flash: {kind} '{entry_id}' {_PRODUCTION_PROGRAMMED}, not a customer "
688
+ f"flash target; skipping.{_channel_clause(channel)}"
689
+ )
690
+ if policy == FLASH_POLICY_RECOVERY_ONLY:
691
+ if recovery_armed and helper_filter == entry_id:
692
+ return None
693
+ return _recovery_only_skip_message(
694
+ kind, entry_id, channel, recovery_armed=recovery_armed
695
+ )
696
+ return (
697
+ f"flash: {kind} '{entry_id}' declares flash_policy '{policy}', which this "
698
+ f"tan does not recognise (known: {', '.join(FLASH_POLICIES)}); refusing to "
699
+ "treat an unrecognised restriction as permission; skipping. Upgrade tan, "
700
+ "or correct the SoM preset."
701
+ )
702
+
703
+
704
+ def slice_should_flash(status: str) -> bool:
705
+ """A slice is flashed iff it built successfully. `image_bundle.rs::
706
+ slice_should_bundle` -- the same one-line predicate, shared on purpose so
707
+ `flash` and `image` can never disagree about which artefacts are real."""
708
+ return status == "ok"
709
+
710
+
711
+ # ── path helpers ────────────────────────────────────────────────────────────
712
+
713
+
714
+ def is_rust_absolute(path: str) -> bool:
715
+ """`Path::is_absolute()` semantics, NOT `os.path.isabs`.
716
+
717
+ On Windows Rust requires BOTH a prefix (drive/UNC) and a root, so a
718
+ rooted-but-driveless `/dev/sdb` or `\\x` is RELATIVE and `base.join(p)`
719
+ discards part of `base`. `os.path.isabs("/dev/sdb")` answered True on
720
+ Windows until Python 3.13 and False from 3.13 on -- so reaching for it would
721
+ make artefact resolution differ from the oracle AND differ between two
722
+ supported interpreters on the same host.
723
+ """
724
+ if os.name == "nt":
725
+ drive, rest = os.path.splitdrive(path)
726
+ return bool(drive) and rest[:1] in ("\\", "/")
727
+ return path.startswith("/")
728
+
729
+
730
+ def resolve_artefact_path(
731
+ artefact: str,
732
+ build_root: str,
733
+ sdk_root: str | None,
734
+ is_file: Callable[[str], bool],
735
+ ) -> str:
736
+ """Resolve a manifest artefact string to a path. Absolute strings pass
737
+ through; a relative string tries `build_root/artefact`, then
738
+ `sdk_root/artefact`, then west's NESTED `build_root/build/artefact`, and
739
+ falls back to the `build_root` candidate. `is_file` is injected to keep this
740
+ pure.
741
+
742
+ The first two candidates and the fallback are `flash/mod.rs::
743
+ resolve_artefact_path` verbatim. The third is the consumer half of **I-18**:
744
+ the planner emits `west build` with NO `-d`, so west's tree lands at
745
+ `<buildDir>/build/`. Before tan-cli#560 (alp-sdk d00dbdc1) the plan's
746
+ `artifacts` block still reported the un-nested `<buildDir>/zephyr/
747
+ zephyr.elf`; as of that pin the planner's own in-process output already
748
+ carries the nested path, but an older cached plan, the stale-SDK alp-sdk
749
+ subprocess fallback, or a hand-authored manifest can still arrive
750
+ un-nested -- this candidate still catches those. Rust reconciles the
751
+ nested case at manifest-WRITE time (`build/execute/manifest.rs::
752
+ resolve_zephyr_artefact`, tan's only writer of `output_artefact`, which
753
+ stores the nested ABSOLUTE path); this port's `build` does not write the
754
+ manifest yet, so an un-nested artefact string would resolve to nothing
755
+ without this candidate. Probed LAST, only when the oracle's own
756
+ candidates all miss a real file, so it never changes a resolution the
757
+ oracle already makes -- an absolute artefact never reaches it at all.
758
+ """
759
+ if is_rust_absolute(artefact):
760
+ return artefact
761
+ cand_build = os.path.join(build_root, artefact)
762
+ if sdk_root is None:
763
+ return cand_build
764
+ if is_file(cand_build):
765
+ return cand_build
766
+ cand_sdk = os.path.join(sdk_root, artefact)
767
+ if is_file(cand_sdk):
768
+ return cand_sdk
769
+ cand_nested = os.path.join(build_root, "build", artefact)
770
+ if is_file(cand_nested):
771
+ return cand_nested
772
+ return cand_build
773
+
774
+
775
+ # ── flash_args accessors ────────────────────────────────────────────────────
776
+
777
+
778
+ def _fa_get(value: Any, key: str) -> Any:
779
+ """A `flash_args` sub-key, or `None` when `flash_args` is not a mapping.
780
+ Mirrors `args.rs::fa_get`'s `v.as_mapping()?`: the AEN701 helper's
781
+ `flash_args: TBD` string reads as an empty map, not an error."""
782
+ if not isinstance(value, dict):
783
+ return None
784
+ return value.get(key)
785
+
786
+
787
+ def _fa_has_key(value: Any, key: str) -> bool:
788
+ """Whether `flash_args` is a mapping that carries `key` AT ALL --
789
+ independent of what it resolves to. `_fa_get`/`fa_str_checked` collapse a
790
+ present-but-null value and a genuinely-absent key to the same `None`,
791
+ which is right for every OPTIONAL field but wrong for one that must
792
+ distinguish "not selected" from "selected with a malformed value" (see
793
+ `slot0_load_address` in `plan_alif_mram_jlink`, and `expect_dpidr` /
794
+ `jlink_device` in `flow_d_preflight_script`)."""
795
+ return isinstance(value, dict) and key in value
796
+
797
+
798
+ def _yaml_debug(value: Any) -> str:
799
+ """`serde_yaml::Value`'s `{:?}` rendering, so the strict accessors' refusal
800
+ messages match the oracle byte for byte (`String("true")`, `Number(1)`,
801
+ `Bool(true)`, `Sequence [Number(1), Number(2)]`). Verified against the
802
+ shipped binary; the messages ship to the customer and to the extension's
803
+ issue list, and a diff harness that has to special-case them stops being
804
+ able to prove anything about the rest of the envelope."""
805
+ if value is None:
806
+ return "Null"
807
+ if isinstance(value, bool):
808
+ return f"Bool({'true' if value else 'false'})"
809
+ if isinstance(value, str):
810
+ return f'String("{value}")'
811
+ if isinstance(value, (int, float)):
812
+ return f"Number({value})"
813
+ if isinstance(value, list):
814
+ return "Sequence [" + ", ".join(_yaml_debug(v) for v in value) + "]"
815
+ if isinstance(value, dict):
816
+ body = ", ".join(f"{_yaml_debug(k)}: {_yaml_debug(v)}" for k, v in value.items())
817
+ return "Mapping {" + body + "}"
818
+ return f"String(\"{value}\")"
819
+
820
+
821
+ def fa_str(value: Any, key: str) -> str | None:
822
+ """A non-empty string sub-key; `None` when absent, empty, or non-string."""
823
+ raw = _fa_get(value, key)
824
+ if isinstance(raw, str) and raw:
825
+ return raw
826
+ return None
827
+
828
+
829
+ def fa_bool_checked(value: Any, key: str) -> bool | None:
830
+ """Strict bool accessor for every behaviour-affecting `flash_args` bool
831
+ (`reset`, `erase`, `use_openocd`, `use_pyocd`, `confirm`, ...).
832
+
833
+ A quoted `"false"` is NOT a bool, and a tolerant reader would read it as
834
+ absent, apply the caller's default and program the OPPOSITE of what was
835
+ written. `None` only for genuinely absent/null; any other shape raises."""
836
+ raw = _fa_get(value, key)
837
+ if raw is None:
838
+ return None
839
+ if isinstance(raw, bool):
840
+ return raw
841
+ raise FlashPlanError(
842
+ f"flash_args.{key} must be a bare boolean (true/false, unquoted; got "
843
+ f"{_yaml_debug(raw)}) -- refusing to silently fall back to a default -- "
844
+ "this plans a real flash write."
845
+ )
846
+
847
+
848
+ def fa_int_checked(value: Any, key: str) -> int | None:
849
+ """Strict int accessor (`jlink_speed`, `baud`, `jobs`, `speed`).
850
+
851
+ `0`-means-absent semantics are preserved from the oracle: an explicit `0`
852
+ yields `None`, i.e. "use the default". `bool` is checked BEFORE `int` --
853
+ Python's `True` IS an `int`, so an unguarded `isinstance(raw, int)` would
854
+ accept `jobs: true` and emit `-j 1`."""
855
+ raw = _fa_get(value, key)
856
+ if raw is None:
857
+ return None
858
+ if isinstance(raw, bool):
859
+ raise FlashPlanError(_int_refusal(key, raw))
860
+ if isinstance(raw, int):
861
+ return raw if raw != 0 else None
862
+ raise FlashPlanError(_int_refusal(key, raw))
863
+
864
+
865
+ def _int_refusal(key: str, raw: Any) -> str:
866
+ return (
867
+ f"flash_args.{key} must be a bare number (unquoted; got {_yaml_debug(raw)}) "
868
+ "-- refusing to silently fall back to a default -- this plans a real "
869
+ "flash write."
870
+ )
871
+
872
+
873
+ def fa_str_checked(value: Any, key: str, as_hex_address: bool) -> str | None:
874
+ """Strict string accessor for fields where falling back to a baked-in default
875
+ is dangerous -- a flash base address, an OpenOCD interface/target name that
876
+ gets interpolated into a spawned command.
877
+
878
+ `fa_str` treats ANY non-string value -- including the bare YAML integer an
879
+ unquoted `base: 0x08000000` resolves to -- as "absent", so the caller
880
+ silently substitutes the default and programs real silicon at the wrong
881
+ address with no warning. This returns `None` only for genuinely
882
+ absent/null/empty, round-trips a bare non-negative number back into a string
883
+ (hex for an address field, decimal otherwise), and refuses every other shape.
884
+
885
+ A NEGATIVE number is refused outright rather than formatted: Rust's
886
+ `n as u64` sign-extends `-8` into `0xFFFFFFFFFFFFFFF8`, which
887
+ `validate_address` (a pure charset check) then ACCEPTS as a plausible
888
+ address and the J-Link/OpenOCD command interpolates verbatim.
889
+ """
890
+ raw = _fa_get(value, key)
891
+ if raw is None:
892
+ return None
893
+ if isinstance(raw, bool):
894
+ # Guarded before the int arm for the same reason as `fa_int_checked`:
895
+ # `True` is an `int`, and `base: true` must not resolve to `0x00000001`.
896
+ raise FlashPlanError(_str_refusal(key, raw))
897
+ if isinstance(raw, str):
898
+ return raw or None
899
+ if isinstance(raw, int):
900
+ if raw < 0:
901
+ raise FlashPlanError(
902
+ f"flash_args.{key} = {raw} is negative; refusing to interpret it as "
903
+ "an address/count -- this plans a real flash write."
904
+ )
905
+ return f"0x{raw:08X}" if as_hex_address else str(raw)
906
+ raise FlashPlanError(_str_refusal(key, raw))
907
+
908
+
909
+ def _str_refusal(key: str, raw: Any) -> str:
910
+ return (
911
+ f"flash_args.{key} must be a quoted string (got {_yaml_debug(raw)}); "
912
+ "refusing to silently fall back to a default -- this plans a real flash write."
913
+ )
914
+
915
+
916
+ def is_pending(value: Any) -> bool:
917
+ """Whether a manifest SCALAR is the SDK's unfilled-field sentinel.
918
+
919
+ **The one definition for the whole flash path (#222).** Every guard in this
920
+ area used to test for EMPTY, and empty is the one thing a `TBD` placeholder
921
+ is not -- so an unfilled field behaved exactly like a filled one, and
922
+ whether that ended in a loud refusal or a spawned flasher came down to
923
+ whether the particular consumer happened to validate against a closed set.
924
+ `flash_method: TBD` hit the backend registry and failed safely;
925
+ `output_artefact`/`firmware_path: TBD` hit nothing at all, resolved to
926
+ `<build_root>/TBD` and reached a real J-Link write. Route every new
927
+ manifest-derived field through THIS, never through a fresh `== "TBD"`.
928
+
929
+ Trimmed before comparing -- a YAML `device: " TBD "` is the same unfilled
930
+ field -- but deliberately NOT case-folded and NOT a substring test:
931
+ `TBD-1234-XYZ` is a plausible part number and `flash_args.build_dir:
932
+ /opt/TBDtool/x` a plausible path, and refusing either would block a
933
+ legitimate flash. `tbd` lowercase is not the sentinel alp-sdk emits;
934
+ widening to it means widening the SDK's convention first, in one place,
935
+ not here.
936
+
937
+ The comparison is the single `tan.core.pending.is_pending_placeholder`
938
+ definition (#276): the neutral module with no flash- or image-bundle
939
+ machinery behind it, so `tan.core.size` (and `pinmux`, once ported) can
940
+ read the same rule without pulling flash internals in. `PENDING_SENTINEL`
941
+ stays the name this module exports -- `flash_cmd` and the flash tests
942
+ already spell it that way -- but it is now an alias for
943
+ `pending.PENDING_PLACEHOLDER`, not a second definition. `tan image`'s own
944
+ `image_bundle.PENDING_SENTINEL` is still a separate `"TBD"` literal;
945
+ pointing it at the same module too is a follow-up outside flash_plan.py.
946
+ """
947
+ return is_pending_placeholder(value)
948
+
949
+
950
+ def flash_args_has_tbd(value: Any) -> bool:
951
+ """Whether `flash_args` carries an unresolved `TBD` ANYWHERE -- a bare `TBD`
952
+ scalar, or a mapping/sequence value that trims to `TBD`.
953
+
954
+ Deliberately broader than a single-key check: a `TBD` anywhere means the
955
+ entry is not finalised yet under the SDK's pending-placeholder convention.
956
+ Do not narrow this back to a set of known keys. Recurses into mapping VALUES
957
+ and sequence elements, not mapping keys: every accessor here reads by a
958
+ known key name, so a key literally named `TBD` selects nothing and cannot
959
+ reach an argv.
960
+
961
+ This covers `flash_args` ONLY. The sibling artefact fields
962
+ (`output_artefact`/`firmware_path`) are NOT part of `flash_args` and are
963
+ guarded separately at the point of use -- see `is_pending`.
964
+ """
965
+ if isinstance(value, str):
966
+ return is_pending(value)
967
+ if isinstance(value, dict):
968
+ return any(flash_args_has_tbd(v) for v in value.values())
969
+ if isinstance(value, list):
970
+ return any(flash_args_has_tbd(v) for v in value)
971
+ return False
972
+
973
+
974
+ # ── validators ──────────────────────────────────────────────────────────────
975
+
976
+
977
+ def validate_identifier(
978
+ text: str, field_name: str, *, destination: str = "a spawned command / OpenOCD Tcl script"
979
+ ) -> None:
980
+ """Reject anything that is not a plain identifier, or a `/`-separated path
981
+ of plain identifier segments.
982
+
983
+ `interface`/`target` are interpolated verbatim into an OpenOCD
984
+ `-f <name>.cfg` path and a `-c` Tcl command string, so an unrestricted value
985
+ is a path-traversal + Tcl-injection primitive into a process routinely run
986
+ with device-flashing privileges. Multi-segment is allowed because OpenOCD
987
+ ships interface configs in subdirectories (`ftdi/olimex-arm-usb-ocd-h`).
988
+
989
+ Rust composes `path_guard::is_plain_relative` with a per-segment charset
990
+ check. The charset alone is EQUIVALENT here and is what is implemented: the
991
+ only shapes `is_plain_relative` adds are absolute/rooted/drive-prefixed and
992
+ `.`/`..`, and every one of those carries a character (`/` leading -> an empty
993
+ segment, `:`, `\\`, `.`) the charset already rejects. Cross-checked against
994
+ the oracle on `a;b`, `../x`, `/x`, `\\x`, `C:/x`, `a//b`, `.`.
995
+
996
+ `destination` lets a caller whose value never goes near OpenOCD say so.
997
+ The default names OpenOCD because `interface`/`target` -- the callers
998
+ that shaped this message originally -- legitimately reach its `-c` Tcl
999
+ string; `jlink_serial` (tan-cli#486 review) does not, and reusing the
1000
+ default gave a captured refusal envelope the sentence "refusing to
1001
+ interpolate it into a spawned command / OpenOCD Tcl script" for a value
1002
+ that only ever reaches a J-Link Commander `SelectEmuBySN` line.
1003
+ """
1004
+ segments = text.split("/")
1005
+ ok = bool(text) and all(
1006
+ seg and all(c.isascii() and (c.isalnum() or c in "-_") for c in seg) for seg in segments
1007
+ )
1008
+ if not ok:
1009
+ raise FlashPlanError(
1010
+ f"flash_args.{field_name} = {_quoted(text)} is not a plain identifier or "
1011
+ "'/'-separated path of plain identifiers (letters, digits, '-', '_' per "
1012
+ f"segment) -- refusing to interpolate it into {destination}."
1013
+ )
1014
+
1015
+
1016
+ def validate_address(text: str, field_name: str) -> None:
1017
+ """A flash base address must be purely hex digits, with an optional `0x`/`0X`.
1018
+
1019
+ `base` is interpolated verbatim into a J-Link Commander script LINE and an
1020
+ OpenOCD `-c` Tcl command string -- both line/command-oriented interpreters,
1021
+ so a newline (or `;`, `[`, `]`) inside `base` runs arbitrary extra commands
1022
+ against whatever silicon is attached.
1023
+ """
1024
+ digits = text
1025
+ for prefix in ("0x", "0X"):
1026
+ if digits.startswith(prefix):
1027
+ digits = digits[len(prefix) :]
1028
+ break
1029
+ if not digits or not all(c in "0123456789abcdefABCDEF" for c in digits):
1030
+ raise FlashPlanError(
1031
+ f"flash_args.{field_name} = {_quoted(text)} is not a plain hex/decimal "
1032
+ "address -- refusing to interpolate it into a J-Link/OpenOCD command."
1033
+ )
1034
+
1035
+
1036
+ def validate_commander_path(text: str, label: str) -> None:
1037
+ """Reject a control character (`\\x00`-`\\x1f`, or DEL `\\x7f`) or a literal
1038
+ `"` in a value bound for a J-Link Commander script LINE -- `loadbin`/
1039
+ `loadfile`/`verifybin`'s path argument, or `SelectEmuBySN`'s serial
1040
+ (tan-cli#486).
1041
+
1042
+ Deliberately narrower than `validate_identifier`: this guards a real
1043
+ filesystem path (`atoc`, the flash artefact), which legitimately carries
1044
+ spaces, `:`, `\\`, and drive letters -- rejecting those would turn a real
1045
+ Windows-style path into a refusal. What must never reach the script is a
1046
+ literal newline/CR: `commander_path`'s conditional quoting only stops
1047
+ SEGGER's whitespace tokeniser from splitting a spaced path into two
1048
+ tokens, it does nothing to stop an embedded newline from ending the
1049
+ quoted string's own Commander LINE and starting a new, attacker-chosen
1050
+ one -- quoting controls tokenisation within a line, not where lines end.
1051
+ A tab/formfeed/vertical-tab is technically a control character too but is
1052
+ already forced through the quoting path by `commander_path`'s `c.isspace()`
1053
+ check; rejecting the narrower "line-ending" set here (which still catches
1054
+ every one of them, since `isspace()` control chars are also `< 0x20`)
1055
+ keeps this guard doing ONE job instead of duplicating that quoting
1056
+ decision.
1057
+
1058
+ `"` is rejected too (tan-cli#486 review): a value carrying BOTH a `"` and
1059
+ a space defeats `commander_path`'s own conditional quoting from the
1060
+ inside -- `/b/a" halt "z.bin` renders `loadbin "/b/a" halt "z.bin", 0x8000`,
1061
+ where the embedded quote closes the wrapper early and `halt` reads back
1062
+ as a bare token mid-line, exactly what this function's own claim to
1063
+ "control tokenisation within a line" promises will not happen. `"` is a
1064
+ reserved character in a Windows filename and vanishingly rare on POSIX,
1065
+ so rejecting it costs a real path nothing.
1066
+ """
1067
+ if any(ord(c) < 0x20 or ord(c) == 0x7F or c == '"' for c in text):
1068
+ raise FlashPlanError(
1069
+ f"{label} = {_quoted(text)} contains a control character or a "
1070
+ 'literal \'"\' -- refusing to interpolate it into a J-Link '
1071
+ "Commander script line, where either would let it end the "
1072
+ "current line/quoted token and start a new, unintended one "
1073
+ "regardless of quoting."
1074
+ )
1075
+
1076
+
1077
+ #: `char::escape_debug`'s named escapes, which is what Rust's `{:?}` for a
1078
+ #: `&str` emits. Applied in ONE pass -- escaping `\\` up front and then
1079
+ #: re-scanning would revisit the backslashes it just added.
1080
+ _DEBUG_ESCAPES = {
1081
+ "\\": "\\\\",
1082
+ '"': '\\"',
1083
+ "\t": "\\t",
1084
+ "\r": "\\r",
1085
+ "\n": "\\n",
1086
+ }
1087
+
1088
+
1089
+ def _quoted(text: str) -> str:
1090
+ """Rust's `{s:?}` for a `&str`.
1091
+
1092
+ Not just `"` and `\\`: Rust escapes control characters too, so a `base`
1093
+ containing a real newline renders as `"0x8000\\n r"` -- ONE line -- and not
1094
+ as a refusal message split across two. These messages are exactly the ones
1095
+ reporting an injection attempt (`validate_address`/`validate_identifier`
1096
+ exist to catch a newline smuggled into a J-Link Commander script line), so a
1097
+ diagnostic that itself breaks across lines is the worst possible rendering:
1098
+ a reader sees a truncated message and the offending bytes on their own line.
1099
+ Caught by the oracle diff, not by review.
1100
+ """
1101
+ rendered = [
1102
+ _DEBUG_ESCAPES.get(char)
1103
+ or (char if char.isprintable() else f"\\u{{{ord(char):x}}}")
1104
+ for char in text
1105
+ ]
1106
+ return '"' + "".join(rendered) + '"'
1107
+
1108
+
1109
+ def is_raw_bin(artefact: str) -> bool:
1110
+ """Whether an artefact is a raw binary (needs an explicit load address), as
1111
+ opposed to ELF/HEX which carry their own. Passing a load offset for a
1112
+ non-`.bin` artefact shifts every section by that offset and writes outside
1113
+ the intended flash region."""
1114
+ return os.path.splitext(artefact)[1].lower() == ".bin"
1115
+
1116
+
1117
+ # ── the plan + backend registry ─────────────────────────────────────────────
1118
+
1119
+
1120
+ @dataclass(frozen=True)
1121
+ class FlashPlan:
1122
+ """A built flash plan: the argv, the success message, whether it is
1123
+ planning-only (never spawns real device IO), and -- for the J-Link path --
1124
+ the Commander script the caller must materialise to a temp file."""
1125
+
1126
+ argv: tuple[str, ...]
1127
+ ok_message: str
1128
+ planning_only: bool = False
1129
+ jlink_script: str | None = None
1130
+ #: tan-cli#520: a backend's own already-resolved WRITE device, carried
1131
+ #: through so the caller's read-only DPIDR preflight can reuse it as its
1132
+ #: own connect device rather than re-reading a second `flash_args` key
1133
+ #: with a different meaning. `None` for every backend/arm that has no such
1134
+ #: device to offer -- today that is every registered backend; the field
1135
+ #: exists for a future J-Link-arm backend that needs it (`swd_probe`, the
1136
+ #: original setter, was removed by tan-cli#732).
1137
+ preflight_device: str | None = None
1138
+
1139
+
1140
+ @dataclass(frozen=True)
1141
+ class BackendMeta:
1142
+ """A registered backend: the tool-gate `requires` list + its plan-builder."""
1143
+
1144
+ requires: tuple[str, ...]
1145
+ build: Callable[["FlashInputs", Callable[[str], bool]], FlashPlan]
1146
+
1147
+
1148
+ @dataclass(frozen=True)
1149
+ class FlashInputs:
1150
+ """Everything a backend plan-builder consumes. Injected by the CLI layer."""
1151
+
1152
+ artefact: str
1153
+ flash_args: Any
1154
+ core_id: str
1155
+ sku: str
1156
+ dry_run: bool = False
1157
+ #: The env half of the confirm gate (`ALP_FLASH_FORCE=1`). The per-entry
1158
+ #: `flash_args.confirm` is OR-ed in by the gated builders, so the effective
1159
+ #: gate is `flash_args.confirm OR ALP_FLASH_FORCE=1`.
1160
+ force_confirm: bool = False
1161
+ #: tan-cli#1509: `(slice id, resolved .bin, slot0 address)` of the other slices a
1162
+ #: combined Flow D write also loads, ahead of the single ATOC. Empty = today's plan.
1163
+ extra_apps: tuple[tuple[str, str, str], ...] = ()
1164
+
1165
+
1166
+ #: The one place the confirm gate's remedy is written (tan-cli#719). Three
1167
+ #: sites used to compose their own "not run" note and only ONE of them named
1168
+ #: `ALP_FLASH_FORCE=1` -- the mechanism that actually works -- so the other two
1169
+ #: pointed the reader at a manifest key and stayed silent about the env var.
1170
+ #: Most-specific-first, matching the SETOOLS resolution message's shape.
1171
+ def confirm_remedy(action: str = "actually flash") -> str:
1172
+ """The remedy sentence, with `action` naming what the gate holds back
1173
+ (`tan model run --device` passes "run on the device", tan-cli#1417)."""
1174
+ return (
1175
+ f"to {action}, most-specific first: `--confirm` on the command line, "
1176
+ "`ALP_FLASH_FORCE=1` in the environment, or `flash_args.confirm: true` in "
1177
+ "the manifest"
1178
+ )
1179
+
1180
+
1181
+ CONFIRM_REMEDY = confirm_remedy()
1182
+
1183
+
1184
+ def confirm_gate_note(why: str, action: str = "actually flash") -> str:
1185
+ """The parenthetical every confirm-gated preview appends.
1186
+
1187
+ `why` is `dry-run` (an explicit preview, nothing to remedy) or the
1188
+ confirm-gate reason, which carries `CONFIRM_REMEDY`.
1189
+ """
1190
+ if why == "dry-run":
1191
+ return why
1192
+ return f"{why} -- {confirm_remedy(action)}"
1193
+
1194
+
1195
+ def backend_for(method: str) -> BackendMeta | None:
1196
+ """Resolve a `flash_method` string to its backend metadata, or `None`."""
1197
+ return _REGISTRY.get(method)
1198
+
1199
+
1200
+ def registry_keys() -> list[str]:
1201
+ """The registered method names, sorted -- for the "Available: ..." error."""
1202
+ return sorted(_REGISTRY)
1203
+
1204
+
1205
+ def registry_keys_debug() -> str:
1206
+ """`{:?}` of a `Vec<&str>`, for the unknown-method message."""
1207
+ return _str_list_debug(registry_keys())
1208
+
1209
+
1210
+ def _str_list_debug(items) -> str:
1211
+ return "[" + ", ".join(_quoted(i) for i in items) + "]"
1212
+
1213
+
1214
+ # ── J-Link Commander script quoting (shared by Flow D) ──────────────────────
1215
+
1216
+
1217
+ def commander_path(path: str) -> str:
1218
+ """A path as it should be interpolated into a J-Link Commander script
1219
+ line -- quoted when it CONTAINS whitespace, unchanged otherwise
1220
+ (tan-cli#369). SEGGER's Commander splits an unquoted line on whitespace,
1221
+ so an unquoted `loadbin C:\\Program Files\\alif\\setools\\build\\
1222
+ AppTocPackage.bin, <address>` silently truncates to `C:\\Program` --
1223
+ `-ExitOnError 1` turns that into a loud SEGGER parse error rather than a
1224
+ mis-write, which is the only reason it was not a blocker. tan generates
1225
+ every Commander script now, so tan owns making its own filenames parse
1226
+ back correctly. Quoting is CONDITIONAL, not unconditional, so the
1227
+ overwhelmingly common no-space path -- every already-measured
1228
+ oracle/bench script -- renders byte-identical to before this fix.
1229
+ """
1230
+ return f'"{path}"' if any(c.isspace() for c in path) else path
1231
+
1232
+
1233
+ def _default(value, fallback):
1234
+ """`Option::unwrap_or`. Spelled out because `value or fallback` is WRONG for
1235
+ every falsy-but-present value this module reads -- `reset: false`,
1236
+ `jlink_speed` legitimately absent-as-0, `interface: ""`."""
1237
+ return fallback if value is None else value
1238
+
1239
+
1240
+ # ── zephyr_west_flash / baremetal_cmake_flash ───────────────────────────────
1241
+
1242
+
1243
+ def zephyr_build_dir(artefact: str) -> str:
1244
+ """The Zephyr build dir derived from the artefact: `parent.parent` when the
1245
+ artefact sits directly in a `zephyr/` subdirectory, else `parent`.
1246
+
1247
+ Checks the PARENT DIRECTORY NAME, never the artefact's basename: an
1248
+ MCUboot-signed (`zephyr.signed.hex`) or sysbuild (`merged.hex`) output still
1249
+ lands in `<build_dir>/zephyr/` under a different name, and a basename
1250
+ allowlist sent those one directory too deep -- `west flash --build-dir
1251
+ <that>` then failed with no CMakeCache.txt there.
1252
+
1253
+ `os.path.dirname`, not `Path.parent`: it slices the string and preserves
1254
+ whatever separators the joined path already mixes (a native `build_root` +
1255
+ a `/`-authored manifest artefact), exactly as Rust's `Path::parent` does.
1256
+ """
1257
+ parent = os.path.dirname(artefact)
1258
+ if os.path.basename(parent).lower() == "zephyr":
1259
+ return os.path.dirname(parent)
1260
+ return parent
1261
+
1262
+
1263
+ def zephyr_west_build_dir(flash_args: Any, artefact: str) -> str:
1264
+ """The `--build-dir` `plan_zephyr_west_flash` hands `west flash`:
1265
+ `flash_args.build_dir`, else derived from the artefact. One definition,
1266
+ shared with `tan.commands.flash_atoc_guard`, which reads the runner's
1267
+ verdict out of this same directory (tan-cli#1267)."""
1268
+ return _default(fa_str(flash_args, "build_dir"), zephyr_build_dir(artefact))
1269
+
1270
+
1271
+ def plan_zephyr_west_flash(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
1272
+ """`zephyr_west_flash`: `west flash --build-dir <d> [--runner <r>] [--erase]
1273
+ [--hex-file <h>]`.
1274
+
1275
+ `runner` is OPTIONAL -- when absent, `--runner` is omitted and `west flash`
1276
+ falls back to the board.cmake default runner (on an AEN board that is
1277
+ `alif_flash`, i.e. Flow A over the SE-UART). `--replace-atoc` is NOT added
1278
+ here: whether it applies depends on the build's runner and the bound SDK's
1279
+ runner source, both IO, so the caller appends it (tan-cli#1267,
1280
+ `tan.core.atoc_guard`).
1281
+ """
1282
+ del which # this backend probes nothing
1283
+ fa = inp.flash_args
1284
+ runner = fa_str(fa, "runner")
1285
+ build_dir = zephyr_west_build_dir(fa, inp.artefact)
1286
+ argv = ["west", "flash", "--build-dir", build_dir]
1287
+ if runner is not None:
1288
+ argv += ["--runner", runner]
1289
+ if _default(fa_bool_checked(fa, "erase"), False):
1290
+ argv.append("--erase")
1291
+ hex_file = fa_str(fa, "hex_file")
1292
+ if hex_file is not None:
1293
+ argv += ["--hex-file", hex_file]
1294
+ return FlashPlan(
1295
+ argv=tuple(argv),
1296
+ ok_message=(
1297
+ f"zephyr_west_flash[{inp.core_id}]: programmed via "
1298
+ f"{runner if runner is not None else 'board-default runner'}"
1299
+ ),
1300
+ )
1301
+
1302
+
1303
+ def plan_baremetal_cmake_flash(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
1304
+ """`baremetal_cmake_flash`: `cmake --build <d> --target <t> [--config <c>] [-j N]`."""
1305
+ del which
1306
+ fa = inp.flash_args
1307
+ build_dir = _default(fa_str(fa, "build_dir"), os.path.dirname(inp.artefact))
1308
+ target = _default(fa_str(fa, "target"), "flash")
1309
+ argv = ["cmake", "--build", build_dir, "--target", target]
1310
+ config = fa_str(fa, "config")
1311
+ if config is not None:
1312
+ argv += ["--config", config]
1313
+ jobs = fa_int_checked(fa, "jobs")
1314
+ if jobs is not None:
1315
+ argv += ["-j", str(jobs)]
1316
+ return FlashPlan(
1317
+ argv=tuple(argv),
1318
+ ok_message=f"baremetal_cmake_flash[{inp.core_id}]: target `{target}` ok",
1319
+ )
1320
+
1321
+
1322
+ # ── storage backends ────────────────────────────────────────────────────────
1323
+
1324
+ PIPE = "|"
1325
+
1326
+ #: The directory a `yocto_wic` flash target must resolve beneath (tan-cli#487,
1327
+ #: porting alp-sdk's `_DEV_ROOT` -- security fix 3aa65cd7 / #1112, the half of
1328
+ #: that commit tan-cli#486's sibling-hardening pass dropped). A module
1329
+ #: constant, not a literal in the check, so a test can exercise the real
1330
+ #: resolution logic without touching a host's actual `/dev`.
1331
+ _DEV_ROOT = "/dev"
1332
+
1333
+ #: The two registry keys `plan_yocto_wic` answers for -- exported so
1334
+ #: `tan.commands.flash_cmd` can scope its write-time block-device gate
1335
+ #: (tan-cli#487, see that module's `_yocto_wic_block_device_refusal`) to
1336
+ #: exactly this backend without re-typing the strings.
1337
+ YOCTO_WIC_METHODS = ("yocto_wic_to_sd_or_emmc", "yocto_wic")
1338
+
1339
+ #: Suffixes `plan_yocto_wic` RECOGNISES as a real compression codec it
1340
+ #: cannot decompress (tan-cli#487, defect 2, shape 2) -- named verbatim from
1341
+ #: the issue's own examples. Deliberately NOT "every suffix that isn't gz/
1342
+ #: xz/wic": an unrecognised suffix might be a customer's own naming
1343
+ #: convention for a genuinely uncompressed artefact, and refusing THAT would
1344
+ #: trade a silent raw-dd for a false-positive refusal on a legitimate plain
1345
+ #: `.wic`.
1346
+ _KNOWN_UNSUPPORTED_COMPRESSION_SUFFIXES = ("zst", "bz2", "lzo")
1347
+
1348
+
1349
+ def _resolve_dev_root(target: str) -> str | None:
1350
+ """LEXICAL canonicalization of `target`: `posixpath.normpath` on a
1351
+ POSIX-normalized copy of the string -- collapses a `..` traversal
1352
+ (`/dev/../home/u/x`) on every host, including Windows, where
1353
+ `pathlib`/`os.path` would instead reinterpret the string as an NT path.
1354
+ Pure -- no filesystem access -- so safe to run unconditionally, including
1355
+ for a `--dry-run` preview of a target that is not plugged in yet.
1356
+
1357
+ Returns the normalized path when it resolves at or beneath `_DEV_ROOT`,
1358
+ else `None`. The caller keeps using the ORIGINAL `target` string for the
1359
+ refusal message and for the argv actually spawned -- never this return
1360
+ value -- so a target that is itself a symlink (`/dev/by-id/mmc-foo`)
1361
+ still reaches `dd`/`bmaptool` under the name the caller asked for.
1362
+
1363
+ Ported from alp-sdk's `_resolve_dev_root` (`scripts/flash_backends/
1364
+ yocto_wic.py`) MINUS its real-filesystem symlink-chase layer: this
1365
+ module is pure by convention (see its own docstring's "NO IO"), so that
1366
+ half lives in `tan.commands.flash_cmd`'s IO side instead, as a SEPARATE,
1367
+ write-time-only block-device `stat` gate
1368
+ (`_yocto_wic_block_device_refusal`) -- see that function's docstring for
1369
+ why, and for the one shape this lexical-only layer alone cannot catch (a
1370
+ real, existing regular file lexically living under a real `/dev/`
1371
+ subtree, e.g. `/dev/shm/<name>`)."""
1372
+ normalized = posixpath.normpath(target.replace(os.sep, "/") if os.sep != "/" else target)
1373
+ root = _DEV_ROOT.rstrip("/")
1374
+ # Strictly BENEATH the root, not equal to it -- `_DEV_ROOT` itself is not
1375
+ # a device (matches the old bare `startswith("/dev/")` check, which also
1376
+ # rejected the bare string `"/dev"`: it has no trailing slash to match).
1377
+ if normalized.startswith(root + "/"):
1378
+ return normalized
1379
+ return None
1380
+
1381
+
1382
+ def plan_yocto_wic(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
1383
+ """`yocto_wic_to_sd_or_emmc` / `yocto_wic`: bmaptool (preferred) or dd to a
1384
+ raw `/dev/` block device. Compressed images pipe `gunzip`/`xz` into `dd`.
1385
+ Planning-only unless the confirm gate is armed."""
1386
+ fa = inp.flash_args
1387
+ target = fa_str(fa, "target")
1388
+ if target is None:
1389
+ raise FlashPlanError("yocto_wic: flash_args.target is required (e.g. /dev/sdb)")
1390
+ if _resolve_dev_root(target) is None:
1391
+ raise FlashPlanError(
1392
+ f"yocto_wic: refusing target '{target}' -- must start with /dev/ to avoid "
1393
+ "clobbering a regular file. Set flash_args.target to a real block device."
1394
+ )
1395
+ artefact = inp.artefact
1396
+ compress = fa_str(fa, "compress")
1397
+ suffix = os.path.splitext(artefact)[1].lstrip(".").lower()
1398
+ confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False)
1399
+ planning_only = inp.dry_run or not confirm
1400
+
1401
+ bmaptool = which("bmaptool")
1402
+ dd = which("dd")
1403
+ if bmaptool or (planning_only and not dd):
1404
+ # tan-cli#487 review finding 2: `compress` is a `dd`-fallback-only
1405
+ # concern -- bmaptool decompresses natively and never reads it -- so
1406
+ # the vocabulary refusals below must not fire here. Before this fix
1407
+ # they ran unconditionally, ahead of tool selection, so a stock Yocto
1408
+ # `IMAGE_FSTYPES` artefact like `core-image.wic.zst` hard-refused a
1409
+ # `bmaptool copy core-image.wic.zst /dev/sdb` even on a bmap-tools
1410
+ # host -- a live regression on the arm this module's own docstring
1411
+ # calls "preferred".
1412
+ argv: tuple[str, ...] = ("bmaptool", "copy", artefact, target)
1413
+ elif dd:
1414
+ # `compress` is validated HERE, not before tool selection -- see the
1415
+ # bmaptool arm above. The suffix-refusal message below does NOT
1416
+ # suggest `flash_args.compress` as a fix for an UNSUPPORTED codec:
1417
+ # that used to send the operator straight into the compress-value
1418
+ # refusal a few lines down (the exact same codec, now explicit,
1419
+ # is still not "gz"|"xz") -- a dead-end loop tan-cli#487's review
1420
+ # caught.
1421
+ if compress is None:
1422
+ if suffix in ("gz", "xz"):
1423
+ compress = suffix
1424
+ elif suffix in _KNOWN_UNSUPPORTED_COMPRESSION_SUFFIXES:
1425
+ # tan-cli#487, defect 2, shape 2: no `compress` key at all,
1426
+ # but a suffix (`.wic.zst`, `.wic.bz2`, `.wic.lzo`) this
1427
+ # backend RECOGNISES as a real codec it just cannot
1428
+ # decompress via `dd` -- the old auto-detect missed it (only
1429
+ # "gz"/"xz" are recognised), so `compress` silently resolved
1430
+ # to `None`, which reads as "genuinely uncompressed" and
1431
+ # raw-`dd`s the compressed stream. An UNRECOGNISED suffix
1432
+ # (anything else) is left alone -- there is no way to tell
1433
+ # "genuinely uncompressed" from "a codec this list simply
1434
+ # does not know about" for those, and guessing wrong in THAT
1435
+ # direction would refuse a legitimate plain `.wic`.
1436
+ raise FlashPlanError(
1437
+ f"yocto_wic: artefact suffix '.{suffix}' looks compressed but "
1438
+ 'is not supported by the dd fallback -- the vocabulary is '
1439
+ '"gz" | "xz". Decompress the artefact first, or install '
1440
+ "bmaptool (preferred -- it decompresses natively)."
1441
+ )
1442
+ # else: no `compress` key and an unrecognised/absent suffix --
1443
+ # genuinely uncompressed, `compress` stays `None`.
1444
+ elif compress not in ("gz", "xz"):
1445
+ # tan-cli#487, defect 2, shape 1 (an explicit out-of-vocabulary
1446
+ # `compress`) and shape 3 (that same bad explicit value SHADOWING
1447
+ # a suffix that would have auto-detected correctly -- a genuinely
1448
+ # `.wic.gz` artefact with `compress: zst` never even reaches the
1449
+ # `if compress is None` branch above, so the bad explicit value
1450
+ # wins and the auto-detect that WOULD have been right never
1451
+ # runs). Both used to fall through to the `else` branch below and
1452
+ # raw-`dd` the still-compressed stream onto the block device --
1453
+ # silently, `ok:true`, exit 0, and the board then silently does
1454
+ # not boot. That `else` branch is the CORRECT path for a
1455
+ # genuinely uncompressed `.wic`; it must never be reached for a
1456
+ # value the manifest actually SET to something this backend
1457
+ # cannot decompress. The documented vocabulary is "gz" | "xz" |
1458
+ # None (alp-sdk `scripts/flash_backends/yocto_wic.py:46`).
1459
+ raise FlashPlanError(
1460
+ f"yocto_wic: flash_args.compress '{compress}' is not supported -- the "
1461
+ 'vocabulary is "gz" | "xz" (omit the key to auto-detect from the '
1462
+ "artefact suffix instead)."
1463
+ )
1464
+ bs = _default(fa_str(fa, "bs"), "4M")
1465
+ dd_cmd = ["dd", f"of={target}", f"bs={bs}", "conv=fsync", "status=progress"]
1466
+ if compress == "gz":
1467
+ if which("gunzip"):
1468
+ dcmp = ["gunzip", "-c", artefact]
1469
+ elif which("gzip"):
1470
+ dcmp = ["gzip", "-dc", artefact]
1471
+ else:
1472
+ raise FlashPlanError(
1473
+ "yocto_wic: compressed .wic.gz fallback needs `gunzip` or `gzip` on PATH."
1474
+ )
1475
+ argv = tuple([*dcmp, PIPE, *dd_cmd])
1476
+ elif compress == "xz":
1477
+ if not which("xz"):
1478
+ raise FlashPlanError(
1479
+ "yocto_wic: compressed .wic.xz fallback needs `xz` on PATH."
1480
+ )
1481
+ argv = tuple(["xz", "-dc", artefact, PIPE, *dd_cmd])
1482
+ else:
1483
+ argv = (
1484
+ "dd", f"if={artefact}", f"of={target}", f"bs={bs}",
1485
+ "conv=fsync", "status=progress",
1486
+ )
1487
+ else:
1488
+ raise FlashPlanError(
1489
+ "yocto_wic: neither `bmaptool` nor `dd` is on PATH; install bmaptool "
1490
+ "(preferred -- sparse aware) via `apt install bmap-tools` or run on a "
1491
+ "host with coreutils."
1492
+ )
1493
+ return FlashPlan(
1494
+ argv=argv,
1495
+ ok_message=f"yocto_wic[{inp.core_id}]: programmed {target}",
1496
+ planning_only=planning_only,
1497
+ )
1498
+
1499
+
1500
+ def plan_xspi_flashwriter(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
1501
+ """`xspi_flashwriter`: Renesas Flash Writer over SCIF. Planning-only unless
1502
+ confirmed; the confirmed real write is HW-gated and fails today."""
1503
+ del which
1504
+ fa = inp.flash_args
1505
+ partition = _default(fa_str(fa, "flash_partition"), "")
1506
+ if partition not in ("mtd0", "mtd1"):
1507
+ raise FlashPlanError(
1508
+ "xspi_flashwriter: flash_args.flash_partition must be 'mtd0' (bl2) or 'mtd1' (fip)"
1509
+ )
1510
+ port = _default(fa_str(fa, "port"), "<port>")
1511
+ writer = _default(fa_str(fa, "flash_writer"), "<flash_writer.mot>")
1512
+ baud = _default(fa_int_checked(fa, "baud"), 115200)
1513
+ artefact_name = os.path.basename(inp.artefact)
1514
+ argv = (
1515
+ "flash-writer-scif", f"port={port}", f"writer={writer}", f"baud={baud}",
1516
+ f"partition={partition}", f"artefact={artefact_name}",
1517
+ )
1518
+ confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False)
1519
+ if inp.dry_run or not confirm:
1520
+ why = "dry-run" if inp.dry_run else "flash_args.confirm is false"
1521
+ return FlashPlan(
1522
+ argv=argv,
1523
+ ok_message=(
1524
+ f"xspi_flashwriter[{inp.core_id}]: would write {artefact_name} -> xSPI "
1525
+ f"{partition} via Flash Writer on {port} ({confirm_gate_note(why)})"
1526
+ ),
1527
+ planning_only=True,
1528
+ )
1529
+ raise FlashPlanError(
1530
+ "xspi_flashwriter: the real SCIF write is HW-gated and not yet validated on "
1531
+ "silicon (bench shelved). Run with --dry-run; see docs/provisioning.md."
1532
+ )
1533
+
1534
+
1535
+ # ── Flow D: J-Link direct MRAM write ────────────────────────────────────────
1536
+
1537
+ #: The `flash_args` key that ARMS Flow D. Only the part-number device profile
1538
+ #: is required: without it J-Link has no MRAM loader at all, so its presence
1539
+ #: alone is metadata's statement that this silicon has one. `slot0_load_address`
1540
+ #: is NOT an arming key -- `tan/planner/orchestrator.py`/`loader.py` DO emit it
1541
+ #: today (alp-sdk#1374/tan-cli#353) for AEN slot0-XIP cores, but even where
1542
+ #: present it only ever selects the two-blob mramxip SHAPE, an ITCM-overflow
1543
+ #: exception, not whether Flow D applies at all. Requiring it here would leave
1544
+ #: Flow D permanently unarmed for every real AEN entry, which is the bug this
1545
+ #: comment replaces.
1546
+ FLOW_D_KEYS = ("jlink_flash_device",)
1547
+ FLOW_D_METHOD = "alif_mram_jlink"
1548
+
1549
+ #: `jlink_serial`'s `validate_identifier(..., destination=...)` override
1550
+ #: (tan-cli#486 review): it is interpolated into a J-Link Commander
1551
+ #: `SelectEmuBySN` line, never an OpenOCD Tcl script -- the default
1552
+ #: `destination` names the wrong interpreter for this one field.
1553
+ _JLINK_SERIAL_DESTINATION = "a J-Link Commander script line (SelectEmuBySN)"
1554
+
1555
+
1556
+ def flow_d_available(flash_args: Any) -> bool:
1557
+ """Whether the manifest armed Flow D for this entry, i.e. supplied every
1558
+ key in `FLOW_D_KEYS`. Purely a data question -- see `select_flash_method`.
1559
+
1560
+ KEY PRESENCE, deliberately -- not "resolves to a non-null/non-empty
1561
+ string": an `is not None` check collapses a present-but-null
1562
+ `jlink_flash_device:` (bare YAML null) to "absent" and SILENTLY routes the
1563
+ entry to Flow A over the SE-UART instead, with no diagnostic at all.
1564
+ Transport must never be decided by a quoting detail. Using `_fa_has_key`
1565
+ arms Flow D on presence alone, so a present-but-null/malformed value still
1566
+ reaches `plan_alif_mram_jlink`, which turns it into the loud refusal it
1567
+ already produces for every other malformed Flow D field -- not a silent
1568
+ Flow A fallback. `fa_str_checked` itself only raises on a genuinely
1569
+ malformed (wrong-type) value; for present-but-null it quietly returns
1570
+ `None` same as for absent, so it is `plan_alif_mram_jlink`'s own explicit
1571
+ `_fa_has_key` re-check on that `None` (distinguishing "present but
1572
+ null/empty" from "absent") that decides the present-but-null case, not
1573
+ `fa_str_checked`'s own check.
1574
+ """
1575
+ return all(_fa_has_key(flash_args, key) for key in FLOW_D_KEYS)
1576
+
1577
+
1578
+ def select_flash_method(target: FlashTarget) -> str | None:
1579
+ """The `flash_method` actually dispatched for `target` -- **Flow D by
1580
+ default, Flow A as the fallback.**
1581
+
1582
+ Two host paths put a signed image into MRAM on an Alif Ensemble part. Both
1583
+ need the SETOOLS `app-gen-toc` step to sign the ATOC; they differ only in
1584
+ TRANSPORT, and the transport is the part tan owns:
1585
+
1586
+ * **Flow A** -- `zephyr_west_flash` with no runner, so `west flash` picks the
1587
+ board.cmake default (`alif_flash`) and burns over the SE-UART. Needs a
1588
+ dedicated 1.8 V-capable USB-UART, which the bench runbook calls the #1
1589
+ trap.
1590
+ * **Flow D** -- `alif_mram_jlink`: J-Link straight over SWD, no SE-UART. Same
1591
+ blob(s), same addresses, ~0.16 s, and the bench's day-to-day default
1592
+ (`docs/aen-bench-bringup.md`: "Flow D is the day-to-day default now").
1593
+
1594
+ The switch is made **entirely from data**, never from silicon knowledge: a
1595
+ `zephyr_west_flash` entry whose `flash_args` carries `FLOW_D_KEYS` is
1596
+ dispatched as Flow D instead. tan cannot ask "is this an AEN MRAM part?" --
1597
+ that would put a SKU or an address in tan, which ADR-0017 / I-26 forbid and
1598
+ no gate would catch. What it CAN ask is "does this slice's SoC variant
1599
+ publish a part-number J-Link profile in its `debug:` block?", because
1600
+ that arriving at all IS metadata's statement that this silicon has a
1601
+ J-Link MRAM loader.
1602
+
1603
+ Consequence, stated plainly: `tan/planner/loader.py` already resolves `jlink_flash_device`
1604
+ (and, where present, the paired `expect_dpidr`/`jlink_device` preflight) from the SoC variant's
1605
+ `debug:` block -- selected via the SoM preset's `silicon_variant` -- and
1606
+ `tan/planner/orchestrator.py::_slice_flash_recipe` copies them onto the emitted `flash_args`.
1607
+ `slot0_load_address` rides alongside them in the same `args` dict but is sourced differently on
1608
+ purpose: `loader.py::_resolve_slot0_load_address` reads it from the SoM preset's `memory_map:`,
1609
+ NOT from that `debug:` block -- it is SDK/module build POLICY, not a silicon fact
1610
+ (alp-sdk#1069), so two SoMs on the same part can pick different slot0 windows. So a Zephyr
1611
+ slice on a SoC variant that publishes a part-number J-Link profile already carries
1612
+ `FLOW_D_KEYS` on emit and dispatches to Flow D, not Flow A. A slice on a variant with no such
1613
+ profile still emits `args={}` and stays on Flow A, which is correct: that silicon has no J-Link
1614
+ MRAM loader for tan to arm. That used to be most of today's shipped Alif metadata; it no longer
1615
+ is. Measured against alp-sdk `origin/dev` on 2026-09-13: all 10 published Ensemble variants
1616
+ (`metadata/socs/alif/ensemble/e3..e8.json`) carry `jlink_flash_device`, and 1 of those also
1617
+ carries `expect_dpidr` -- a fact about what's published today, not an invariant of the emitter
1618
+ itself. It is also the fact that sizes the gap `docs/setools.md` records under tan-cli#1252:
1619
+ a planner-emitted AEN manifest dispatches Flow D and is covered by the whole-ATOC
1620
+ acknowledgement, so what reaches an ATOC over Flow A is a hand-written or legacy manifest --
1621
+ not the published catalogue, and guarded by the runner's own check (`tan.core.atoc_guard`).
1622
+ """
1623
+ method = target.flash_method or None
1624
+ if method == "zephyr_west_flash" and flow_d_available(target.flash_args):
1625
+ return FLOW_D_METHOD
1626
+ return method
1627
+
1628
+
1629
+ def parse_atoc_start_address(text: str) -> str | None:
1630
+ """The ATOC package's MRAM placement out of an `app-gen-toc`
1631
+ `app-package-map.txt` report -- the LAST `APP Package Start Address:`
1632
+ line's last field, mirroring every bench script's own
1633
+ ``awk '/APP Package Start Address:/{print $NF}' app-package-map.txt | tail
1634
+ -1`` byte for byte (last match wins: a re-signed re-run APPENDS a fresh
1635
+ block rather than truncating the file, per
1636
+ `scripts/bench/aen/flash-jlink.sh`/`flash-jlink-mramxip.sh`/
1637
+ `flash-update-log-dual.sh`). `None` when the marker never appears -- an
1638
+ empty, foreign or not-yet-signed file, not a malformed one; the caller
1639
+ decides what that means.
1640
+
1641
+ **tan-cli#373.** `tan.core.setools.sign_slot0` -- the one place this repo
1642
+ WRITES that report -- never deletes it for exactly this reason: an
1643
+ earlier version did, which destroyed every prior entry (this SETOOLS
1644
+ install's whole accumulated sign history) the moment a soft-failing
1645
+ re-sign recreated the file holding only its own block.
1646
+
1647
+ **This is a BUILD-TIME output, never plan-time metadata.** `app-gen-toc`
1648
+ writes the address fresh at signing time and the runbook says outright it
1649
+ SHIFTS per build/config -- no field under `metadata/**` can express it, so
1650
+ parsing this report is the only correct source. See `plan_alif_mram_jlink`
1651
+ for the required/optional split this feeds; the actual file read happens
1652
+ in `tan.commands.flash_cmd` (IO), never here.
1653
+ """
1654
+ address: str | None = None
1655
+ for line in text.splitlines():
1656
+ if "APP Package Start Address:" not in line:
1657
+ continue
1658
+ fields = line.split()
1659
+ if fields:
1660
+ address = fields[-1]
1661
+ return address
1662
+
1663
+
1664
+ def is_elf_artefact(artefact: str) -> bool:
1665
+ """Extension-based, mirroring `is_raw_bin`'s own convention: no extension,
1666
+ `.elf`, or `.out` (case-insensitive) -- the three "plausibly ELF" shapes
1667
+ #367(a) named and #353 agreed are safe to resolve automatically to a
1668
+ same-stem sibling `.bin`. Covers the Zephyr build's own known-good
1669
+ `zephyr.elf`/`zephyr.bin` pair AND a toolchain output named bare (`app`)
1670
+ or `.out` with no `.elf` suffix. Every other shape (a `.hex` carries its
1671
+ own load addresses) is NOT, even when a same-stem `.bin` happens to sit
1672
+ beside it: that could be an unrelated image, and resolving it silently
1673
+ would flash something the manifest never named.
1674
+
1675
+ **tan-cli#373.** A prior version of this function accepted `.elf` only --
1676
+ a narrowing of #367(a)'s own three-shape decision that nothing flagged,
1677
+ since no test exercised the other two.
1678
+ """
1679
+ return os.path.splitext(artefact)[1].lower() in ("", ".elf", ".out")
1680
+
1681
+
1682
+ def resolve_slot0_binary(artefact: str, is_file: Callable[[str], bool]) -> str | None:
1683
+ """The ONE definition of "what raw `.bin` does this slot0 write/sign
1684
+ actually mean" -- shared by [`plan_alif_mram_jlink`] (the `loadbin`/
1685
+ `verifybin` pair) and `tan.commands.flash_cmd
1686
+ ._resolve_flow_d_atoc_via_setools` (the SETOOLS `app-gen-toc` sign
1687
+ input), via [`validate_flow_d_shape`], so the two can never resolve
1688
+ DIFFERENT files for the same entry. **#367's root cause**: they used to
1689
+ each carry their own copy of this logic, and neither actually applied the
1690
+ ELF-only restriction its own comment/tests claimed -- a `.hex` resolved
1691
+ to a same-stem sibling `.bin` exactly like an ELF did, silently flashing
1692
+ a different artefact than the manifest named.
1693
+
1694
+ Already a raw `.bin` -> returned unchanged. A plausibly-ELF artefact
1695
+ ([`is_elf_artefact`]: no extension, `.elf`, `.out`) with a real
1696
+ same-directory, same-stem `.bin` -> that sibling. Every other shape -- a
1697
+ `.hex`, a plausibly-ELF artefact with no sibling, anything else -- ->
1698
+ `None`; the caller decides how to phrase the refusal.
1699
+ """
1700
+ if is_raw_bin(artefact):
1701
+ return artefact
1702
+ if not is_elf_artefact(artefact):
1703
+ return None
1704
+ sibling = os.path.splitext(artefact)[0] + ".bin"
1705
+ return sibling if is_file(sibling) else None
1706
+
1707
+
1708
+ #: The manifest spelling of the whole-ATOC acknowledgement (tan-cli#1252).
1709
+ #: Deliberately the SAME word upstream's bench scripts use for the flag
1710
+ #: (`--atoc-unqueryable`), so an operator who has read either one recognises
1711
+ #: the other.
1712
+ ATOC_ACK_KEY = "atoc_unqueryable"
1713
+
1714
+ #: The command-line spelling of the same acknowledgement. EXACTLY two spellings
1715
+ #: exist -- this flag and `flash_args.atoc_unqueryable` -- and see
1716
+ #: [`atoc_replacement_acknowledged`] for why there is deliberately no third.
1717
+ ATOC_ACK_FLAG = "--atoc-unqueryable"
1718
+
1719
+
1720
+ def atoc_replacement_acknowledged(fa: Any, cli_flag: bool) -> bool:
1721
+ """Has the operator accepted that this Flow D write REPLACES the whole
1722
+ ATOC (tan-cli#1252, porting alp-sdk#2025)? Pure.
1723
+
1724
+ Two spellings, OR-ed: `--atoc-unqueryable` on the command line (`cli_flag`)
1725
+ and `flash_args.atoc_unqueryable: true` in the manifest.
1726
+
1727
+ **Deliberately NO environment variable**, unlike `ALP_FLASH_FORCE` /
1728
+ `ALP_FLASH_REQUIRE_DPIDR` next to it in `flash_cmd`, whose obvious shape
1729
+ invites a third one for symmetry. An env var is exported ONCE into a shell
1730
+ profile or a CI job and then silences this guard forever, on every
1731
+ subsequent run, including the runs nobody is watching -- which is exactly
1732
+ the habit alp-sdk#2025's own header warns about when it says the Flow D
1733
+ flag must never be merged or aliased with Flow A's `--replace-atoc`. The
1734
+ acknowledgement is a statement about THIS write's ATOC, not a property of
1735
+ the host, so it has to be re-stated per invocation (the flag) or recorded
1736
+ against the specific manifest that knows its own boot layout (the key).
1737
+ That is the opposite trade from `ALP_FLASH_REQUIRE_DPIDR`, which IS a
1738
+ property of the host and is therefore an env var on purpose -- see
1739
+ `flash_cmd._require_dpidr_refusal`.
1740
+
1741
+ **Not the confirm gate, and never aliased to it.** `--confirm` means "yes,
1742
+ write"; this means "yes, I accept that the entire ATOC is replaced". A run
1743
+ can legitimately be confirmed and still refuse here.
1744
+
1745
+ `_default(fa_bool_checked(...), False)` -- the same shape
1746
+ `plan_alif_mram_jlink` reads `confirm` with -- makes an ABSENT key and a
1747
+ present-but-null one both read `False`, i.e. both REFUSE. Fail-safe by
1748
+ construction: no manifest shape can accidentally acknowledge. (A
1749
+ present-but-null value is additionally called out as malformed at plan
1750
+ time by `validate_flow_d_shape`, so it never reaches here silently.)
1751
+ """
1752
+ return cli_flag or _default(fa_bool_checked(fa, ATOC_ACK_KEY), False)
1753
+
1754
+
1755
+ def atoc_replacement_refusal(method: str, entry_id: str) -> str:
1756
+ """The refusal text for a confirmed Flow D write that has not acknowledged
1757
+ the whole-ATOC replacement. Pure.
1758
+
1759
+ Carries upstream's facts (alp-sdk#2025, which made
1760
+ `scripts/bench/aen/flash-jlink.sh`, `flash-jlink-hp.sh` and
1761
+ `flash-jlink-mramxip.sh` refuse with exit 8 for the same reason) in tan's
1762
+ own voice, then the two remedies."""
1763
+ return (
1764
+ f"{method}[{entry_id}]: this write REPLACES the ENTIRE ATOC, and Flow D has no "
1765
+ "SE-UART channel to enumerate what is resident first -- so any boot entry already "
1766
+ "in MRAM that this ATOC does not name (an A32 boot chain, an HP app, a diagnostic "
1767
+ "image) is silently DELISTED, and the SES still prints \"[SES] ATOC ok\" "
1768
+ "afterwards. Refusing until that is acknowledged: pass "
1769
+ f"{ATOC_ACK_FLAG} on the command line, or set flash_args.{ATOC_ACK_KEY}: true in "
1770
+ "the manifest. --confirm does NOT acknowledge this -- it only means \"yes, write\" "
1771
+ "(alp-sdk#2025, tan-cli#1252)."
1772
+ )
1773
+
1774
+
1775
+ #: Appended to every Flow D PREVIEW message (tan-cli#1252). A preview must stay
1776
+ #: a preview -- the refusal above fires only on a write that would really
1777
+ #: proceed -- but the operator has to learn what arming the write means BEFORE
1778
+ #: they arm it, and a preview is the one moment they are reading.
1779
+ ATOC_REPLACEMENT_PREVIEW_NOTE = (
1780
+ "NOTE: a real write REPLACES the ENTIRE ATOC -- Flow D cannot enumerate what is "
1781
+ "resident, so any boot entry this ATOC does not name is silently delisted and the SES "
1782
+ f"still reports \"[SES] ATOC ok\". Arming the write therefore also needs {ATOC_ACK_FLAG} "
1783
+ f"(or flash_args.{ATOC_ACK_KEY}: true) alongside the confirm gate "
1784
+ "(alp-sdk#2025, tan-cli#1252)."
1785
+ )
1786
+
1787
+
1788
+ @dataclass(frozen=True)
1789
+ class FlowDShape:
1790
+ """Everything about a Flow D entry that is knowable WITHOUT `atoc`/
1791
+ `atoc_address` -- i.e. before SETOOLS may need to sign one. `artefact` is
1792
+ already resolved via [`resolve_slot0_binary`] when `app_address` is set;
1793
+ callers must use THIS value, not the raw input artefact, for both the
1794
+ SETOOLS sign input and the final loadbin/verifybin.
1795
+ """
1796
+
1797
+ device: str
1798
+ app_address: str | None
1799
+ artefact: str
1800
+
1801
+
1802
+ def validate_flow_d_shape(fa: Any, artefact: str, is_file: Callable[[str], bool]) -> FlowDShape:
1803
+ """Validate + resolve every Flow D input that does NOT depend on `atoc`/
1804
+ `atoc_address` -- the part-number device profile and, when
1805
+ `slot0_load_address` selects the mramxip shape, the artefact itself.
1806
+
1807
+ **The single source `tan.commands.flash_cmd._flash_entry` now calls
1808
+ BEFORE ever considering a SETOOLS auto-sign or a `--dry-run` preview
1809
+ (#366).** `atoc`/`atoc_address` are legitimately still absent at that
1810
+ point -- SETOOLS is what is about to produce them -- so this cannot
1811
+ validate the WHOLE entry; splitting out exactly the half that CAN be
1812
+ checked early means a manifest that would fail here can no longer report
1813
+ `ok:true` on a SETOOLS `--dry-run` preview just because the failure used
1814
+ to live only in the (until-then unreached) second half of
1815
+ `plan_alif_mram_jlink`.
1816
+
1817
+ Also called BY `plan_alif_mram_jlink` itself -- there remains exactly ONE
1818
+ implementation of these checks; calling it twice on the real-write path
1819
+ is pure/side-effect-free, so the repeat costs nothing.
1820
+ """
1821
+ device = fa_str_checked(fa, "jlink_flash_device", False)
1822
+ if device is None:
1823
+ if _fa_has_key(fa, "jlink_flash_device"):
1824
+ raise FlashPlanError(
1825
+ f"{FLOW_D_METHOD}: flash_args.jlink_flash_device is present but "
1826
+ "null/empty -- refusing to write MRAM with no part-number J-Link "
1827
+ "device profile; the generic profile has none. It is a per-variant "
1828
+ "metadata fact (socs/**/*.json `variants[].debug.jlink_flash_device`); "
1829
+ "tan does not guess a part number."
1830
+ )
1831
+ raise FlashPlanError(
1832
+ f"{FLOW_D_METHOD}: flash_args.jlink_flash_device is required -- only the "
1833
+ "part-number J-Link device profile unlocks the MRAM loader, and the "
1834
+ "generic profile has none. It is a per-variant metadata fact "
1835
+ "(socs/**/*.json `variants[].debug.jlink_flash_device`); tan does not "
1836
+ "guess a part number."
1837
+ )
1838
+ validate_identifier(device, "jlink_flash_device")
1839
+ # OPTIONAL -- selects the mramxip two-blob shape when present; the default
1840
+ # single-ATOC-blob shape (flash-jlink.sh) needs no app-address write at
1841
+ # all, since the ATOC embeds the app. `None` only for genuinely absent; a
1842
+ # present-but-malformed value still raises below, never silently reverts
1843
+ # to the default shape.
1844
+ #
1845
+ # `fa_str_checked` alone cannot tell "key absent" from "key present with a
1846
+ # null/empty-string value" -- both collapse to `None` (`raw or None` at
1847
+ # line ~571). A key that IS present must still refuse when it resolves to
1848
+ # `None`: a `slot0_load_address: ""` or a bare `slot0_load_address:` (YAML
1849
+ # null) must never silently pick the default shape, exactly like any other
1850
+ # malformed value.
1851
+ app_address = fa_str_checked(fa, "slot0_load_address", True)
1852
+ if app_address is None and _fa_has_key(fa, "slot0_load_address"):
1853
+ raise FlashPlanError(
1854
+ f"{FLOW_D_METHOD}: flash_args.slot0_load_address is present but "
1855
+ "null/empty -- refusing to silently select the default "
1856
+ "single-ATOC-blob shape. Remove the key entirely to use the default "
1857
+ "shape, or supply the app's real MRAM address to select the mramxip "
1858
+ "two-blob shape."
1859
+ )
1860
+ resolved_artefact = artefact
1861
+ if app_address is not None:
1862
+ validate_address(app_address, "slot0_load_address")
1863
+ # The mramxip shape `loadbin`s the app blob at an explicit MRAM
1864
+ # address -- correct ONLY for a raw `.bin`. `loadbin`ing anything else
1865
+ # (e.g. `zephyr.elf`) at that address writes the artefact's own
1866
+ # headers into MRAM instead of the app image (tan-cli#311). There is no
1867
+ # ELF/HEX fallback to `loadfile` here: it ignores `slot0_load_address` entirely,
1868
+ # which would silently place the app wherever the ELF's own load
1869
+ # addresses say rather than where this flow demands -- a refusal is
1870
+ # the safer failure. tan-cli#353 resolves a real ELF/sibling-`.bin`
1871
+ # pair rather than refusing over something resolvable;
1872
+ # [`resolve_slot0_binary`] is the ONE place that decides which shapes
1873
+ # qualify (#367).
1874
+ resolved = resolve_slot0_binary(artefact, is_file)
1875
+ if resolved is None:
1876
+ if is_elf_artefact(artefact):
1877
+ detail = (
1878
+ "No sibling "
1879
+ f"{os.path.basename(os.path.splitext(artefact)[0] + '.bin')} "
1880
+ "was found beside it either."
1881
+ )
1882
+ else:
1883
+ detail = (
1884
+ "Only a plausibly-ELF artefact's (no extension, .elf, .out) "
1885
+ "same-stem sibling .bin is resolved automatically -- a .hex "
1886
+ "(or any other shape) is not, even with a same-stem .bin "
1887
+ "beside it, since that could be an unrelated image."
1888
+ )
1889
+ raise FlashPlanError(
1890
+ f"{FLOW_D_METHOD}: flash_args.slot0_load_address is set but the "
1891
+ f"artefact {artefact} is not a raw .bin -- refusing to loadbin "
1892
+ "it at slot0_load_address, which would write the artefact's own "
1893
+ f"headers into MRAM instead of the app image. {detail} Point the "
1894
+ "build's output_artefact at the slot0-linked zephyr.bin for the "
1895
+ "mramxip shape."
1896
+ )
1897
+ resolved_artefact = resolved
1898
+ # tan-cli#373: `jlink_speed`/`confirm` do not depend on `atoc`/`atoc_address`
1899
+ # either, so they belong in the "everything checkable early" half this
1900
+ # function IS -- but #366 only moved `jlink_flash_device`/
1901
+ # `slot0_load_address` here, leaving these two still validated only deep
1902
+ # inside `plan_alif_mram_jlink`. That left #366's own fix narrowed, not
1903
+ # closed: `_resolve_flow_d_atoc_via_setools`'s `--dry-run` preview short-
1904
+ # circuits BEFORE `plan_alif_mram_jlink` ever runs (measured: a manifest
1905
+ # with `jlink_speed: "fast"` and SETOOLS resolving reported `ok:true`
1906
+ # under `--dry-run`), and on a REAL (confirmed) run the SETOOLS auto-sign
1907
+ # itself -- spawning `app-gen-toc`, writing into the customer's install --
1908
+ # happens before `plan_alif_mram_jlink` ever gets a chance to refuse.
1909
+ # Validating (and discarding the result -- `plan_alif_mram_jlink` still
1910
+ # applies its own default) here closes that gap regardless of which path
1911
+ # the entry takes afterward.
1912
+ fa_int_checked(fa, "jlink_speed")
1913
+ fa_bool_checked(fa, "confirm")
1914
+ # tan-cli#1252: the whole-ATOC acknowledgement is the same class of key as
1915
+ # the two above -- it does not depend on `atoc`/`atoc_address` either -- so
1916
+ # its SHAPE is validated (and the result discarded; the guard reads it
1917
+ # again at the one place that decides) here, for the reason the #373
1918
+ # comment above gives: otherwise `atoc_unqueryable: "yes"` would report
1919
+ # `ok:true` from `tan flash --dry-run` and, on a real run, only be refused
1920
+ # AFTER the SETOOLS auto-sign had already written into the customer's
1921
+ # install. `fa_bool_checked` raises for every non-bool shape.
1922
+ #
1923
+ # The present-but-NULL case needs the explicit `_fa_has_key` pairing, the
1924
+ # same way `slot0_load_address` above does: `fa_bool_checked` collapses a
1925
+ # bare `atoc_unqueryable:` and an absent key to the same `None`. Both
1926
+ # REFUSE the write either way -- `atoc_replacement_acknowledged` is
1927
+ # fail-safe -- but an operator who TRIED to acknowledge and mistyped it
1928
+ # deserves to be told the value is malformed, at plan time, rather than
1929
+ # reading a refusal that says they never acknowledged at all.
1930
+ if _fa_has_key(fa, ATOC_ACK_KEY) and fa_bool_checked(fa, ATOC_ACK_KEY) is None:
1931
+ raise FlashPlanError(
1932
+ f"{FLOW_D_METHOD}: flash_args.{ATOC_ACK_KEY} is present but null/empty -- "
1933
+ "refusing to read that as a whole-ATOC acknowledgement, and refusing to "
1934
+ f"read it as an absent one. Write `{ATOC_ACK_KEY}: true` (a bare, unquoted "
1935
+ "boolean) to acknowledge that this write replaces the entire ATOC, or "
1936
+ "remove the key entirely (tan-cli#1252)."
1937
+ )
1938
+ # tan-cli#486 review: `jlink_serial` is the same class of "checkable
1939
+ # early" field as `jlink_speed`/`confirm` just above -- it does not
1940
+ # depend on `atoc`/`atoc_address` either -- but was left validated only
1941
+ # deep inside `plan_alif_mram_jlink`/`flow_d_preflight_script`, which is
1942
+ # exactly the #373 gap those two comments describe: a hostile
1943
+ # `jlink_serial` (e.g. embedded newlines forming extra Commander
1944
+ # commands) reported `ok:true` from `tan flash --dry-run`, and on a real
1945
+ # confirmed run reached the customer's SETOOLS install via
1946
+ # `_resolve_flow_d_atoc_via_setools` -- which runs BEFORE this
1947
+ # function -- before ever being refused. Validating (and discarding the
1948
+ # result, like the two lines above) here closes that gap regardless of
1949
+ # which path the entry takes afterward; the late call sites keep their
1950
+ # own check too, defensively, since it is pure and costs nothing to repeat.
1951
+ serial = fa_str_checked(fa, "jlink_serial", False)
1952
+ if serial is not None:
1953
+ validate_identifier(serial, "jlink_serial", destination=_JLINK_SERIAL_DESTINATION)
1954
+ return FlowDShape(device=device, app_address=app_address, artefact=resolved_artefact)
1955
+
1956
+
1957
+ def plan_alif_mram_jlink(inp: FlashInputs, which: Callable[[str], bool]) -> FlashPlan:
1958
+ """Flow D: burn the signed ATOC into MRAM over SWD with J-Link's built-in
1959
+ Alif MRAM loader, verify it, then PIN-reset so the Secure Enclave boot ROM
1960
+ boots the image -- the same blob(s) at the same addresses SETOOLS writes
1961
+ over the SE-UART, so no re-signing and no keys.
1962
+
1963
+ **Two shapes, selected from data, matching the two bench scripts they
1964
+ port** (`scripts/bench/aen/flash-jlink.sh` / `flash-jlink-mramxip.sh`):
1965
+
1966
+ * **Default -- single ATOC blob.** The day-to-day flow
1967
+ (`flash-jlink.sh`): the ATOC is self-contained (an ITCM-load package,
1968
+ its own embedded load address set by `app-gen-toc`), so ONE
1969
+ `loadbin`/`verifybin` of `atoc` at `atoc_address` is the whole write.
1970
+ This is what runs whenever `flash_args` omits `slot0_load_address`.
1971
+ * **mramxip -- two blobs.** The ITCM-overflow exception
1972
+ (`flash-jlink-mramxip.sh`), for an app LINKED into MRAM slot0 (built
1973
+ with `CONFIG_USE_DT_CODE_PARTITION=y`, a per-app-build opt-in tan does
1974
+ not set): the app blob itself also needs writing, to `slot0_load_address`,
1975
+ ahead of the ATOC. This activates only when `flash_args.slot0_load_address`
1976
+ is present -- tan cannot detect the Kconfig opt-in from here, so a
1977
+ manifest that arms the mramxip shape must supply the address that
1978
+ proves it was built that way.
1979
+
1980
+ **Every identifier is read from `flash_args`; none is baked in.** Required
1981
+ in both shapes:
1982
+
1983
+ * `jlink_flash_device` -- the PART-NUMBER device profile. Only this unlocks
1984
+ the loader; with a generic `Cortex-M55` profile there is no loader and
1985
+ `loadbin` to MRAM does nothing useful. It is also the wrong profile for
1986
+ attaching to a live core, which is why it is a distinct metadata key
1987
+ (`jlink_flash_device`, not `jlink_device`) on the SoC spec.
1988
+ * `atoc` + `atoc_address` -- the signed ATOC blob and its MRAM placement.
1989
+ The address SHIFTS per build/config and the runbook says outright not to
1990
+ hardcode it -- it is a BUILD-TIME output of the signing step, never a
1991
+ metadata fact, so this function still requires it as a plain
1992
+ `flash_args` value and REFUSES when it is absent. tan does NOT run
1993
+ `app-gen-toc` here either way: signing is common to both flows and
1994
+ belongs to whatever produced the ATOC. What changed is only WHO fills
1995
+ `atoc_address` in before this function runs -- `tan.commands.flash_cmd`
1996
+ resolves it from `flash_args.atoc_map` (an `app-package-map.txt` path)
1997
+ via `parse_atoc_start_address` when the manifest supplies that instead
1998
+ of a baked-in address, so a customer's manifest never has to hardcode a
1999
+ value that changes every build. `atoc` itself is read here VERBATIM --
2000
+ this module has no filesystem access to resolve it against -- so
2001
+ `tan.commands.flash_cmd` also anchors it on `build_root`/`sdk_root`
2002
+ (`resolve_artefact_path`, the same resolver `output_artefact`/
2003
+ `atoc_map` use) before this function ever sees it; a caller that skips
2004
+ that step hands this function a path relative to WHATEVER the eventual
2005
+ spawn's cwd turns out to be, not the build root.
2006
+
2007
+ Optional, mramxip-only:
2008
+
2009
+ * `slot0_load_address` -- where the slot0-linked app itself sits, so the SE boots
2010
+ it in place rather than loading it out of the ATOC. Present but
2011
+ malformed is still a loud refusal, never a silent fall-back to the
2012
+ default shape -- a quoting detail must never decide which shape burns.
2013
+
2014
+ Absent a required identifier this REFUSES. There is no default to fall
2015
+ back to: a guessed MRAM address is a write to the wrong place on a part
2016
+ whose Secure Enclave then boots whatever is there.
2017
+
2018
+ Confirm-gated (`flash_args.confirm` OR `ALP_FLASH_FORCE=1`) like the other
2019
+ two persistent-device backends -- see the `planning_only` note below.
2020
+ """
2021
+ fa = inp.flash_args
2022
+ shape = validate_flow_d_shape(fa, inp.artefact, os.path.isfile)
2023
+ device, app_address, artefact = shape.device, shape.app_address, shape.artefact
2024
+
2025
+ atoc = fa_str(fa, "atoc")
2026
+ atoc_address = fa_str_checked(fa, "atoc_address", True)
2027
+ if atoc is None or atoc_address is None:
2028
+ raise FlashPlanError(
2029
+ f"{FLOW_D_METHOD}: flash_args.atoc (the signed ATOC blob) and "
2030
+ "flash_args.atoc_address are both required. Both flows burn the SAME "
2031
+ "signed ATOC -- sign it with the SETOOLS `app-gen-toc` step and pass the "
2032
+ "blob plus the placement its own report prints; the addresses shift per "
2033
+ "build and must not be hardcoded."
2034
+ )
2035
+ validate_address(atoc_address, "atoc_address")
2036
+
2037
+ speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED)
2038
+ # Probe serial: the ONLY disambiguator when a bench carries more than one
2039
+ # J-Link. No default -- a bench-wide serial can be shared by two probes that
2040
+ # differ only by USB path, and a silent default can select the wrong board.
2041
+ # `fa_str_checked`, not the tolerant `fa_str` (tan-cli#486): a NUMERIC
2042
+ # serial -- the canonical SEGGER spelling -- is a bare YAML integer, which
2043
+ # `fa_str` treats as "absent" and silently drops the SelectEmuBySN line
2044
+ # on a bench with more than one probe attached. `validate_identifier`
2045
+ # then closes the same newline-injection hole `jlink_flash_device` above
2046
+ # is already guarded against: `serial` is interpolated verbatim into
2047
+ # `SelectEmuBySN {serial}`, a J-Link Commander script LINE. Also already
2048
+ # validated by `validate_flow_d_shape` above (tan-cli#486 review, #373
2049
+ # gap) -- repeated here defensively; the check is pure and this is the
2050
+ # value actually used below.
2051
+ serial = fa_str_checked(fa, "jlink_serial", False)
2052
+ if serial is not None:
2053
+ validate_identifier(serial, "jlink_serial", destination=_JLINK_SERIAL_DESTINATION)
2054
+ # The expected SW-DP IDR. When the manifest supplies one, the Commander
2055
+ # script connects with the READ profile first and the caller ABORTS unless
2056
+ # that ID appears -- writing MRAM on the wrong attached board is the one
2057
+ # unrecoverable mistake this path can make. A hardware value, so it comes
2058
+ # from data: tan neither knows nor invents an IDR.
2059
+ # tan-cli#795: `as_hex_address=True` -- an unquoted YAML `expect_dpidr:
2060
+ # 0x0BE12477` must round-trip back to `0x0BE12477`, the way `base` /
2061
+ # `atoc_address` / `slot0_load_address` already do, not decay to the
2062
+ # decimal string `199304311` and refuse the CORRECT board.
2063
+ expect_dpidr = fa_str_checked(fa, "expect_dpidr", True)
2064
+ if expect_dpidr is not None:
2065
+ validate_address(expect_dpidr, "expect_dpidr")
2066
+
2067
+ jlink = _JLINK_BINARIES[0] if inp.dry_run else next(
2068
+ (n for n in _JLINK_BINARIES if which(n)), None
2069
+ )
2070
+ if jlink is None:
2071
+ raise FlashPlanError(
2072
+ f"{FLOW_D_METHOD}: needs SEGGER J-Link on PATH (JLinkExe/JLink), on a "
2073
+ "V9.46+ DLL with the probe on matched firmware -- the built-in Alif MRAM "
2074
+ "loader ships with the DLL and older ones cannot connect with the "
2075
+ "part-number device profile."
2076
+ )
2077
+
2078
+ # Two-blob mramxip shape only when `slot0_load_address` armed it; otherwise the
2079
+ # default single-ATOC-blob shape (flash-jlink.sh) writes nothing for the
2080
+ # app -- the ATOC already embeds it. See the docstring's "two shapes" note.
2081
+ lines: list[str] = []
2082
+ if serial is not None:
2083
+ lines.append(f"SelectEmuBySN {serial}")
2084
+ else:
2085
+ # tan-cli#353: no serial pinned, so this script selects no probe. Fine
2086
+ # on a single-probe host; on a bench with several J-Links JLinkExe
2087
+ # cannot choose and answers "Connecting to J-Link ...FAILED: Cannot
2088
+ # connect to the probe/programmer." -- measured on the AEN bench, which
2089
+ # carries three. Recorded here so the failure diagnosis can SAY that
2090
+ # instead of leaving the user with SEGGER's bare sentence; the plan
2091
+ # itself is unchanged, because refusing would break every correct
2092
+ # single-probe host.
2093
+ pass
2094
+ # tan-cli#486: quoting (below) is not escaping -- a newline embedded in
2095
+ # either path still ends the quoted string's own Commander LINE and
2096
+ # starts a new, attacker-chosen one. Validated on the UNQUOTED value,
2097
+ # same as `jlink_commander_script`'s own artefact guard.
2098
+ validate_commander_path(artefact, "the flash artefact path")
2099
+ validate_commander_path(atoc, "flash_args.atoc")
2100
+ # Quoted (tan-cli#369) ONLY for these Commander-script lines, when either
2101
+ # actually contains whitespace -- `artefact`/`atoc` themselves are left
2102
+ # unquoted for `ok_message` and every other use above.
2103
+ commander_artefact = commander_path(artefact)
2104
+ commander_atoc = commander_path(atoc)
2105
+ apps: list[tuple[str, str]] = []
2106
+ if app_address is not None:
2107
+ # `artefact`, not `inp.artefact`: the tan-cli#353 sibling resolution
2108
+ # above may have swapped an ELF for its real raw `.bin`, and the
2109
+ # write must use what was RESOLVED or the guard would be decorative.
2110
+ apps.append((commander_artefact, app_address))
2111
+ for _extra_id, extra_bin, extra_address in inp.extra_apps:
2112
+ validate_commander_path(extra_bin, "the flash artefact path")
2113
+ validate_address(extra_address, "slot0_load_address")
2114
+ apps.append((commander_path(extra_bin), extra_address))
2115
+ lines += ["si SWD", f"speed {speed}", f"device {device}", "connect"]
2116
+ lines += [f"loadbin {path} {addr}" for path, addr in apps]
2117
+ lines.append(f"loadbin {commander_atoc} {atoc_address}")
2118
+ lines += [f"verifybin {path} {addr}" for path, addr in apps]
2119
+ lines += [
2120
+ f"verifybin {commander_atoc} {atoc_address}",
2121
+ # PIN reset (RSetType 2), then run: the Secure Enclave boot ROM re-reads
2122
+ # and boots the ATOC, exactly as it does after an SE-UART burn. A core
2123
+ # reset would leave the SE out of the loop.
2124
+ "RSetType 2",
2125
+ "r",
2126
+ "g",
2127
+ "exit",
2128
+ ]
2129
+ argv = (
2130
+ jlink, "-device", device, "-if", "SWD", "-speed", str(speed),
2131
+ "-ExitOnError", "1", "-NoGui", "1", "-CommanderScript",
2132
+ )
2133
+ confirm = inp.force_confirm or _default(fa_bool_checked(fa, "confirm"), False)
2134
+ ok_message = (
2135
+ f"{FLOW_D_METHOD}[{inp.core_id}]: app -> {app_address}"
2136
+ + "".join(f", {i} app -> {a}" for i, _b, a in inp.extra_apps)
2137
+ + f", signed ATOC -> {atoc_address} via J-Link ({device}); cache-verified and PIN-reset"
2138
+ if app_address is not None
2139
+ else (
2140
+ f"{FLOW_D_METHOD}[{inp.core_id}]: signed ATOC (app embedded) -> "
2141
+ f"{atoc_address} via J-Link ({device}); cache-verified and PIN-reset"
2142
+ )
2143
+ )
2144
+ return FlashPlan(
2145
+ argv=argv,
2146
+ ok_message=ok_message,
2147
+ # `planning_only` -- and therefore the `planned` status + the
2148
+ # `flash.confirm-required` warning -- for an UNCONFIRMED run, matching
2149
+ # `yocto_wic`/`xspi_flashwriter`. This is a NEW backend, so nothing in
2150
+ # the oracle is being diverged from, and it is the only backend in the
2151
+ # registry that persistently programs on-die MRAM: a `tan flash` in a
2152
+ # fresh customer's checkout must not silently reprogram an attached
2153
+ # module. A backend targeting an external helper MCU's own flash
2154
+ # rather than persistent on-die storage would not need this gate.
2155
+ planning_only=inp.dry_run or not confirm,
2156
+ jlink_script="\n".join(lines) + "\n",
2157
+ )
2158
+
2159
+
2160
+ def validate_flow_d_preflight_args(
2161
+ flash_args: Any, method: str = FLOW_D_METHOD, *, require_device_key: bool = True
2162
+ ) -> tuple[str | None, str | None]:
2163
+ """The presence/pairing/shape checks for the read-only DPIDR preflight,
2164
+ returning `(expect_dpidr, jlink_device)` -- both `None` (opted out) or
2165
+ both set (validated), UNLESS `require_device_key` is `False` (see below),
2166
+ in which case the second slot is always `None`. Raises `FlashPlanError`
2167
+ for every half-armed or malformed shape; never touches a J-Link binary or
2168
+ builds the Commander script, so the CALLER decides when to run it.
2169
+ `flow_d_preflight_script` (write-path) runs it then builds the script
2170
+ from the same values; `tan.commands.flash_cmd` also runs it PLAN-TIME,
2171
+ before the confirm/dry-run gate, so a half-armed or malformed manifest
2172
+ surfaces as a `flash.entry-failed` issue in the planned envelope too --
2173
+ not only at real-write time.
2174
+
2175
+ Written to be shared by more than one backend, not one copy per backend
2176
+ (tan-cli#520): the caller is Flow D (`alif_mram_jlink`) today, with
2177
+ `method` interpolated ONLY into the refusal text -- it names which
2178
+ backend's `flash_args` a customer needs to fix, since a manifest could
2179
+ otherwise not tell which entry a bare "flash_args.expect_dpidr" refusal
2180
+ was about. Defaults to `FLOW_D_METHOD` so every existing Flow D call site
2181
+ keeps its exact wording unchanged. `swd_probe`'s J-Link arm was the second
2182
+ caller (`method="swd_probe"`, `require_device_key=False`) until tan-cli#732
2183
+ removed the backend; the parameter stays for a future J-Link-arm backend
2184
+ with the same shape.
2185
+
2186
+ `require_device_key` (default `True`, Flow D's own shape, unchanged):
2187
+ whether `expect_dpidr` must be PAIRED with a `flash_args.jlink_device`
2188
+ naming the live-core read attach profile, the way Flow D needs it
2189
+ (Flow D's `jlink_device` is DISTINCT from its write-time flash-algorithm
2190
+ profile, `jlink_flash_device`). A backend whose `flash_args.jlink_device`
2191
+ already means the write's own `-device` profile -- `swd_probe` was one --
2192
+ passes `False`: pairing that field with `expect_dpidr` the way Flow D's
2193
+ DIFFERENT field is paired would retroactively demand a preflight of every
2194
+ manifest that only ever set the write device. With
2195
+ `require_device_key=False`, `expect_dpidr` ALONE arms the preflight and
2196
+ the second returned slot is always `None`; the caller supplies its own
2197
+ read device via `FlashPlan.preflight_device`.
2198
+
2199
+ `None`/`None` (or `expect_dpidr`/`None` under `require_device_key=False`)
2200
+ only for a genuinely ABSENT `expect_dpidr` -- the documented, test-pinned
2201
+ way to opt out of the preflight entirely. A key that IS present must
2202
+ still refuse when it resolves to `None` (`fa_str_checked` alone cannot
2203
+ tell "absent" from "present but null/empty"; see `_fa_has_key`'s
2204
+ docstring): silently treating it as absent would drop the SW-DP IDR check
2205
+ -- the one guard standing between a wrong-board attach and an MRAM/GD32
2206
+ write -- with no diagnostic at all.
2207
+ """
2208
+ fa = flash_args
2209
+ # tan-cli#795: `as_hex_address=True` -- same round-trip fix as
2210
+ # `plan_alif_mram_jlink` above; a bare YAML integer must not decay to a
2211
+ # decimal string that then fails to match the probe's hex-formatted banner
2212
+ # on the CORRECT board.
2213
+ expected = fa_str_checked(fa, "expect_dpidr", True)
2214
+ if expected is None and _fa_has_key(fa, "expect_dpidr"):
2215
+ raise FlashPlanError(
2216
+ f"{method}: flash_args.expect_dpidr is present but null/empty -- "
2217
+ "refusing to silently skip the pre-write SW-DP IDR check. Remove the key "
2218
+ "entirely to skip the preflight, or supply the board's real expected ID."
2219
+ )
2220
+ if not require_device_key:
2221
+ if expected is None:
2222
+ return None, None
2223
+ validate_address(expected, "expect_dpidr")
2224
+ _validate_expect_dpidr_width(expected, method)
2225
+ return expected, None
2226
+ read_device = fa_str_checked(fa, "jlink_device", False)
2227
+ if read_device is None and _fa_has_key(fa, "jlink_device"):
2228
+ raise FlashPlanError(
2229
+ f"{method}: flash_args.jlink_device is present but null/empty -- "
2230
+ "refusing to silently skip the pre-write SW-DP IDR check. Remove the key "
2231
+ "entirely to skip the preflight, or supply the live-core read device "
2232
+ "profile."
2233
+ )
2234
+ # Half-armed by a genuinely ABSENT partner key (not a null one -- that is
2235
+ # the two checks above): supplying `expect_dpidr` is the manifest's
2236
+ # unambiguous statement that it wanted the wrong-board guard armed, and
2237
+ # the reverse holds for `jlink_device`. Silently returning `None` here
2238
+ # would drop the SW-DP IDR check with no diagnostic at all, immediately
2239
+ # before the one write this backend's own docstring calls unrecoverable.
2240
+ if (expected is None) != (read_device is None):
2241
+ present_key, absent_key = (
2242
+ ("expect_dpidr", "jlink_device")
2243
+ if expected is not None
2244
+ else ("jlink_device", "expect_dpidr")
2245
+ )
2246
+ raise FlashPlanError(
2247
+ f"{method}: flash_args.{present_key} is present but flash_args."
2248
+ f"{absent_key} is not -- refusing to silently skip the pre-write SW-DP "
2249
+ "IDR check. Supply both flash_args.expect_dpidr and flash_args."
2250
+ "jlink_device to arm the preflight, or remove both to skip it entirely."
2251
+ )
2252
+ if expected is None or read_device is None:
2253
+ return None, None
2254
+ validate_address(expected, "expect_dpidr")
2255
+ _validate_expect_dpidr_width(expected, method)
2256
+ validate_identifier(read_device, "jlink_device")
2257
+ return expected, read_device
2258
+
2259
+
2260
+ def _validate_expect_dpidr_width(expected: str, method: str) -> None:
2261
+ """Refuse an `expect_dpidr` that is not a full 32-bit / 8-hex-digit SW-DP
2262
+ ID (tan-cli#795). `validate_address` (above) stays a generic charset check
2263
+ of any length -- `base` and friends share it -- so this width rule lives
2264
+ here instead, narrowly scoped to the one field this preflight arms off of,
2265
+ and runs at the SAME PLAN-TIME call site as `validate_address` itself:
2266
+ Flow D calls `validate_flow_d_preflight_args` before any write, via
2267
+ `tan.commands.flash_cmd`, so a truncated value surfaces under `--dry-run`
2268
+ too, not only when a real J-Link probe reads a banner.
2269
+
2270
+ Without this, a truncated value like `0x2477`, or `0x477` (ARM's own
2271
+ JEP106 designer field, shared by every ARM SW-DP), would match any ARM
2272
+ board's banner and silently disarm the wrong-board guard.
2273
+ """
2274
+ digits = expected[2:] if expected[:2] in ("0x", "0X") else expected
2275
+ if len(digits) == 8:
2276
+ return
2277
+ raise FlashPlanError(
2278
+ f"{method}: flash_args.expect_dpidr = {_quoted(expected)} is not a full "
2279
+ "32-bit SW-DP ID (8 hex digits) -- refusing to arm the wrong-board "
2280
+ "guard with a value short enough to match more than one board (ARM's "
2281
+ "own JEP106 designer field, 0x477, is shared by every ARM SW-DP)."
2282
+ )
2283
+
2284
+
2285
+ def flow_d_preflight_script(
2286
+ inp: FlashInputs, method: str = FLOW_D_METHOD, *, read_device: str | None = None
2287
+ ) -> tuple[str, str] | None:
2288
+ """The read-only DPIDR preflight script for a Flow D J-Link plan (tan-cli
2289
+ #520 generalised this out of a Flow D-only shape so a second backend
2290
+ could reuse it -- see `read_device` below): `(script, expected_id)`, or
2291
+ `None` when the preflight is not armed at all -- see
2292
+ `validate_flow_d_preflight_args` for every refusal shape, which this
2293
+ delegates to (passing `method` through unchanged) before building the
2294
+ script.
2295
+
2296
+ `read_device` (tan-cli#520, default `None`): for a CALLER that already has
2297
+ a connect device in hand via `FlashPlan.preflight_device` -- the same
2298
+ device its own write already resolved -- pass it here rather than
2299
+ requiring a second `flash_args` key. This flips
2300
+ `validate_flow_d_preflight_args`'s `require_device_key` to `False`, so
2301
+ `expect_dpidr` ALONE arms the preflight on that path. `None` (the default)
2302
+ keeps Flow D's own shape exactly: `jlink_device` PAIRED with
2303
+ `expect_dpidr` in `flash_args`, as it always has been. `swd_probe`'s
2304
+ J-Link arm was the one caller that passed a `read_device` until tan-cli
2305
+ #732 removed the backend; the parameter stays for the next one.
2306
+
2307
+ Run BEFORE any write, with a live-core attach profile (which the
2308
+ part-number one is not), so the caller can abort on the wrong board while
2309
+ the session is still read-only. The expected ID always comes from
2310
+ `flash_args.expect_dpidr`; the device name comes from either
2311
+ `flash_args.jlink_device` (Flow D shape) or the caller-supplied
2312
+ `read_device`, depending on which was armed.
2313
+ """
2314
+ expected, paired_device = validate_flow_d_preflight_args(
2315
+ inp.flash_args, method, require_device_key=read_device is None
2316
+ )
2317
+ if expected is None:
2318
+ return None
2319
+ read_device = read_device if read_device is not None else paired_device
2320
+ if read_device is None:
2321
+ return None
2322
+ # tan-cli#520 REVIEW, BLOCKER 1: `read_device` -- whichever source it came
2323
+ # from -- is charset-validated HERE, unconditionally, before it reaches
2324
+ # the script line below. A CALLER-supplied `read_device` (`swd_probe`'s
2325
+ # `FlashPlan.preflight_device`, i.e. `_resolve_jlink_device`'s return
2326
+ # value) was NEVER validated anywhere: `_resolve_jlink_device` returns
2327
+ # `flash_args.jlink_device` verbatim, which was safe while it only ever
2328
+ # reached ARGV (a list element, newline-inert) via the write -- this
2329
+ # preflight instead interpolates it into a Commander script LINE
2330
+ # (`device {read_device}`, a few lines down), the exact injection surface
2331
+ # `validate_identifier` already closes for Flow D's own PAIRED
2332
+ # `jlink_device` inside `validate_flow_d_preflight_args`'s
2333
+ # `require_device_key=True` branch. Without this, an embedded newline in
2334
+ # `flash_args.jlink_device` could splice EXTRA Commander lines (a
2335
+ # `loadfile`/`r`/`g` ahead of `connect`) into this READ-ONLY preflight,
2336
+ # running BEFORE the mismatch abort this function exists to run first --
2337
+ # the same class of hole #486 closed for `jlink_serial` on Flow D, now
2338
+ # closed here for the device name on both callers (re-validating Flow
2339
+ # D's own already-checked `paired_device` here too is redundant but
2340
+ # harmless -- `validate_identifier` is pure).
2341
+ validate_identifier(read_device, "jlink_device")
2342
+ fa = inp.flash_args
2343
+ speed = _default(fa_int_checked(fa, "jlink_speed"), _DEFAULT_JLINK_SPEED)
2344
+ lines = []
2345
+ # `fa_str_checked` + `validate_identifier`, not the tolerant `fa_str`
2346
+ # (tan-cli#486) -- same fix as `plan_alif_mram_jlink`'s write script, and
2347
+ # more load-bearing HERE: an injected line prefixed onto this READ-ONLY
2348
+ # preflight (see the module docstring's "the identity is confirmed while
2349
+ # the session is still read-only" comment in `flash_cmd`) would run
2350
+ # before the wrong-board abort ever gets a chance to fire. Also already
2351
+ # validated by `validate_flow_d_shape`, which `_flash_entry` runs before
2352
+ # this preflight (tan-cli#486 review) -- repeated here defensively.
2353
+ serial = fa_str_checked(fa, "jlink_serial", False)
2354
+ if serial is not None:
2355
+ validate_identifier(serial, "jlink_serial", destination=_JLINK_SERIAL_DESTINATION)
2356
+ lines.append(f"SelectEmuBySN {serial}")
2357
+ lines += ["si SWD", f"speed {speed}", f"device {read_device}", "connect", "exit"]
2358
+ return "\n".join(lines) + "\n", expected
2359
+
2360
+
2361
+ _REGISTRY: dict[str, BackendMeta] = {
2362
+ "zephyr_west_flash": BackendMeta(("west",), plan_zephyr_west_flash),
2363
+ "baremetal_cmake_flash": BackendMeta(("cmake",), plan_baremetal_cmake_flash),
2364
+ "yocto_wic_to_sd_or_emmc": BackendMeta(("bmaptool", "dd"), plan_yocto_wic),
2365
+ "yocto_wic": BackendMeta(("bmaptool", "dd"), plan_yocto_wic),
2366
+ "xspi_flashwriter": BackendMeta((), plan_xspi_flashwriter),
2367
+ FLOW_D_METHOD: BackendMeta(("JLinkExe", "JLink"), plan_alif_mram_jlink),
2368
+ }
2369
+
2370
+
2371
+ # ── tan-cli#609: which methods the wrong-board SW-DP ID guard covers ────────
2372
+
2373
+ #: Every registered `flash_method`, mapped to whether **tan itself composes
2374
+ #: the J-Link Commander session that performs the write** and can therefore
2375
+ #: run the read-only SW-DP IDR preflight (`flow_d_preflight_script`) ahead of
2376
+ #: it. `True` is the guard's coverage set.
2377
+ #:
2378
+ #: A TABLE rather than an inline `method == ...` test at the one call site,
2379
+ #: because tan-cli#609 measured what the inline form costs. `flash_cmd` used to
2380
+ #: gate the unarmed-guard ADVISORY on a single hardcoded method; the AEN
2381
+ #: dispatches Flow D (`alif_mram_jlink`), so a real MRAM write on
2382
+ #: `e1m-aen-evk-01` (2026-08-10, `origin/dev` `a9062ea`) emitted `ISSUES = []`
2383
+ #: -- no guard AND no signal -- on a bench where one J-Link serial is
2384
+ #: OEM-cloned across two probes and `JLinkExe` selects by serial alone. The
2385
+ #: advisory tracked whichever method someone had last wired it to, not the
2386
+ #: methods that can actually write.
2387
+ #:
2388
+ #: `False` is NOT a safety claim about a method. It says only that
2389
+ #: `flash_args.expect_dpidr` would have nothing to arm there, because tan does
2390
+ #: not build that method's probe session: `zephyr_west_flash` hands the job to
2391
+ #: `west flash`, whose runner composes its own command line (a J-Link runner
2392
+ #: among them); `baremetal_cmake_flash` hands it to a CMake target; the
2393
+ #: `yocto_wic*` pair and `xspi_flashwriter` address a block device and a
2394
+ #: serial port, neither probe-selected. An advisory on any of those would tell
2395
+ #: an operator to set a key that does nothing.
2396
+ #:
2397
+ #: `tests/gates/test_dpidr_guard_coverage.py` pins these keys to `_REGISTRY`'s,
2398
+ #: so a NEW backend cannot dispatch until someone has decided which side it is
2399
+ #: on. That pin is what makes the `.get(..., False)` default in
2400
+ #: `dpidr_preflight_possible` a closed question rather than a silent omission.
2401
+ DPIDR_GUARD_COVERAGE: dict[str, bool] = {
2402
+ FLOW_D_METHOD: True,
2403
+ "zephyr_west_flash": False,
2404
+ "baremetal_cmake_flash": False,
2405
+ "yocto_wic": False,
2406
+ "yocto_wic_to_sd_or_emmc": False,
2407
+ "xspi_flashwriter": False,
2408
+ }
2409
+
2410
+
2411
+ def dpidr_preflight_possible(method: str, preflight_device: str | None) -> bool:
2412
+ """Whether tan CAN run its read-only SW-DP IDR preflight for this entry --
2413
+ i.e. whether `flash_args.expect_dpidr` would arm a real check here. Pure.
2414
+
2415
+ The method being on the `True` side of `DPIDR_GUARD_COVERAGE` is the whole
2416
+ test today. `preflight_device` stays a parameter for call-site symmetry
2417
+ with `dpidr_preflight_unarmed`, which threads the same `FlashPlan` field
2418
+ through -- tan-cli#732 removed `swd_probe`, the one backend whose plan
2419
+ builder could take an arm where a preflight is impossible even though the
2420
+ METHOD is covered (`preflight_device is None` on its openocd/pyocd arm).
2421
+ Flow D has no such split (it is J-Link by construction) and always passes
2422
+ `preflight_device=None`; a future backend with its own split arm would
2423
+ restate that second condition here rather than inventing a new predicate.
2424
+ """
2425
+ return DPIDR_GUARD_COVERAGE.get(method, False)
2426
+
2427
+
2428
+ def dpidr_preflight_unarmed(
2429
+ method: str, flash_args: Any, preflight_device: str | None
2430
+ ) -> bool:
2431
+ """Whether this write went ahead with a wrong-board guard that COULD have
2432
+ run and was not armed -- the condition `flash.dpidr-preflight-unarmed`
2433
+ reports. Pure.
2434
+
2435
+ `expect_dpidr` PRESENT is the whole test, via `_fa_has_key` rather than
2436
+ `fa_str_checked`: a present-but-null key is a refusal
2437
+ (`validate_flow_d_preflight_args`), never a quiet opt-out, so treating it
2438
+ as absent here would report the wrong thing about a manifest that already
2439
+ cannot flash. Flow D additionally requires `expect_dpidr` to be PAIRED
2440
+ with `jlink_device`, but a half-armed pair refuses before any write, so by
2441
+ the time an entry reaches a caller of this function "present" and "armed"
2442
+ coincide on both covered methods.
2443
+
2444
+ Ask this only of a REAL, CONFIRMED write. It says nothing about a
2445
+ `--dry-run` or an unconfirmed preview, neither of which writes anything
2446
+ for a wrong-board guard to have protected.
2447
+ """
2448
+ return dpidr_preflight_possible(method, preflight_device) and not _fa_has_key(
2449
+ flash_args, "expect_dpidr"
2450
+ )
2451
+
2452
+
2453
+ # ── the required-tool gate ──────────────────────────────────────────────────
2454
+
2455
+ PROCEED = "proceed"
2456
+ SKIP = "skip"
2457
+ FAIL = "fail"
2458
+
2459
+
2460
+ @dataclass(frozen=True)
2461
+ class ToolGate:
2462
+ outcome: str
2463
+ message: str = ""
2464
+
2465
+
2466
+ def tool_gate(
2467
+ requires,
2468
+ dry_run: bool,
2469
+ skip_missing: bool,
2470
+ kind: str,
2471
+ entry_id: str,
2472
+ method: str,
2473
+ which: Callable[[str], bool],
2474
+ ) -> ToolGate:
2475
+ """A backend is usable when AT LEAST ONE of `requires` is on PATH. Bypassed
2476
+ entirely under `--dry-run`, and for a backend with an empty `requires`."""
2477
+ if dry_run or not requires:
2478
+ return ToolGate(PROCEED)
2479
+ if any(which(t) for t in requires):
2480
+ return ToolGate(PROCEED)
2481
+ msg = (
2482
+ f"flash: {kind} '{entry_id}' backend '{method}' needs one of "
2483
+ f"{_str_list_debug(requires)} on PATH; none found."
2484
+ )
2485
+ if skip_missing:
2486
+ return ToolGate(SKIP, f"{msg} (skipped via --skip-missing-tools)")
2487
+ return ToolGate(FAIL, msg)
2488
+
2489
+
2490
+ # ── argv display ────────────────────────────────────────────────────────────
2491
+
2492
+
2493
+ def display_argv(plan: FlashPlan) -> str:
2494
+ """The would-run display string; a J-Link plan shows a `<generated.jlink>`
2495
+ placeholder for the temp Commander script (which does not exist yet, and
2496
+ whose name carries a pid + nanosecond stamp that must never reach a
2497
+ golden)."""
2498
+ parts = list(plan.argv)
2499
+ if plan.jlink_script is not None:
2500
+ parts.append("<generated.jlink>")
2501
+ return " ".join(parts)