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
@@ -0,0 +1,381 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pure facts about a Flow D write, for the envelope (tan-cli#1321, #1318).
3
+
4
+ Two halves, both IO-free:
5
+
6
+ **What a J-Link transcript says (tan-cli#1321).** `verifybin` compares the file
7
+ against J-Link's flash CACHE, not the chip, so "verified" overstated what was
8
+ proven (alp-sdk#2233: on this bench only a fresh-session read after a cold cycle
9
+ proves a write). The envelope reports `verification: "cache-verified"`, the SW-DP
10
+ ID the transcript actually carried, and every reset-failure marker in it
11
+ (tan-cli#522: `Failed to halt CPU` was already caught; `Reset: Failed` and
12
+ `CPU may have not been reset` are the same condition worded by other J-Link
13
+ builds).
14
+
15
+ **What a write would do (tan-cli#1318).** Every write as `{address, size,
16
+ sectorSpan}` with 16 KiB sectors -- the AEN MRAM loader rewrites whole sectors
17
+ and fills the remainder with 0xFF, so a write's footprint is wider than its
18
+ size, and the operator needs it before arming.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import re
24
+ from collections.abc import Sequence
25
+ from typing import Any
26
+
27
+ from tan.core.dp_id import _dp_id_value
28
+ from tan.core.flash_plan import FlashPlanError, commander_path, validate_commander_path
29
+
30
+ #: The MRAM loader's erase/rewrite granule on the AEN parts (tan-cli#1318).
31
+ SECTOR_BYTES = 16 * 1024
32
+
33
+ #: What `verifybin` proves, in the one spelling the envelope and message use.
34
+ VERIFICATION_CACHE = "cache-verified"
35
+ VERIFICATION_READBACK = "readback-verified"
36
+ #: Stated once, beside the field, so a consumer is not left reading
37
+ #: "verified" as "the chip holds these bytes".
38
+ VERIFICATION_NOTE = (
39
+ "verifybin compares the image against J-Link's flash cache, not the chip; a "
40
+ "fresh-session read after a cold power cycle is what proves a write "
41
+ "(alp-sdk#2233). --readback adds a fresh-session read-back + sha256 compare."
42
+ )
43
+
44
+ #: What a matching read-back proves: a FRESH J-Link session read the same bytes. Still
45
+ #: not a cold-cycle proof (alp-sdk#2233).
46
+ VERIFICATION_READBACK_NOTE = (
47
+ "a fresh J-Link session read the written regions back and their sha256 matches the "
48
+ "source files; this is stronger than the flash cache but is not a cold-power-cycle "
49
+ "proof (alp-sdk#2233)."
50
+ )
51
+
52
+ #: J-Link Commander phrasing that means the PIN reset (`RSetType 2` / `r` / `g`)
53
+ #: did not land. Substring-matched against the captured transcript; the exit
54
+ #: code cannot tell these runs apart (JLinkExe still exits 0).
55
+ RESET_FAILURE_MARKERS = (
56
+ "Failed to halt CPU",
57
+ "CPU is not halted",
58
+ "Reset: Failed",
59
+ "CPU may have not been reset",
60
+ )
61
+
62
+
63
+ def reset_failures(transcript: str) -> tuple[str, ...]:
64
+ """Every reset-failure marker present in `transcript`, in marker order."""
65
+ return tuple(m for m in RESET_FAILURE_MARKERS if m in transcript)
66
+
67
+
68
+ #: Core-debug DHCSR (0xE000EDF0) bits (tan-cli#1453). S_RESET_ST and S_RETIRE_ST are
69
+ #: sticky but CLEAR ON READ, and J-Link reads DHCSR itself (a failed halt, the probe's
70
+ #: own `connect`) before tan does -- so only S_RESET_ST (a reset happened since the last
71
+ #: read, with no lockup) counts as proof that THIS reset booted. S_SLEEP / S_RETIRE_ST
72
+ #: alone are also what the OLD image idling looks like, so they prove only that a core is
73
+ #: running, never that the reset took.
74
+ DHCSR_ADDRESS = "0xE000EDF0"
75
+ DHCSR_S_HALT = 1 << 17
76
+ DHCSR_S_SLEEP = 1 << 18
77
+ DHCSR_S_LOCKUP = 1 << 19
78
+ DHCSR_S_RETIRE_ST = 1 << 24
79
+ DHCSR_S_RESET_ST = 1 << 25
80
+ _DHCSR_RAN = DHCSR_S_SLEEP | DHCSR_S_RETIRE_ST | DHCSR_S_RESET_ST
81
+
82
+ #: Probe-transcript phrasing that means the probe session itself reset or tried to halt
83
+ #: the core, which makes its DHCSR read evidence of that session, not of the flash's reset.
84
+ PROBE_TROUBLE_MARKERS = ("Reset:",)
85
+
86
+ #: DWT_PCSR, the program-counter sample register: a NON-halting witness of where a RUNNING
87
+ #: core is executing (tan-cli#1453 review). DHCSR's S_RESET_ST is cleared by J-Link's own
88
+ #: reads and will usually be gone, so this is the second witness. It needs the DWT unit
89
+ #: present (Cortex-M55 has it) and readable while the core runs; a halted or sleeping core
90
+ #: returns 0xFFFFFFFF, which is no sample at all.
91
+ DWT_PCSR_ADDRESS = "0xE000101C"
92
+ PCSR_NO_SAMPLE = 0xFFFFFFFF
93
+ #: 0x00000000 is not a sample either (a gated or unpowered DWT reads as zero).
94
+ PCSR_NO_SAMPLES = (PCSR_NO_SAMPLE, 0x00000000)
95
+ PCSR_SAMPLES = 3
96
+
97
+
98
+ def nohalt_probe_script(jlink_script: str, extra_reads: Sequence[str] = ()) -> str:
99
+ """A fresh, read-only, NON-halting session: the write's preamble up to and including
100
+ `connect`, one `mem32` of DHCSR, the PCSR samples, then `extra_reads` (the read-only uptime
101
+ reads of tan-cli#1539), `exit` (tan-cli#1453). Raises `ValueError` without a `connect` line."""
102
+ out: list[str] = []
103
+ for line in jlink_script.splitlines():
104
+ out.append(line)
105
+ if line.strip().lower() == "connect":
106
+ break
107
+ else:
108
+ raise ValueError("the write script has no `connect` line to build a probe from")
109
+ out.append(f"mem32 {DHCSR_ADDRESS} 1")
110
+ for _ in range(PCSR_SAMPLES):
111
+ out += [f"mem32 {DWT_PCSR_ADDRESS} 1", "Sleep 5"]
112
+ out += extra_reads
113
+ out.append("exit")
114
+ return "\n".join(out) + "\n"
115
+
116
+
117
+ def dhcsr_in(transcript: str) -> int | None:
118
+ """The DHCSR word a `mem32 0xE000EDF0 1` printed (`E000EDF0 = 03050001`), or `None`."""
119
+ match = re.search(r"E000EDF0\s*=\s*([0-9A-Fa-f]{8})", transcript)
120
+ return int(match.group(1), 16) if match else None
121
+
122
+
123
+ def dhcsr_confirms_reset(value: int | None) -> bool:
124
+ """True only when `value` shows a reset since the last read (S_RESET_ST) on a core
125
+ that is neither halted nor locked up."""
126
+ return (
127
+ value is not None and bool(value & DHCSR_S_RESET_ST)
128
+ and not value & (DHCSR_S_HALT | DHCSR_S_LOCKUP)
129
+ )
130
+
131
+
132
+ def dhcsr_core_running(value: int | None) -> bool:
133
+ """True when `value` shows a running core (retired an instruction, slept, or reset)
134
+ that is not halted or locked up. This does NOT say the reset took."""
135
+ return (
136
+ value is not None and bool(value & _DHCSR_RAN)
137
+ and not value & (DHCSR_S_HALT | DHCSR_S_LOCKUP)
138
+ )
139
+
140
+
141
+ def pcsr_samples(transcript: str) -> list[int]:
142
+ """Every PC sample a `mem32 0xE000101C 1` printed, in order (0xFFFFFFFF included)."""
143
+ return [int(m, 16) for m in re.findall(r"E000101C\s*=\s*([0-9A-Fa-f]{8})", transcript)]
144
+
145
+
146
+ def pcsr_in_ranges(samples: Sequence[int], ranges: Sequence[tuple[int, int]]) -> bool:
147
+ """True when at least one real sample exists and EVERY real sample lies inside
148
+ `ranges` (`[start, end)` of the image tan just flashed). 0xFFFFFFFF is no evidence; a
149
+ sample outside the ranges (old image, loader, ROM) vetoes. The old image is not known to
150
+ tan, so an old image linked into the same range is not excluded (tan-cli#1537): a True here
151
+ is "the core runs code from this range", never "the new image runs" or "a reset happened"."""
152
+ real = [x for x in samples if x not in PCSR_NO_SAMPLES]
153
+ return bool(real) and bool(ranges) and all(
154
+ any(lo <= x < hi for lo, hi in ranges) for x in real
155
+ )
156
+
157
+
158
+ def probe_trouble(transcript: str) -> tuple[str, ...]:
159
+ """Reset/halt markers in a boot-probe transcript (it must have done neither)."""
160
+ return tuple(m for m in (*PROBE_TROUBLE_MARKERS, *RESET_FAILURE_MARKERS) if m in transcript)
161
+
162
+
163
+ def dpidr_in(transcript: str) -> str | None:
164
+ """The SW-DP ID the transcript reports (`Found SW-DP with ID 0x...` /
165
+ `DPIDR: 0x...`), verbatim, or `None` when it names none."""
166
+ return _dp_id_value(transcript)
167
+
168
+
169
+ def transcript_tail(transcript: str, lines: int = 40) -> list[str]:
170
+ """The last `lines` non-empty lines of `transcript`."""
171
+ kept = [line.rstrip() for line in transcript.splitlines() if line.strip()]
172
+ return kept[-lines:]
173
+
174
+
175
+ def sector_span(address: int, size: int, sector: int = SECTOR_BYTES) -> dict[str, Any]:
176
+ """The sectors a write of `size` bytes at `address` touches: the first
177
+ sector's base address, the last sector's END (exclusive), the count and the
178
+ byte footprint (count * sector). A zero-size write touches nothing."""
179
+ if size <= 0:
180
+ return {"first": hex_addr(address - address % sector), "end": hex_addr(address - address % sector),
181
+ "count": 0, "bytes": 0, "sectorBytes": sector}
182
+ first = address - address % sector
183
+ last = (address + size - 1) - (address + size - 1) % sector
184
+ count = (last - first) // sector + 1
185
+ return {
186
+ "first": hex_addr(first),
187
+ "end": hex_addr(last + sector),
188
+ "count": count,
189
+ "bytes": count * sector,
190
+ "sectorBytes": sector,
191
+ }
192
+
193
+
194
+ def hex_addr(value: int) -> str:
195
+ return f"0x{value:08X}"
196
+
197
+
198
+ def planned_write(name: str, address: str, size: int | None, path: str | None) -> dict[str, Any]:
199
+ """One planned write as the envelope reports it. `size` is `None` when the
200
+ file is not readable yet (the dry-run of a hand-written manifest)."""
201
+ entry: dict[str, Any] = {"name": name, "address": address, "path": path, "size": size}
202
+ if size is not None:
203
+ entry["sectorSpan"] = sector_span(int(address, 16), size)
204
+ return entry
205
+
206
+
207
+ def sha256_of(path: str) -> str:
208
+ digest = hashlib.sha256()
209
+ with open(path, "rb") as fh:
210
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
211
+ digest.update(chunk)
212
+ return digest.hexdigest()
213
+
214
+
215
+ def reset_tail(jlink_script: str) -> list[str]:
216
+ """The write script's reset/run tail -- from its first `RSetType` line up to (not
217
+ including) the final `exit` -- so a read-back ends the same way the write did and
218
+ the app is not left halted. `["g"]` when the script has no `RSetType`."""
219
+ lines = [line for line in jlink_script.splitlines() if line.strip()]
220
+ for i, line in enumerate(lines):
221
+ if line.strip().lower().startswith("rsettype"):
222
+ end = len(lines) - 1 if lines[-1].strip().lower() == "exit" else len(lines)
223
+ return lines[i:end]
224
+ return ["g"]
225
+
226
+
227
+ #: What an in-session read-back proves (tan-cli#1458): the chip's bytes were read back after a
228
+ #: halt, in the write's own session, before the reset -- NOT a fresh session, so J-Link's
229
+ #: flash cache may sit between the read and the cells.
230
+ VERIFICATION_INSESSION = "insession-readback"
231
+ VERIFICATION_INSESSION_NOTE = (
232
+ "the written regions were read back with savebin in the SAME J-Link session as the write, "
233
+ "after a halt and before the PIN reset (a separate session would let the app run in "
234
+ "between); their sha256 matches the source files. J-Link may serve that read from its flash "
235
+ "cache, so this is NOT a fresh-session or cold-power-cycle proof (alp-sdk#2233); "
236
+ "`readback-verified` is reserved for a fresh-session match."
237
+ )
238
+ VERIFICATION_INSESSION_NORESET_NOTE = (
239
+ "the written regions were read back with savebin in the SAME J-Link session as the write, "
240
+ "after a halt; no reset command was sent (--no-reset); their sha256 matches the source "
241
+ "files. J-Link may serve that read from its flash cache, so this is NOT a fresh-session or "
242
+ "cold-power-cycle proof (alp-sdk#2233); `readback-verified` is reserved for a fresh-session "
243
+ "match."
244
+ )
245
+
246
+ #: J-Link Commander phrasing for "no debug access to the target right now" -- a gated
247
+ #: debug domain (the app is in STOP/WFI) as much as a bad cable (tan-cli#1450).
248
+ UNREACHABLE_MARKERS = (
249
+ "Could not read memory",
250
+ "Cannot read memory",
251
+ "Cannot connect to target",
252
+ "Could not connect to target",
253
+ "Connecting to target failed",
254
+ )
255
+
256
+
257
+ def target_unreachable(transcript: str) -> bool:
258
+ """Whether `transcript` says the target could not be reached for a memory read."""
259
+ return any(m in transcript for m in UNREACHABLE_MARKERS)
260
+
261
+
262
+ def drop_reset_tail(jlink_script: str) -> str:
263
+ """The write script WITHOUT its reset/run tail (`RSetType` .. final `exit`): connect,
264
+ loadbin, verifybin, `exit`. For `--no-reset` (tan-cli#1445): no reset command is sent. J-Link's
265
+ `exit` may still resume the core (bench, tan-cli#1458), which is why the envelope says so."""
266
+ lines = [line for line in jlink_script.splitlines() if line.strip()]
267
+ for i, line in enumerate(lines):
268
+ if line.strip().lower().startswith("rsettype"):
269
+ return "\n".join([*lines[:i], "exit"]) + "\n"
270
+ return jlink_script
271
+
272
+
273
+ def combined_script(
274
+ jlink_script: str, regions: Sequence[tuple[str, int, str]], tail: bool = True
275
+ ) -> str:
276
+ """The write session WITH the read-back inside it (tan-cli#1458, bench): everything up to
277
+ the first `RSetType` (connect, loadbin, verifybin), then `h`, one `savebin` per region,
278
+ the write's own reset/run tail, `exit`. There is NO `exit` between the write and the read:
279
+ J-Link's `exit` resumes the core even after `h`, so a separate read-back session let the
280
+ app run on stale state before it connected. `regions` is `(address_hex, size, dest)`.
281
+ `tail=False` (`--no-reset`) ends after the read with `exit` and no reset/run commands; the
282
+ script then needs no `RSetType`. Otherwise raises `ValueError` without a `RSetType` tail to
283
+ keep (nothing to run after the read)."""
284
+ lines = [line for line in jlink_script.splitlines() if line.strip()]
285
+ for i, line in enumerate(lines):
286
+ if line.strip().lower().startswith("rsettype"):
287
+ break
288
+ else:
289
+ if tail:
290
+ raise ValueError("the write script has no reset tail to run after the read-back")
291
+ i = len(lines) - 1 if lines and lines[-1].strip().lower() == "exit" else len(lines)
292
+ tail_lines = reset_tail(jlink_script) if tail else []
293
+ out = [*lines[:i], "h"]
294
+ for address, size, dest in regions:
295
+ validate_commander_path(dest, "the read-back destination path")
296
+ out.append(f"savebin {commander_path(dest)} {address} 0x{size:X}")
297
+ return "\n".join([*out, *tail_lines, "exit"]) + "\n"
298
+
299
+
300
+ def readback_script(
301
+ jlink_script: str,
302
+ regions: Sequence[tuple[str, int, str]],
303
+ ) -> str:
304
+ """The Commander script of a FRESH read-back session: the same preamble the
305
+ write used (everything up to and including `connect`, so the same probe,
306
+ interface, speed and part-number device), then one `savebin <file>, <addr>,
307
+ <size>` per region and `exit`. `regions` is `(address_hex, size, dest_path)`.
308
+ The session ends at `exit` WITHOUT the write's reset/run tail (a raw sector write must
309
+ not reset or run anything, tan-cli#1446; Flow D reads inside its write session instead,
310
+ see [`combined_script`]).
311
+ Raises `ValueError` if `jlink_script` has no `connect` line."""
312
+ out: list[str] = []
313
+ for line in jlink_script.splitlines():
314
+ out.append(line)
315
+ if line.strip().lower() == "connect":
316
+ break
317
+ else:
318
+ raise ValueError("the write script has no `connect` line to build a read-back from")
319
+ for address, size, dest in regions:
320
+ validate_commander_path(dest, "the read-back destination path")
321
+ out.append(f"savebin {commander_path(dest)} {address} 0x{size:X}")
322
+ out.append("exit")
323
+ return "\n".join(out) + "\n"
324
+
325
+
326
+ # ── sector overlap (tan-cli#1343 review) ────────────────────────────────────
327
+
328
+ CODE_SECTOR_OVERLAP = "flash.write-sector-overlap"
329
+
330
+
331
+ class SectorOverlapError(FlashPlanError):
332
+ """Two writes (or a write and a resident entry that will not be rewritten)
333
+ share a 16 KiB sector. `code` is the registered issue code."""
334
+
335
+ code = CODE_SECTOR_OVERLAP
336
+
337
+
338
+ def _interval(address: int, size: int | None) -> tuple[int, int]:
339
+ """`[first, end)` of the sectors touched by `size` bytes at `address`; an
340
+ unknown size counts as one sector (the least it can touch)."""
341
+ first = address - address % SECTOR_BYTES
342
+ last = address + max((size or 1), 1) - 1
343
+ return first, last - last % SECTOR_BYTES + SECTOR_BYTES
344
+
345
+
346
+ def find_overlaps(
347
+ writes: Sequence[dict[str, Any]],
348
+ resident: Sequence[tuple[str, int | None, int | None]] = (),
349
+ ) -> list[str]:
350
+ """Human descriptions of every sector overlap among `writes`
351
+ (`{name, address, size}` dicts) and between a write and a `resident`
352
+ `(name, address, size)` region (entries with no address are skipped: tan
353
+ cannot place what it was not told). The loader rewrites WHOLE sectors, so a
354
+ write whose tail reaches the next write's first sector would erase the
355
+ neighbour's head."""
356
+ spans = [
357
+ (w["name"], *_interval(int(w["address"], 16), w.get("size")))
358
+ for w in writes
359
+ if w.get("address")
360
+ ]
361
+ out: list[str] = []
362
+ for i, (a_name, a_lo, a_hi) in enumerate(spans):
363
+ for b_name, b_lo, b_hi in spans[i + 1 :]:
364
+ if a_lo < b_hi and b_lo < a_hi:
365
+ lo, hi = max(a_lo, b_lo), min(a_hi, b_hi)
366
+ out.append(
367
+ f"the {a_name} write and the {b_name} write share the 16 KiB sector(s) "
368
+ f"{hex_addr(lo)}-{hex_addr(hi)}"
369
+ )
370
+ for r_name, r_addr, r_size in resident:
371
+ if r_addr is None:
372
+ continue
373
+ r_lo, r_hi = _interval(r_addr, r_size)
374
+ for w_name, w_lo, w_hi in spans:
375
+ if w_lo < r_hi and r_lo < w_hi:
376
+ out.append(
377
+ f"the {w_name} write's sectors {hex_addr(w_lo)}-{hex_addr(w_hi)} cover the "
378
+ f"resident entry {r_name} at {hex_addr(r_addr)}, and no write here replaces it "
379
+ "(a write starting at exactly that address does)"
380
+ )
381
+ return out
@@ -0,0 +1,164 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """tan-cli#1539: a boot witness for Flow D -- the kernel uptime, bounded by the wall clock.
3
+
4
+ After a write whose halt failed, DHCSR `S_RESET_ST` is usually gone and PC samples cannot tell the
5
+ new image from the replaced one (tan-cli#1537). A free-running tick counter can, provided it is
6
+ compared with the time tan itself measured: the probe's last read must show an uptime no larger
7
+ than the monotonic time elapsed since the write session ended (plus a slack for the reset tail
8
+ inside that session). SysTick stops while the core is halted for the write, so an image that was
9
+ already running when the write began resumes with its uptime frozen at the halt; the bound admits it
10
+ only if that frozen uptime was at most the slack, i.e. it booted under ~1 s before the write halted
11
+ it, which the preceding probe/preflight sessions already rule out in practice.
12
+ This proves a RECENT BOOT, not that the SE PIN reset took (reported as `reset: "booted"`).
13
+
14
+ The counter is Zephyr's kernel tick count (`curr_tick`, a `static uint64_t` in `kernel/timeout.c`;
15
+ present in an AEN alp-console ELF at a DTCM address). Its address is resolved from the ELF being
16
+ flashed, never hardcoded, and only an address inside a RAM LOAD segment of the slice core's own
17
+ DTCM / SRAM0 is ever read (never HP ITCM 0x50000000, never anything outside the image). The tick
18
+ rate comes from the same ELF's absolute `CONFIG_SYS_CLOCK_TICKS_PER_SEC` symbol, else the build's
19
+ `.config` beside it. Anything missing, ambiguous or out of range yields no probe, and the caller
20
+ keeps the honest `unconfirmed`. Pure: no IO except `load_uptime_probe` reading two local files."""
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ from dataclasses import dataclass
26
+ from typing import Sequence
27
+
28
+ from tan.core.ram_run import ElfImage, RamRunError, parse_elf
29
+
30
+ UPTIME_SYMBOL = "curr_tick"
31
+ TICKS_PER_SEC_SYMBOL = "CONFIG_SYS_CLOCK_TICKS_PER_SEC"
32
+ #: Reads per session, and the pause between them (ms). The tick only advances when the timeout
33
+ #: ISR announces, so the window must span more than one idle wake-up of a typical sleeper.
34
+ UPTIME_READS = 4
35
+ UPTIME_SLEEP_MS = 600
36
+ #: Slack (s) added to the elapsed time: the reset tail runs inside the write session, so the
37
+ #: image boots slightly BEFORE the session's end timestamp. Measured on e1m-aen-evk-02
38
+ #: (alp-console, 2026-10-11): uptime - elapsed = -0.04..-0.05 s, so 1 s is all headroom.
39
+ UPTIME_SLACK_SECONDS = 1.0
40
+ #: The only address windows ever read: the slice core's DTCM (local alias, 1 MiB window) and the
41
+ #: E8's 4 MiB SRAM0. HP ITCM (0x50000000) and every other aperture are out.
42
+ _READABLE = ((0x20000000, 0x20100000), (0x02000000, 0x02400000))
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class UptimeProbe:
47
+ address: int
48
+ size: int # 4 or 8 bytes
49
+ ticks_per_sec: int
50
+ symbol: str = UPTIME_SYMBOL
51
+
52
+ @property
53
+ def words(self) -> int:
54
+ return self.size // 4
55
+
56
+
57
+ def plan_uptime_probe(elf: ElfImage, config_ticks_per_sec: int | None = None) -> UptimeProbe | None:
58
+ """The probe for `elf`, or `None` when the symbol is missing, ambiguous, oddly sized, outside
59
+ the readable RAM windows or outside a RAM LOAD segment, or the tick rate is unknown."""
60
+ if UPTIME_SYMBOL in elf.ambiguous or UPTIME_SYMBOL not in elf.symbols:
61
+ return None
62
+ address, size = elf.symbols[UPTIME_SYMBOL]
63
+ if size not in (4, 8) or address % 4:
64
+ return None
65
+ end = address + size
66
+ if not any(lo <= address and end <= hi for lo, hi in _READABLE):
67
+ return None
68
+ if not any(s.vaddr <= address and end <= s.vaddr + s.memsz for s in elf.segments):
69
+ return None
70
+ rate = config_ticks_per_sec
71
+ if TICKS_PER_SEC_SYMBOL in elf.symbols and TICKS_PER_SEC_SYMBOL not in elf.ambiguous:
72
+ rate = elf.symbols[TICKS_PER_SEC_SYMBOL][0]
73
+ if not rate or rate <= 0:
74
+ return None
75
+ return UptimeProbe(address, size, rate)
76
+
77
+
78
+ def config_ticks_per_sec(config_text: str) -> int | None:
79
+ match = re.search(r"^CONFIG_SYS_CLOCK_TICKS_PER_SEC=(\d+)\s*$", config_text, re.MULTILINE)
80
+ return int(match.group(1)) if match else None
81
+
82
+
83
+ def load_elf(elf_path: str) -> ElfImage | None:
84
+ """The parsed ELF at `elf_path`, or `None` when it cannot be read or parsed."""
85
+ try:
86
+ with open(elf_path, "rb") as fh:
87
+ return parse_elf(fh.read())
88
+ except (OSError, RamRunError):
89
+ return None
90
+
91
+
92
+ def load_uptime_probe(elf: ElfImage | None, elf_path: str) -> UptimeProbe | None:
93
+ """`plan_uptime_probe` for the parsed `elf` (read from `elf_path`, whose directory may hold
94
+ the build's `.config`); `None` when there is no ELF."""
95
+ if elf is None:
96
+ return None
97
+ rate = None
98
+ try:
99
+ with open(os.path.join(os.path.dirname(elf_path), ".config"), encoding="utf-8") as fh:
100
+ rate = config_ticks_per_sec(fh.read())
101
+ except OSError:
102
+ pass
103
+ return plan_uptime_probe(elf, rate)
104
+
105
+
106
+ def uptime_script_lines(probe: UptimeProbe) -> list[str]:
107
+ """Read-only Commander lines: `UPTIME_READS` non-halting `mem32` reads of the counter."""
108
+ out: list[str] = []
109
+ for i in range(UPTIME_READS):
110
+ out.append(f"mem32 0x{probe.address:08X} {probe.words}")
111
+ if i + 1 < UPTIME_READS:
112
+ out.append(f"Sleep {UPTIME_SLEEP_MS}")
113
+ return out
114
+
115
+
116
+ def uptime_ticks(transcript: str, probe: UptimeProbe) -> list[int]:
117
+ """Every counter value the probe's `mem32` lines printed, in order (little-endian words)."""
118
+ pattern = rf"(?<![0-9A-Fa-f]){probe.address:08X}\s*=\s*((?:[0-9A-Fa-f]{{8}}\s*){{{probe.words}}})"
119
+ values: list[int] = []
120
+ for match in re.finditer(pattern, transcript, re.IGNORECASE):
121
+ words = [int(w, 16) for w in match.group(1).split()]
122
+ values.append(sum(w << (32 * i) for i, w in enumerate(words)))
123
+ return values
124
+
125
+
126
+ def uptime_confirms_boot(
127
+ ticks: Sequence[int], ticks_per_sec: int, elapsed_s: float,
128
+ slack_s: float = UPTIME_SLACK_SECONDS,
129
+ ) -> bool:
130
+ """True only for a counter that moved (never going backwards, last > first) and whose last
131
+ value is no more uptime than `elapsed_s` (monotonic seconds since the write session ended)
132
+ plus `slack_s`. A constant counter is a halted or gated core; a larger uptime is an image
133
+ that was running before the write; an all-ones word is an unreadable bus, not a count. The
134
+ 64-bit counter is read as two 32-bit words, so a read can tear across a carry: it shows as a
135
+ huge jump or a step backwards and is rejected by the same rules."""
136
+ if len(ticks) < 2 or any(t in (0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF) for t in ticks):
137
+ return False
138
+ if any(b < a for a, b in zip(ticks, ticks[1:])) or ticks[-1] <= ticks[0]:
139
+ return False
140
+ return ticks[-1] / ticks_per_sec <= elapsed_s + slack_s
141
+
142
+
143
+ def uptime_evidence(
144
+ transcript: str, probe: UptimeProbe, elapsed_s: float
145
+ ) -> tuple[dict, dict | None]:
146
+ """`(bootProbe.uptime evidence, jlink-block update)`; the update is `None` unless the
147
+ counter confirms a boot."""
148
+ ticks = uptime_ticks(transcript, probe)
149
+ evidence = {
150
+ "symbol": probe.symbol, "address": f"0x{probe.address:08X}",
151
+ "ticks": [f"0x{t:X}" for t in ticks], "ticksPerSec": probe.ticks_per_sec,
152
+ "elapsedSinceWriteSeconds": round(elapsed_s, 2),
153
+ }
154
+ if not uptime_confirms_boot(ticks, probe.ticks_per_sec, elapsed_s):
155
+ return evidence, None
156
+ return evidence, {
157
+ "coreRunning": True, "reset": "booted", "resetConfirmedBy": "uptime",
158
+ "witness": f"{probe.symbol} 0x{ticks[0]:X} -> 0x{ticks[-1]:X}, up "
159
+ f"{ticks[-1] / probe.ticks_per_sec:.1f} s, {elapsed_s:.1f} s after the write",
160
+ "resetNote": "the halt after the reset failed, but the kernel tick counter "
161
+ f"({probe.symbol}, read without halting at the flashed ELF's own address) is rising and "
162
+ "shows no more uptime than the time elapsed since the write ended: the image booted "
163
+ "after the write. That proves a recent boot, not that the SE PIN reset took.",
164
+ }
tan/core/fs_confine.py ADDED
@@ -0,0 +1,130 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The one containment guard every ACTIVE project writer trusts.
3
+
4
+ `tan init` (`tan.core.scaffold.write_files`) and `tan generate`
5
+ (`tan.commands.generate_cmd`, its default -- non-`--output` -- targets) both
6
+ join a caller-controlled relative path onto a project root and write there.
7
+ Neither used to check the RESOLVED target after following symlinks, so a
8
+ pre-existing directory symlink under the project (`<project>/src` ->
9
+ somewhere else) or a symlinked existing leaf silently redirected the write
10
+ outside the project while the envelope still reported the logical in-project
11
+ path as written (tan-cli#325).
12
+
13
+ `confine_to_build_root` (`tan.commands.build.materialise`) is the same
14
+ question for a DIFFERENT root (a build root, reused by `tan clean`) with its
15
+ own coded error (`build.path-escape`) and its own message shape; this module
16
+ is `resolve_confined` for the other two writers, not a third hand-rolled copy
17
+ of the same predicate (tan-cli#322 is exactly this drift: bootstrap and doctor
18
+ each grew their own resolver).
19
+
20
+ `Path.is_relative_to()` on TWO resolved paths, not a string prefix test:
21
+ resolving both sides is what makes a symlinked PROJECT ROOT itself still work
22
+ (`is_relative_to` only needs the root to be a prefix of the target's own
23
+ resolved components, so a project opened through a symlink still confines
24
+ correctly -- the escape this guards against is a write LANDING outside the
25
+ root, not the root being reached through one), and `is_relative_to` is
26
+ correct on Windows where a naive `str(target).startswith(str(root))` is not:
27
+ path comparison there folds case (`C:\\Foo` vs `c:\\foo`) and a bare
28
+ `startswith` would also wrongly accept a sibling directory whose name happens
29
+ to share the root's as a prefix (`C:\\proj` vs `C:\\project2`).
30
+
31
+ That case-folding is a `PureWindowsPath` property, NOT a filesystem one, and
32
+ those two came apart on macOS (tan-cli#587). `PurePosixPath.is_relative_to`
33
+ compares case-SENSITIVELY, and `Path.resolve()` does not canonicalise case on
34
+ a POSIX flavour to hide the difference -- `posixpath.realpath` walks the path
35
+ with `os.lstat`/`os.readlink` only and never asks the filesystem for a
36
+ component's true on-disk name, so `<tmp>/PROJECT` stays `<tmp>/PROJECT` even
37
+ where it names the very directory created as `<tmp>/Project`. On APFS (the
38
+ macOS default, case-INSENSITIVE and case-preserving) that made the guard
39
+ refuse a target genuinely inside the root. The failure direction was
40
+ over-restriction, never a bypass, but a `PathEscapeError` naming a path the
41
+ user can plainly see inside their project is a refusal that cannot be
42
+ trusted, and this module exists to be trusted.
43
+
44
+ The fallback below is INODE IDENTITY (`os.path.samestat`), not case folding.
45
+ Folding would be wrong on a case-sensitive volume, where `<tmp>/Project` and
46
+ `<tmp>/PROJECT` are two different directories and accepting the second as
47
+ inside the first is exactly the escape this guards against; asking the
48
+ filesystem whether the two names reach the same directory is right on both
49
+ kinds of volume, and needs no guess about which kind is underfoot.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import os
55
+ from pathlib import Path
56
+
57
+
58
+ class PathEscapeError(Exception):
59
+ """`target` resolved outside `root` after symlink resolution. Carries both
60
+ resolved paths so a caller can phrase its own coded issue message rather
61
+ than parsing this one."""
62
+
63
+ def __init__(self, root: Path, target: Path) -> None:
64
+ super().__init__(f"'{target}' resolves outside '{root}'")
65
+ self.root = root
66
+ self.target = target
67
+
68
+
69
+ def _reaches_the_same_root(resolved_root: Path, resolved_target: Path) -> bool:
70
+ """True when `resolved_target`'s first `len(resolved_root.parts)` components
71
+ name the SAME directory as `resolved_root`, spelled differently.
72
+
73
+ Only the prefix at the root's own depth is compared, and only by
74
+ `os.stat` identity: if that prefix IS the root directory, everything after
75
+ it lands inside the root by construction -- `resolved_target` has already
76
+ had every symlink followed by `Path.resolve()`, so the remaining
77
+ components are literal, not another indirection waiting to redirect the
78
+ write.
79
+
80
+ A shorter target than the root cannot be inside it, and an `OSError` (the
81
+ prefix does not exist, or is unreadable) answers the question `False`:
82
+ this runs only after `is_relative_to` already said no, so `False` simply
83
+ keeps the pre-existing refusal.
84
+
85
+ Not restricted to case: a bind mount or any other alias that makes two
86
+ spellings reach one directory answers `True` here, which is the same
87
+ answer for the same reason -- the write lands inside the root either way,
88
+ and "the write LANDS outside the root" is the escape being guarded.
89
+ """
90
+ root_parts = resolved_root.parts
91
+ target_parts = resolved_target.parts
92
+ if len(target_parts) < len(root_parts):
93
+ return False
94
+ prefix = Path(*target_parts[: len(root_parts)])
95
+ try:
96
+ return os.path.samestat(os.stat(prefix), os.stat(resolved_root))
97
+ except OSError:
98
+ return False
99
+
100
+
101
+ def resolve_confined(root: Path, target: Path) -> Path:
102
+ """Resolve `target` and require it stays inside the resolved `root`.
103
+
104
+ Returns the resolved target. Raises `PathEscapeError` when it does not --
105
+ including when a symlinked (or junctioned) PARENT directory of `target`
106
+ points outside `root`, and when `target` itself is a symlink to a file
107
+ outside `root`; `Path.resolve()` follows both. `target` need not exist:
108
+ `resolve(strict=False)` (the default) resolves every symlink it finds and
109
+ appends any remaining, not-yet-created components literally, so a `new`
110
+ file under a symlinked parent is caught exactly like an existing one.
111
+
112
+ Callers should also catch `OSError`/`ValueError` around this call: a path
113
+ shape the host rejects outright (a Windows device-namespace path, one
114
+ embedding a NUL) raises there, not here, and is a refusal either way.
115
+
116
+ The returned path keeps the CALLER's spelling of the components it was
117
+ given (`<tmp>/PROJECT/board.yaml`, not `<tmp>/Project/board.yaml`) on the
118
+ identity path: on a case-insensitive volume both spellings open the same
119
+ file, and re-spelling a caller's path from the on-disk casing would need
120
+ a `readdir` per component that `Path.resolve()` never performs. Compare
121
+ two returned paths with `os.path.samefile`, not `==`, if a test needs to
122
+ tie one back to a differently-spelled root (tan-cli#587).
123
+ """
124
+ resolved_root = root.resolve()
125
+ resolved_target = target.resolve()
126
+ if not resolved_target.is_relative_to(resolved_root) and not _reaches_the_same_root(
127
+ resolved_root, resolved_target
128
+ ):
129
+ raise PathEscapeError(resolved_root, resolved_target)
130
+ return resolved_target