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,1224 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pure decision logic for ADR 0021 Lane 1 P1: `tan bootstrap` acquiring the
3
+ `arm-zephyr-eabi` cross toolchain via `west sdk install --install-dir`, into
4
+ the artifact-keyed store `~/.alp/toolchains/<artifact>-<version>/` (or
5
+ `$ALP_TOOLCHAIN_ROOT`, ADR 0021's own escape hatch).
6
+
7
+ **No IO here.** Every function below is a pure transform over already-read
8
+ bytes (a manifest's text, a stamp's text, a `shutil.disk_usage` reading, a
9
+ platform pair) -- the SAME split `tan.core.bootstrap` already draws for the
10
+ venv/west phases. `tan.commands.bootstrap_cmd.toolchain_phase` is the IO
11
+ layer: it reads `<sdkRoot>/metadata/toolchains.json`, probes the filesystem
12
+ and spawns `west sdk install`, then hands the results through the functions
13
+ here to decide what happened and what to do next.
14
+
15
+ **Why a manifest DIGEST, not just a version compare.** ADR 0021's own words:
16
+ "a stamped 1.0.1 store against a moved pin is a Fail with a fix, not 'a
17
+ toolchain exists'". A version bump already changes [`store_dir_name`]'s own
18
+ directory, so two DIFFERENT versions can never collide on disk -- the digest
19
+ exists for the narrower case a version number alone cannot catch: an
20
+ artifact's `sha256`/`baseUrl` rotating under an UNCHANGED version string (a
21
+ corrected pin, a re-signed release). [`ToolchainManifest.digest`] hashes the
22
+ manifest's own raw bytes, so that case invalidates a stamp too, at the one
23
+ cost of also invalidating it on a comment-only edit -- the safe direction to
24
+ err in, matching `zephyr_python_floor`'s own "prefer the fact over the cached
25
+ memory of it" reasoning doctor already applies one layer up.
26
+
27
+ **Directory-exists is never the success predicate anywhere in this module.**
28
+ [`stamp_matches_pin`] is the ONLY verdict function, and it takes a *parsed*
29
+ stamp plus a *parsed* manifest -- never a boolean "does the path exist".
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import json
35
+ import re
36
+ from collections.abc import Mapping
37
+ from dataclasses import dataclass
38
+
39
+ #: The one cross-toolchain ADR 0021 Lane 1 acquires. `west sdk install`'s own
40
+ #: `--gnu-toolchains` vocabulary spells it exactly this way.
41
+ TOOLCHAIN_COMPONENT = "arm-zephyr-eabi"
42
+
43
+ #: The stamp file's name, inside the store directory it describes. Written
44
+ #: LAST (`tan.commands.bootstrap_cmd.toolchain_phase`), only once the compiler
45
+ #: probe below has actually run and the installed `sdk_version` file has been
46
+ #: read back and compared -- never on `west sdk install` merely exiting 0.
47
+ STAMP_FILENAME = ".alp-toolchain-stamp.json"
48
+
49
+ #: Sibling-directory naming for an in-progress/interrupted acquisition.
50
+ #: Nothing else under a toolchain root ever produces this suffix, so a
51
+ #: directory matching [`wreckage_glob_pattern`] found at the START of a new
52
+ #: `tan bootstrap` run is unambiguously wreckage tan itself left behind on a
53
+ #: prior interrupted attempt -- reclaimable the same way `ensure_venv`
54
+ #: reclaims a corpse venv `_probe_venv_pip` proved absent, never merely
55
+ #: unstamped. Never applied to `$ALP_TOOLCHAIN_ROOT` (see [`ToolchainRoot`]):
56
+ #: an adopted root may contain directories tan never created, and this
57
+ #: pattern existing is not evidence tan created THIS ONE on THIS root.
58
+ TMP_SUFFIX_PREFIX = ".tmp-"
59
+
60
+ #: Safety margin above the manifest's own MEASURED extracted footprint
61
+ #: (`measuredFootprint.extractedBytes.wholeSdk`) -- covers filesystem block
62
+ #: overhead and the transient extra copy `west sdk install` itself needs
63
+ #: mid-extraction (the downloaded archive and the extracted tree briefly
64
+ #: coexist inside its own `tempfile.TemporaryDirectory` before the archive is
65
+ #: discarded). Not a guess: 15% of ~1.89 GiB is comfortably inside "a rounding
66
+ #: error", while still catching a host that is only a few hundred MiB short.
67
+ DISK_MARGIN_RATIO = 0.15
68
+
69
+ #: `<store>/sdk_version` -- the file `west sdk install` itself writes at the
70
+ #: SDK's own top level (verified against a real extracted 1.0.1 tree: exact
71
+ #: byte content `"1.0.1\n"`), read back and string-compared against the pin
72
+ #: AFTER install, never assumed from the version tan asked `west` to install.
73
+ #: A `west` that silently resolved a different version (a stale local cache,
74
+ #: a future `west` defaulting when `--version` is dropped by a caller error)
75
+ #: is caught here rather than trusted.
76
+ SDK_VERSION_FILE_RELPATH = "sdk_version"
77
+
78
+
79
+ def gcc_binary_relpath(*, is_windows: bool) -> tuple[str, ...]:
80
+ """`<store>` -> the `arm-zephyr-eabi-gcc` binary's path components.
81
+ Verified against a real extracted `zephyr-sdk-1.0.1` tree:
82
+ `gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gcc` (SDK 1.0.0+ places GNU
83
+ toolchains under `gnu/`, per `west`'s own `scripts/west_commands/sdk.py`
84
+ comment beside its `gnu_toolchains` walk)."""
85
+ name = "arm-zephyr-eabi-gcc.exe" if is_windows else "arm-zephyr-eabi-gcc"
86
+ return ("gnu", TOOLCHAIN_COMPONENT, "bin", name)
87
+
88
+
89
+ def store_compiler_present(store_dir, *, is_windows: bool) -> bool:
90
+ """Whether the stamped store still holds its `arm-zephyr-eabi-gcc` file.
91
+ A stamp is written only after a compiler probe, but nothing stops the
92
+ compiler subtree being deleted afterwards (disk cleanup, a partial `rm`);
93
+ a stamp alone must not keep saying "verified" over that."""
94
+ try:
95
+ return store_dir.joinpath(*gcc_binary_relpath(is_windows=is_windows)).is_file()
96
+ except OSError:
97
+ return False
98
+
99
+
100
+ #: What a stamp does and does NOT establish (tan-cli#1496; the full account is in
101
+ #: `tan.core.toolchain_pin`'s docstring). `west sdk install` verifies only the
102
+ #: minimal SDK bundle (against the release `sha256.sum`) and its `setup.sh` fetches
103
+ #: the toolchain archive with no check, so a stamped install with `pinChecked` means:
104
+ #: the toolchain archive's sha256 was hashed by tan against alp-sdk's pin while
105
+ #: downloading it, and the minimal bundle matched the release sum which tan compared
106
+ #: with the pin before and after west ran. Nothing is re-hashed from disk later, and
107
+ #: tan never saw the minimal bundle's bytes. A stamp without `pinChecked` predates
108
+ #: this check: nothing about its archives was compared with the pin. See
109
+ #: docs/toolchain-archive-verification.md.
110
+ ARCHIVE_SHA256_NOTE = (
111
+ "files on disk are not re-hashed; a stamp marked pinChecked means the toolchain "
112
+ "archive was hashed against alp-sdk's pin at download (the minimal SDK bundle only "
113
+ "via the release sha256.sum, which west checks), and an unmarked stamp predates "
114
+ "that check, so its archives were never compared with the pin"
115
+ )
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Host identity
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class UnsupportedHost:
125
+ """This host cannot get the pinned toolchain -- named honestly rather than
126
+ guessed at or silently downgraded to a build that will not run.
127
+ `host_key` is the best key this function could compute (even an invented
128
+ one, for the detail message); `reason` is customer-facing prose."""
129
+
130
+ host_key: str
131
+ reason: str
132
+
133
+
134
+ def toolchain_host_key(sys_platform: str, machine: str) -> str | UnsupportedHost:
135
+ """Map `(sys.platform, platform.machine())` onto the
136
+ `metadata/toolchains.json` / `west sdk install` host vocabulary
137
+ (`linux-x86_64`, `linux-aarch64`, `windows-x86_64`, `macos-aarch64`,
138
+ `macos-x86_64`).
139
+
140
+ `windows-arm64` is refused BY NAME with the WSL2 redirect ADR 0021 itself
141
+ specifies ("no official Zephyr SDK host build ... must be routed to
142
+ WSL2-aarch64"), rather than silently resolving to `windows-x86_64`
143
+ (nothing on that host can execute those binaries) or falling through to
144
+ the generic "no artifact for this host" message a manifest-driven check
145
+ would otherwise give it, which does not name the WSL2 remedy.
146
+ """
147
+ m = machine.strip().lower()
148
+ if sys_platform.startswith("linux"):
149
+ if m in ("x86_64", "amd64"):
150
+ return "linux-x86_64"
151
+ if m in ("aarch64", "arm64"):
152
+ return "linux-aarch64"
153
+ return UnsupportedHost(f"linux-{m or 'unknown'}", f"no Zephyr SDK host build for linux-{m}")
154
+ if sys_platform == "darwin":
155
+ if m in ("arm64", "aarch64"):
156
+ return "macos-aarch64"
157
+ if m in ("x86_64", "amd64"):
158
+ return "macos-x86_64"
159
+ return UnsupportedHost(f"macos-{m or 'unknown'}", f"no Zephyr SDK host build for macos-{m}")
160
+ if sys_platform in ("win32", "cygwin"):
161
+ if m in ("amd64", "x86_64"):
162
+ return "windows-x86_64"
163
+ if m in ("arm64", "aarch64"):
164
+ return UnsupportedHost(
165
+ "windows-arm64",
166
+ "the pinned Zephyr SDK publishes no windows-arm64 host build (ADR 0021) -- "
167
+ # tan-cli#990 review MINOR: NOT "linux-x86_64 or linux-aarch64" --
168
+ # measured against both the vendored fixture and a live alp-sdk
169
+ # checkout, the pinned manifest publishes linux-x86_64 only (no
170
+ # linux-aarch64 row at all), so a WSL2-aarch64 reader following
171
+ # the old wording landed on this SAME host's own unsupported-host
172
+ # skip, one level down. Name the ONE host this pin can actually
173
+ # serve; widen this sentence again only once the manifest does.
174
+ "run `tan bootstrap` from a WSL2 distro (linux-x86_64) instead; see "
175
+ "docs/cross-platform-setup.md.",
176
+ )
177
+ return UnsupportedHost(f"windows-{m or 'unknown'}", f"no Zephyr SDK host build for windows-{m}")
178
+ return UnsupportedHost(sys_platform or "unknown", f"unrecognised host platform {sys_platform!r}")
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # The manifest -- `<sdkRoot>/metadata/toolchains.json`, read fresh every run
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ class ToolchainManifestError(Exception):
187
+ """`<sdkRoot>/metadata/toolchains.json` does not have the shape this
188
+ reader needs. Caught at the one call site (`toolchain_phase`) and reported
189
+ as a non-fatal, `WORKSPACE_BLOCKING`-eligible warning naming the file --
190
+ mirrors `BootstrapManifestError`'s own role for `bootstrap.json`."""
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class ToolchainArtifact:
195
+ host: str
196
+ component: str
197
+ filename: str
198
+ size_bytes: int
199
+ sha256: str
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class ToolchainManifest:
204
+ version: str
205
+ base_url: str
206
+ artifacts: tuple[ToolchainArtifact, ...]
207
+ #: `measuredFootprint.extractedBytes.wholeSdk`, when present. `None` on an
208
+ #: older manifest shape that predates it -- [`required_bytes`] treats that
209
+ #: as "cannot preflight", not as "zero bytes needed".
210
+ extracted_bytes_whole_sdk: int | None
211
+ #: The exact bytes read from disk -- what [`digest`] hashes. Kept
212
+ #: verbatim (not re-serialised) so the digest is of what alp-sdk actually
213
+ #: shipped, not of this reader's own re-encoding of it.
214
+ raw_text: str
215
+
216
+ def digest(self) -> str:
217
+ """A stable identity for "the pin this manifest declares" -- sha256 of
218
+ the file's own raw bytes. See the module docstring for why the WHOLE
219
+ file, not just the artifact rows."""
220
+ return hashlib.sha256(self.raw_text.encode("utf-8")).hexdigest()
221
+
222
+ def artifacts_for_host(self, host_key: str) -> tuple[ToolchainArtifact, ...]:
223
+ return tuple(a for a in self.artifacts if a.host == host_key)
224
+
225
+
226
+ def parse_toolchain_manifest(text: str) -> ToolchainManifest:
227
+ """`<sdkRoot>/metadata/toolchains.json`'s text -> a [`ToolchainManifest`],
228
+ or [`ToolchainManifestError`] naming exactly which required field is
229
+ missing or malformed -- never a bare `KeyError`/`TypeError` escaping to
230
+ the caller, matching `parse_bootstrap_manifest`'s own contract.
231
+ """
232
+ try:
233
+ doc = json.loads(text)
234
+ except ValueError as err:
235
+ raise ToolchainManifestError(f"not valid JSON: {err}") from err
236
+ if not isinstance(doc, dict):
237
+ raise ToolchainManifestError("top level is not a JSON object")
238
+ zephyr_sdk = doc.get("zephyrSdk")
239
+ if not isinstance(zephyr_sdk, dict):
240
+ raise ToolchainManifestError("missing object `zephyrSdk`")
241
+ version = zephyr_sdk.get("version")
242
+ base_url = zephyr_sdk.get("baseUrl")
243
+ if not isinstance(version, str) or not version:
244
+ raise ToolchainManifestError("missing non-empty string `zephyrSdk.version`")
245
+ if not isinstance(base_url, str) or not base_url:
246
+ raise ToolchainManifestError("missing non-empty string `zephyrSdk.baseUrl`")
247
+ raw_artifacts = zephyr_sdk.get("artifacts")
248
+ if not isinstance(raw_artifacts, list):
249
+ raise ToolchainManifestError("missing array `zephyrSdk.artifacts`")
250
+ artifacts = tuple(_parse_artifact(i, item) for i, item in enumerate(raw_artifacts))
251
+ extracted = _extracted_whole_sdk(doc)
252
+ return ToolchainManifest(version, base_url, artifacts, extracted, text)
253
+
254
+
255
+ def _parse_artifact(index: int, item: object) -> ToolchainArtifact:
256
+ if not isinstance(item, dict):
257
+ raise ToolchainManifestError(f"artifacts[{index}] is not an object")
258
+ try:
259
+ return ToolchainArtifact(
260
+ host=str(item["host"]),
261
+ component=str(item["component"]),
262
+ filename=str(item["filename"]),
263
+ size_bytes=int(item["sizeBytes"]),
264
+ sha256=str(item["sha256"]),
265
+ )
266
+ except (KeyError, TypeError, ValueError) as err:
267
+ raise ToolchainManifestError(f"artifacts[{index}] malformed: {err}") from err
268
+
269
+
270
+ def _extracted_whole_sdk(doc: dict) -> int | None:
271
+ footprint = doc.get("measuredFootprint")
272
+ if not isinstance(footprint, dict):
273
+ return None
274
+ extracted = footprint.get("extractedBytes")
275
+ if not isinstance(extracted, dict):
276
+ return None
277
+ whole = extracted.get("wholeSdk")
278
+ return whole if isinstance(whole, int) and not isinstance(whole, bool) else None
279
+
280
+
281
+ def artifacts_missing_for_host(manifest: ToolchainManifest, host_key: str) -> bool:
282
+ """True when the pinned manifest publishes NO artifact row for
283
+ `host_key` -- the general, manifest-driven form of the ADR's named Intel
284
+ Mac case (`macos-x86_64` simply has no row at the pinned version): a
285
+ coded, honest skip that needs no per-platform special-casing, and that
286
+ stays correct if alp-sdk adds/drops a host row later.
287
+ """
288
+ return len(manifest.artifacts_for_host(host_key)) == 0
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # The artifact-keyed store
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ def store_dir_name(version: str) -> str:
297
+ """`~/.alp/toolchains/<artifact>-<version>/`'s leaf name -- ADR 0021's own
298
+ worked example (`zephyr-sdk-0.17.0-arm-zephyr-eabi`). Keyed by ARTIFACT
299
+ (Zephyr SDK version + component), never by an alp-sdk checkout or SKU, so
300
+ two projects pinning the same Zephyr SDK version share one on-disk copy
301
+ ("Key the toolchain store by SDK version" -- rejected in the ADR's own
302
+ Alternatives for duplicating ~1-17 GB per release)."""
303
+ return f"zephyr-sdk-{version}-{TOOLCHAIN_COMPONENT}"
304
+
305
+
306
+ def wreckage_glob_pattern(leaf_name: str) -> str:
307
+ """The glob a caller runs INSIDE the toolchain root to find a prior
308
+ interrupted attempt at `leaf_name` worth reclaiming before starting a new
309
+ one. See [`TMP_SUFFIX_PREFIX`]."""
310
+ return f"{leaf_name}{TMP_SUFFIX_PREFIX}*"
311
+
312
+
313
+ @dataclass(frozen=True)
314
+ class ToolchainRoot:
315
+ #: Where `~/.alp/toolchains` (or the override) actually is.
316
+ path_str: str
317
+ #: True when `$ALP_TOOLCHAIN_ROOT` was set -- an ADOPTED root tan did not
318
+ #: necessarily create. Mirrors the venv-recreate rule's own exception:
319
+ #: wreckage reclamation ([`TMP_SUFFIX_PREFIX`]) still applies (that naming
320
+ #: pattern is tan's own and nothing else produces it), but a bare,
321
+ #: UNSTAMPED store directory under an adopted root is never assumed to be
322
+ #: tan's to delete and start over -- only a genuinely wreckage-suffixed
323
+ #: sibling is.
324
+ adopted: bool
325
+
326
+
327
+ def resolve_toolchain_root(env_override: str | None, home_alp_dir_str: str) -> ToolchainRoot:
328
+ """`$ALP_TOOLCHAIN_ROOT` when set and non-blank (ADR 0021's escape hatch
329
+ "for bench machines and CI"), else `<home_alp_dir>/toolchains`."""
330
+ if env_override and env_override.strip():
331
+ return ToolchainRoot(env_override.strip(), True)
332
+ return ToolchainRoot(f"{home_alp_dir_str.rstrip('/').rstrip(chr(92))}/toolchains", False)
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # The stamp -- written LAST, only after a real compiler probe succeeds
337
+ # ---------------------------------------------------------------------------
338
+
339
+
340
+ @dataclass(frozen=True)
341
+ class ToolchainStamp:
342
+ version: str
343
+ manifest_digest: str
344
+ #: The triple `arm-zephyr-eabi-gcc -dumpmachine` (or the fixed
345
+ #: `TOOLCHAIN_COMPONENT` string, when the probe output was not parsed)
346
+ #: reported -- carried through to the doctor detail line so a customer
347
+ #: reading `tan doctor`'s output sees which compiler was actually proven
348
+ #: to run, not just a version number.
349
+ target_triple: str
350
+ #: True only when this tan hashed the toolchain archive against the alp-sdk pin
351
+ #: (tan-cli#1496). Absent in older stamps -> False.
352
+ pin_checked: bool = False
353
+
354
+
355
+ def render_stamp(stamp: ToolchainStamp) -> str:
356
+ """The stamp file's exact bytes. `schemaVersion` for the same forward-
357
+ compatibility reason every other tan/alp-sdk fact file carries one."""
358
+ return (
359
+ json.dumps(
360
+ {
361
+ "schemaVersion": 1,
362
+ "version": stamp.version,
363
+ "manifestDigest": stamp.manifest_digest,
364
+ "targetTriple": stamp.target_triple,
365
+ "pinChecked": stamp.pin_checked,
366
+ },
367
+ indent=2,
368
+ sort_keys=True,
369
+ )
370
+ + "\n"
371
+ )
372
+
373
+
374
+ def parse_stamp(text: str) -> ToolchainStamp | None:
375
+ """`None` on ANY parse problem (missing file, invalid JSON, wrong shape)
376
+ -- an unreadable stamp is treated exactly like "no stamp", never a crash
377
+ and never a stale-but-trusted value."""
378
+ try:
379
+ doc = json.loads(text)
380
+ except ValueError:
381
+ return None
382
+ if not isinstance(doc, dict):
383
+ return None
384
+ version = doc.get("version")
385
+ digest = doc.get("manifestDigest")
386
+ triple = doc.get("targetTriple")
387
+ if not (isinstance(version, str) and isinstance(digest, str) and isinstance(triple, str)):
388
+ return None
389
+ return ToolchainStamp(version, digest, triple, doc.get("pinChecked") is True)
390
+
391
+
392
+ def stamp_matches_pin(stamp: ToolchainStamp | None, manifest: ToolchainManifest) -> bool:
393
+ """The ONE verdict function -- `False` for "no stamp" (`None`) and `False`
394
+ for a stamp naming a different version OR a manifest whose bytes rotated
395
+ under an unchanged version. Never asked "does the directory exist";
396
+ callers that only have a path must read+parse the stamp file first.
397
+
398
+ This is ADR 0021's "a stamped 1.0.1 store against a moved pin is a Fail
399
+ with a fix, not 'a toolchain exists'" made literal: `doctor`'s `toolchain`
400
+ check and `bootstrap`'s skip-if-already-installed step both call this,
401
+ and only this, so they cannot independently drift on what "still valid"
402
+ means.
403
+ """
404
+ if stamp is None:
405
+ return False
406
+ return stamp.version == manifest.version and stamp.manifest_digest == manifest.digest()
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # Disk preflight
411
+ # ---------------------------------------------------------------------------
412
+
413
+
414
+ def required_bytes(manifest: ToolchainManifest) -> int | None:
415
+ """Bytes tan should refuse to proceed without -- the manifest's own
416
+ measured extracted footprint plus [`DISK_MARGIN_RATIO`]. `None` when the
417
+ manifest predates `measuredFootprint` (an older alp-sdk pin): there is
418
+ nothing to preflight against, so the caller skips the check rather than
419
+ inventing a number -- an unmeasured guess would be exactly the ~17 GB
420
+ `pr-twister.yml` estimate ADR 0021 itself found wrong in both directions.
421
+ """
422
+ if manifest.extracted_bytes_whole_sdk is None:
423
+ return None
424
+ return int(manifest.extracted_bytes_whole_sdk * (1 + DISK_MARGIN_RATIO))
425
+
426
+
427
+ def _gib(num_bytes: int) -> str:
428
+ return f"{num_bytes / (1 << 30):.2f} GiB"
429
+
430
+
431
+ def disk_preflight_refusal(free_bytes: int, needed_bytes: int) -> str | None:
432
+ """`None` when there is enough room; otherwise a message naming BOTH
433
+ numbers -- "Refuse with a number" (ADR 0021's own phrase), never a bare
434
+ "not enough disk space"."""
435
+ if free_bytes >= needed_bytes:
436
+ return None
437
+ return (
438
+ f"only {_gib(free_bytes)} free, but the Zephyr SDK + {TOOLCHAIN_COMPONENT} "
439
+ f"toolchain need about {_gib(needed_bytes)} on disk (the pinned manifest's "
440
+ f"own measured footprint plus a {int(DISK_MARGIN_RATIO * 100)}% margin) -- "
441
+ "free up space and re-run `tan bootstrap`, or pass `--no-toolchain` to skip "
442
+ "cross-toolchain acquisition for now (native_sim and workspace setup are "
443
+ "unaffected)."
444
+ )
445
+
446
+
447
+ #: Below this, a `west sdk install` failure gets a disk-exhaustion note even
448
+ #: though the PREFLIGHT passed -- the preflight checks free space once,
449
+ #: before anything downloads; the download+extract itself is what actually
450
+ #: consumes it, and a host that barely cleared the preflight bar can still
451
+ #: run out mid-extraction (the archive and the extracted tree briefly
452
+ #: coexist, exactly the margin [`DISK_MARGIN_RATIO`] exists for, but a margin
453
+ #: is a cushion, not a guarantee on an already-tight host).
454
+ LOW_DISK_AFTER_FAILURE_FLOOR_BYTES = 512 * (1 << 20)
455
+
456
+
457
+ def low_disk_note(free_bytes: int) -> str | None:
458
+ """`None` when `free_bytes` is comfortably above
459
+ [`LOW_DISK_AFTER_FAILURE_FLOOR_BYTES`]; otherwise a note worth appending
460
+ to a `west sdk install` failure message -- disk exhaustion DURING
461
+ extraction reads identically to a dozen other causes, and is worth
462
+ naming as a candidate even now that `west sdk install`'s own capture
463
+ uses a wider tail (`bootstrap_cmd.TOOLCHAIN_INSTALL_TAIL_LINES`, tan-cli
464
+ #990 review) than `tan.core.bootstrap.capture_tail`'s 4-line default:
465
+ the free-space reading here is taken AFTER `west`'s own
466
+ `tempfile.TemporaryDirectory` cleanup already ran, so it can still miss
467
+ a failure that was genuinely disk-caused DURING extraction and only
468
+ looks fine again once the failed attempt's own partial state was
469
+ deleted.
470
+ """
471
+ if free_bytes >= LOW_DISK_AFTER_FAILURE_FLOOR_BYTES:
472
+ return None
473
+ return (
474
+ f"only {_gib(free_bytes)} free on this volume right now -- if the failure "
475
+ "above does not obviously name a cause, low disk space during extraction "
476
+ "is a likely one even though the preflight passed (the preflight checks "
477
+ "space ONCE, before anything is written; a host that barely cleared it can "
478
+ "still run out mid-extraction)."
479
+ )
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # `west sdk install` argv + failure classification
484
+ # ---------------------------------------------------------------------------
485
+
486
+
487
+ #: Passed on EVERY `west sdk install` tan spawns (tan-cli#1176), never
488
+ #: probed for. Without it `west` runs the pinned SDK's own `setup.sh -t
489
+ #: arm-zephyr-eabi -h`, whose bundled host-tools install needs `file(1)` on
490
+ #: `PATH`. `file` is NOT among the prerequisites the quickstart documents
491
+ #: (`cmake ninja-build python3 python3-pip python3-venv git curl xz-utils
492
+ #: wget`), so on a host carrying only those -- a bare `ubuntu:24.04` is the
493
+ #: measured case -- the step dies with:
494
+ #:
495
+ #: Installing host tools ...
496
+ #: ERROR: Host tools installation failed
497
+ #: FATAL ERROR: command "<sdk>/setup.sh -t arm-zephyr-eabi -h" failed
498
+ #:
499
+ #: `bootstrap_cmd._acquire_toolchain` then retries three times and `tan
500
+ #: bootstrap` exits 1 with "bootstrap: INCOMPLETE -- toolchain-install did not
501
+ #: install, so this workspace cannot build yet." The flag is what makes the
502
+ #: automatic acquisition path (tan-cli#990 / ADR 0021 Lane 1 P1) work on the
503
+ #: exact host class the quickstart documents, and it is what makes
504
+ #: `doctor_cmd`'s own note -- that `file` stays out of the prerequisites
505
+ #: manifest because "`tan bootstrap` genuinely does not need it" -- TRUE;
506
+ #: between #990 and #1176 that sentence was false. It is also the same flag
507
+ #: alp-sdk's `metadata/bootstrap.json` (`manualInstallHints.posix.note[0]`,
508
+ #: vendored at `contract/fixtures/bootstrap/manifest.json` and mirrored in
509
+ #: `tan.core.bootstrap`'s own hint text) and
510
+ #: `.github/workflows/onramp-clean-container.yml` have always used. NOT the
511
+ #: README's manual command, which is `west sdk install --version 1.0.1 -t
512
+ #: arm-zephyr-eabi` and carries no `--no-hosttools` on purpose: it mirrors
513
+ #: `doctor_cmd.zephyr_sdk_install_command()` / the frozen Rust oracle
514
+ #: verbatim, so it DOES run the host-tools step and DOES need `file` -- which
515
+ #: is exactly what the README says beside it.
516
+ #:
517
+ #: UNCONDITIONAL, and that is deliberate. Passing it only when `file(1)` is
518
+ #: absent would produce two differently-shaped installs -- one with
519
+ #: `hosttools/`, one without -- that are INDISTINGUISHABLE in the store: the
520
+ #: store is keyed by artifact and version alone
521
+ #: (`~/.alp/toolchains/zephyr-sdk-<version>-arm-zephyr-eabi/`) and the
522
+ #: verification stamp records the pinned version plus the manifest digest,
523
+ #: neither of which encodes the flag. A host that installed one shape and
524
+ #: later wanted the other would read a matching stamp and skip. One shape,
525
+ #: always.
526
+ #:
527
+ #: WHAT IT GIVES UP, stated rather than glossed: the whole `hosttools/`
528
+ #: bundle. Measured on a real `zephyr-sdk-1.0.1` install,
529
+ #: `hosttools/sysroots/x86_64-pokysdk-linux/usr/bin/` holds 39 binaries, among
530
+ #: them `dtc` (plus `fdtdump`/`fdtget`/`fdtoverlay`/`fdtput`), `openocd`,
531
+ #: `bossac` and the `qemu-system-*` family;
532
+ #: `contract/fixtures/toolchains/toolchains.json` measures the same bundle at
533
+ #: 1242632545 bytes, 61% of the 2026739200-byte SDK. Zephyr treats both `dtc`
534
+ #: and `openocd` as optional -- `cmake/modules/FindHostTools.cmake` says so in
535
+ #: as many words for openocd ("openocd is an optional dependency", a bare
536
+ #: `find_program(OPENOCD openocd)`) -- and the container job above builds a
537
+ #: real `zephyr.elf` with this flag and no `dtc`, `gperf` or `file` anywhere
538
+ #: on the host.
539
+ #:
540
+ #: The consequence worth naming beyond `dtc` is OPENOCD. `tan debug-config
541
+ #: --server openocd` fills `serverpath`/`searchDir` from `runners.yaml`'s
542
+ #: `config.openocd` / `config.openocd_search`, and Zephyr writes those keys
543
+ #: only inside `if(OPENOCD)` (`cmake/flash/CMakeLists.txt`), resolved from the
544
+ #: SDK's own `list(APPEND CMAKE_PREFIX_PATH ${HOST_TOOLS_HOME}/usr)`
545
+ #: (`<sdk>/cmake/zephyr/host-tools.cmake`). With no `hosttools/` and no system
546
+ #: OpenOCD both keys are simply ABSENT -- never `OPENOCD-NOTFOUND` -- so
547
+ #: `debug_launch.apply_launch_resolution` inserts neither (both are additive,
548
+ #: neither is a `<resolved-...>` placeholder) and `_preview_notes_for`'s
549
+ #: "Placeholder fields" warning does not fire. The emitted configuration stays
550
+ #: valid and `configFiles` still resolves (it comes from `board.cmake`, not
551
+ #: from `hosttools/`); cortex-debug then falls back to its own
552
+ #: `openocd`-on-`PATH` lookup. NO LONGER SILENT (tan-cli#1179): a host with no
553
+ #: `openocd` on `PATH` either used to learn this from the debug adapter at
554
+ #: session start, with `tan` nowhere in the loop -- `debug_config_cmd`'s
555
+ #: `_openocd_hosttools_note` now says it in `data.notes` instead, naming this
556
+ #: flag and that OpenOCD has to come from the system. It stays a NOTE, not a
557
+ #: failure: one advisory field is missing, nothing else is, and a host that
558
+ #: does have `openocd` on `PATH` (or a `serverpath` that resolved) hears
559
+ #: nothing. `tan support-bundle` probes `openocd` on `PATH` too -- through the
560
+ #: very table and lookup that note borrows.
561
+ #:
562
+ #: The `dtc` half of the same shape is STILL silent and is tracked as
563
+ #: tan-cli#1192: `cmake/modules/dts.cmake` runs the devicetree lint only
564
+ #: `if(DTC)`, so with no `hosttools/` and no system `dtc` the
565
+ #: `-Wunique_unit_address_if_enabled` / `-E unit_address_vs_reg` pass stops
566
+ #: running and nothing says so. It is a `tan doctor` CHECK rather than a note,
567
+ #: which is why tan-cli#1179 did not carry it: it moves that command's verdict,
568
+ #: and after this flag a CORRECT install has no `dtc` at all, so the naive
569
+ #: on-PATH predicate would warn on every correctly-provisioned host.
570
+ NO_HOSTTOOLS_FLAG = "--no-hosttools"
571
+
572
+
573
+ #: tan-cli#1496: west installs ONLY the (west-verified) minimal bundle; the
574
+ #: toolchain archive is fetched and hashed by tan itself, because the bundle's
575
+ #: `setup.sh` downloads it with `wget` and no integrity check.
576
+ NO_GNU_TOOLCHAINS_FLAG = "--no-gnu-toolchains"
577
+
578
+
579
+ def west_sdk_install_argv(west: str, *, version: str, install_dir: str) -> list[str]:
580
+ """The one place this argv is assembled -- `--no-gnu-toolchains`, not the
581
+ deprecated `--no-toolchains` alias (`scripts/west_commands/sdk.py`: the
582
+ deprecated spelling only warns-and-aliases today, but a customer reading
583
+ `data.plannedCommands` under `--dry-run` should see the command tan will
584
+ actually run). The toolchain itself is then acquired by tan
585
+ ([`NO_GNU_TOOLCHAINS_FLAG`]).
586
+
587
+ [`NO_HOSTTOOLS_FLAG`] rides on every one of these, unconditionally --
588
+ see its own comment for the `file(1)` failure it prevents (tan-cli#1176),
589
+ why it is not probed for, and what it gives up (`dtc`, OpenOCD).
590
+ """
591
+ return [
592
+ west,
593
+ "sdk",
594
+ "install",
595
+ "--version",
596
+ version,
597
+ NO_GNU_TOOLCHAINS_FLAG,
598
+ NO_HOSTTOOLS_FLAG,
599
+ "--install-dir",
600
+ install_dir,
601
+ ]
602
+
603
+
604
+ def augment_acquisition_failure(detail: str) -> str:
605
+ """Append the proxy/CA-interference hint (#304's `_TLS_HINT` lesson,
606
+ applied to THIS download) when `detail` -- the captured `west sdk
607
+ install` failure text -- names a checksum mismatch. `west` itself
608
+ compares downloads against the release's own published `sha256.sum`
609
+ (tan checks alp-sdk's pin against that sum BEFORE the install) and raises `sha256 mismatched: <want>:<got>` on a
610
+ disagreement -- which reads exactly like "the upstream archive is
611
+ corrupt" unless a reader is told a TLS-terminating middlebox rewriting
612
+ the byte stream produces the identical symptom.
613
+ """
614
+ lower = detail.lower()
615
+ if "sha256" in lower or "checksum" in lower:
616
+ return (
617
+ f"{detail} This can mean a TLS-intercepting proxy or corporate CA "
618
+ "rewrote the download in flight rather than the upstream archive "
619
+ "being corrupt -- check ALL_PROXY/HTTPS_PROXY/NO_PROXY, or retry "
620
+ "from a network path that does not intercept TLS."
621
+ )
622
+ return detail
623
+
624
+
625
+ # ---------------------------------------------------------------------------
626
+ # Authenticating the SDK download (tan-cli#1143)
627
+ # ---------------------------------------------------------------------------
628
+
629
+ #: The environment variables `tan bootstrap` reads a GitHub credential from,
630
+ #: in precedence order, for the `west sdk install` download ONLY.
631
+ #:
632
+ #: **An environment variable, not a CLI flag, and not a manifest field.** A
633
+ #: token is a secret, and the three candidate surfaces are not equally safe
634
+ #: for one:
635
+ #:
636
+ #: - a CLI flag lands the value in shell history, in the host process table
637
+ #: for the whole (multi-minute) run, and in any CI log that echoes the
638
+ #: command -- and `tan`'s own argv is what a customer pastes into a bug
639
+ #: report;
640
+ #: - a manifest field puts it in a file people commit;
641
+ #: - an environment variable is the one surface that survives none of those,
642
+ #: and is already how every workflow in this repo carries `GH_TOKEN`.
643
+ #:
644
+ #: The precedence deliberately DEFERS to the existing credential sources
645
+ #: rather than inventing a tan-only concept: `GH_TOKEN` then `GITHUB_TOKEN`
646
+ #: is `gh`'s own order, so a developer who has run `gh auth login`, and a
647
+ #: GitHub Actions job that already exports the workflow token, both get an
648
+ #: authenticated download with nothing new to set. `TAN_GITHUB_TOKEN` leads
649
+ #: so a host with an ambient `GH_TOKEN` bound to some other account can
650
+ #: override it for tan alone.
651
+ SDK_TOKEN_ENV_VARS: tuple[str, ...] = ("TAN_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
652
+
653
+ #: The env var `requests` reads a netrc path from -- `requests.utils.
654
+ #: get_netrc_auth` consults `os.environ["NETRC"]` FIRST and falls back to
655
+ #: `~/.netrc` only when it is unset. This is the whole reason the token can
656
+ #: travel to `west` out of band: `west sdk install` reads a token from
657
+ #: `--personal-access-token` and from NOTHING else (measured against Zephyr
658
+ #: v4.4.1 `scripts/west_commands/sdk.py:473` -- it consults no environment
659
+ #: variable of its own; `grep environ` across that file finds only
660
+ #: `ZEPHYR_BASE` and `ZEPHYR_SDK_INSTALL_DIR`), but its GitHub call goes
661
+ #: through `requests`, and `requests` applies netrc credentials whenever the
662
+ #: request carries no `Authorization` header -- which is exactly west's
663
+ #: `req_headers = {}` branch. West's own rate-limit message names the
664
+ #: mechanism: "Try executing install script with --personal-access-token
665
+ #: argument **or use a .netrc file**".
666
+ #:
667
+ #: **This is STRICTLY NARROWER than the flag it replaces, and that is a
668
+ #: feature, not a consolation.** `--personal-access-token` puts an
669
+ #: `Authorization: Bearer` header on west's `requests` session, and
670
+ #: `requests` carries a session header across a redirect to a different host
671
+ #: unless the caller strips it; a netrc credential is matched PER MACHINE, and
672
+ #: [`netrc_text`] names only [`GITHUB_API_HOST`]. So the token is offered to
673
+ #: the rate-limited `fetch_releases` call and to nothing else -- not to the
674
+ #: release CDN the multi-hundred-megabyte archive is actually fetched from,
675
+ #: not to anything else the child talks to.
676
+ #:
677
+ #: **It REPLACES rather than augments the caller's own netrc**, for the
678
+ #: duration of that one child. `get_netrc_auth` sets
679
+ #: `netrc_locations = (netrc_file,)` when `$NETRC` is set -- there is no
680
+ #: `~/.netrc` fallback behind it -- so a caller whose own `~/.netrc`
681
+ #: authenticates some other host loses it inside `west sdk install`.
682
+ #: Accepted rather than worked around: merging their file into ours would
683
+ #: mean copying THEIR secrets into a file tan wrote, which is a worse trade
684
+ #: than one child process not seeing an unrelated credential, and the child
685
+ #: in question talks to GitHub and its CDN and nothing else. Only ever set
686
+ #: when tan actually has a token; an unauthenticated run passes no env at all.
687
+ #:
688
+ #: **Staging touches disk.** The token is written to a real file (see
689
+ #: `bootstrap_cmd._stage_sdk_credential`), not held in memory -- mode 0600
690
+ #: inside a 0700 directory under the toolchain root, deleted in a `finally`
691
+ #: and swept on the next run if a crash skipped that.
692
+ #:
693
+ #: **RESIDUAL RISK, recorded because tan pins neither west nor requests --
694
+ #: and CHECKED AT RUNTIME since tan-cli#1154.** Two external behaviours make
695
+ #: this work and neither is under a version constraint tan controls: (1)
696
+ #: `requests.utils.get_netrc_auth` reading `$NETRC` (measured on 2.34.2, which
697
+ #: is also the current release on PyPI; present since 2.15), and (2) west's
698
+ #: no-token branch leaving `req_headers` EMPTY (measured on Zephyr v4.4.1
699
+ #: `sdk.py:478`, unchanged at v4.4.2 `:478` and on `zephyrproject/main`
700
+ #: @8a4e180b93e `:479`).
701
+ #:
702
+ #: **(2)'s MECHANISM was stated wrongly here and the correction is recorded
703
+ #: rather than quietly swapped in.** An earlier revision said `requests`
704
+ #: "skips netrc entirely for a request that already carries an
705
+ #: `Authorization` header". Measured against 2.34.2 -- a real `http.server`, a
706
+ #: real netrc, the header read back off the wire -- it does not.
707
+ #: `Session.prepare_request` gates netrc on `self.trust_env and not auth and
708
+ #: not self.auth` -- the `auth=` ARGUMENT and the session's `trust_env`, never
709
+ #: the headers; `PreparedRequest.prepare` runs `prepare_auth` AFTER
710
+ #: `prepare_headers`; so a netrc match OVERWRITES an `Authorization` header
711
+ #: passed through `headers=`, which is exactly how west passes `req_headers`.
712
+ #: A future west that sets a header on that branch therefore does not make
713
+ #: TAN's credential inert -- it makes WEST'S inert, under tan's, silently.
714
+ #: Either way the user is authenticated as something other than what they were
715
+ #: told, and either way the answer is the same: stop claiming authentication
716
+ #: and say so.
717
+ #:
718
+ #: The corollary matters more than the correction, because it says what a
719
+ #: SUPPRESSED netrc actually looks like: the only two shapes that stop tan's
720
+ #: credential reaching the wire are the `auth=` argument (netrc skipped,
721
+ #: west's own credential goes out instead) and `trust_env=False` (measured:
722
+ #: `Authorization: None`, nothing goes out at all). Headers are not one of
723
+ #: them. Anything downstream that tells a reader otherwise is wrong -- see
724
+ #: [`_AUTHENTICATED_QUOTA_NOTE`], which said exactly that until tan-cli#1170,
725
+ #: two rounds after this note was corrected: a docstring fix that leaves the
726
+ #: customer-facing copy of the same claim standing has fixed nothing.
727
+ #:
728
+ #: There is no CI gate for this: `requests` is not a tan dependency
729
+ #: (`pyproject.toml` -- `import requests` is a `ModuleNotFoundError` in a
730
+ #: CI-shaped venv) and `ci.yml` checks out alp-sdk but never zephyr, so a
731
+ #: CI-time gate would SKIP everywhere, which is a dead gate rather than a real
732
+ #: one. The RUNTIME check is [`west_sdk_netrc_assumptions_hold`], driven by
733
+ #: `bootstrap_cmd._west_sdk_netrc_drift` against the `zephyr_base` the
734
+ #: bootstrap has already resolved: it runs where the artifact exists and
735
+ #: nowhere else, and downgrades the "Authenticating ..." line to a
736
+ #: `bootstrap.sdk-credential-unverified` warning when it trips. It watches for
737
+ #: BOTH suppressing shapes named above and for the flag the token arrives on
738
+ #: -- read its own docstring for the bound on that, which is real: a west that
739
+ #: stops using `requests` at all satisfies every fact it checks.
740
+ #: [`rate_limit_note`]'s authenticated branch remains the second half of this
741
+ #: -- it is the moment a suppressed transport becomes observable, when a
742
+ #: credentialled download is rate-limited anyway. Re-verify with
743
+ #: `grep -n "req_headers" <zephyr>/scripts/west_commands/sdk.py` and
744
+ #: `python -c "import inspect,requests as r; print(inspect.getsource(
745
+ #: r.sessions.Session.prepare_request))"` -- the gate itself, not just
746
+ #: `get_netrc_auth`, since the gate is where `auth=` and `trust_env` bite.
747
+ NETRC_ENV_VAR = "NETRC"
748
+
749
+ #: The source line `west sdk install`'s NO-TOKEN branch leaves behind -- the
750
+ #: `else:` arm of `if args.personal_access_token:`. See
751
+ #: [`west_sdk_netrc_assumptions_hold`].
752
+ WEST_SDK_NO_TOKEN_HEADERS = "req_headers = {}"
753
+
754
+ #: The attribute that `else:` is the arm of. Measured present in the real file
755
+ #: at Zephyr v4.4.1 `sdk.py:473`. Two jobs: it is what makes
756
+ #: [`WEST_SDK_NO_TOKEN_HEADERS`] mean anything (a `req_headers = {}` that is no
757
+ #: longer that branch proves nothing about the no-token path), and its
758
+ #: DISAPPEARANCE is the "west grew a credential of its own" drift -- a west
759
+ #: that reads a token from a config file rather than this flag can leave every
760
+ #: other fact below satisfied while tan's netrc is no longer what authenticates
761
+ #: the download.
762
+ WEST_SDK_TOKEN_FLAG_ATTR = "args.personal_access_token"
763
+
764
+ #: Every environment variable `scripts/west_commands/sdk.py` is known to read
765
+ #: (`sdk.py:231` and `:573` at Zephyr v4.4.1). No credential among them, which
766
+ #: is the entire reason tan's token has to travel by netrc rather than by an
767
+ #: environment variable west would read directly.
768
+ WEST_SDK_KNOWN_ENV_READS = frozenset({"ZEPHYR_BASE", "ZEPHYR_SDK_INSTALL_DIR"})
769
+
770
+ #: What [`west_sdk_netrc_assumptions_hold`]'s environment-allowlist fact
771
+ #: actually sees, and what it does not. It matches TEXT, not reads: measured
772
+ #: against a copy of the real v4.4.1 file it TRIPS on a bare COMMENT
773
+ #: mentioning `os.environ.get("ZEPHYR_TOOLCHAIN_VARIANT")`, and it stays
774
+ #: SILENT on `from os import environ; environ.get("WEST_GITHUB_TOKEN")` and on
775
+ #: `os.environ.get(_TOKEN_VAR)` -- an indirect name it cannot resolve. The
776
+ #: substring facts have the same two-sided character: `req_headers = dict()`
777
+ #: and `req_headers: dict = {}` both TRIP [`WEST_SDK_NO_TOKEN_HEADERS`]
778
+ #: despite changing nothing that matters. Widening the regex to close the
779
+ #: silences would cost more false trips, which is the trade this file has
780
+ #: already made in the other direction -- but a reader must not take silence
781
+ #: for proof.
782
+ _WEST_SDK_ENV_READ = re.compile(
783
+ r"""os\.(?:environ(?:\.get)?|getenv)\s*[(\[]\s*["']([A-Za-z_][A-Za-z0-9_]*)"""
784
+ )
785
+
786
+ #: An `auth=` keyword argument anywhere in the file. THE shape that makes
787
+ #: `requests` skip netrc outright -- `Session.prepare_request` gates the netrc
788
+ #: lookup on `self.trust_env and not auth and not self.auth`, re-measured on
789
+ #: 2.34.2 -- so a west that grows one takes tan's credential out of the request
790
+ #: entirely and puts its own in. Word-bounded, so `oauth=` is not a match;
791
+ #: measured ABSENT from the real v4.4.1 file, whose three `requests.get` calls
792
+ #: pass only `url`, `headers=`, `params=` and `stream=` (`sdk.py:266`, `:332`,
793
+ #: `:340`).
794
+ #:
795
+ #: Two-sided in the same way [`_WEST_SDK_ENV_READ`] is, and recorded here for
796
+ #: the same reason: it matches TEXT, so a comment, a help string or an
797
+ #: unrelated helper's `auth=None` default TRIPS it, while a call that reaches
798
+ #: the same place indirectly (`**kwargs`, a wrapper that adds the argument
799
+ #: itself) is SILENT. Fail-toward-telling is the chosen direction, but the
800
+ #: silence is real.
801
+ _WEST_SDK_REQUESTS_AUTH_ARG = re.compile(r"\bauth\s*=")
802
+
803
+ #: The other half of that gate. A `Session(trust_env=False)` was measured to
804
+ #: send `Authorization: None` -- no netrc read at all, and unlike the `auth=`
805
+ #: case nothing of west's replacing it, so the download simply goes out on the
806
+ #: anonymous quota. Matched as a bare substring rather than a call shape, so
807
+ #: `s.trust_env = False` on a session built elsewhere trips it too. Measured
808
+ #: absent from the real v4.4.1 file, which constructs no `Session` and calls
809
+ #: the module-level `requests.get`.
810
+ WEST_SDK_TRUST_ENV = "trust_env"
811
+
812
+
813
+ def west_sdk_netrc_assumptions_hold(source: str) -> bool:
814
+ """Whether `source` -- the text of `<zephyr_base>/scripts/west_commands/
815
+ sdk.py` -- still matches what [`NETRC_ENV_VAR`]'s residual-risk note was
816
+ measured against.
817
+
818
+ **A TRIPWIRE, not a parser** (tan-cli#1154). It answers one question --
819
+ "is this the shape the netrc route was reasoned about?" -- and deliberately
820
+ does not model west's control flow. Four substring-level facts:
821
+
822
+ 1. [`WEST_SDK_TOKEN_FLAG_ATTR`] is still present -- west's own token still
823
+ arrives on `--personal-access-token`, not from a config file.
824
+ 2. [`WEST_SDK_NO_TOKEN_HEADERS`] is still present -- that flag's `else:`
825
+ arm still sends an empty header dict.
826
+ 3. Nothing passes `requests` an [`_WEST_SDK_REQUESTS_AUTH_ARG`] and nothing
827
+ mentions [`WEST_SDK_TRUST_ENV`] -- the only two shapes MEASURED to stop
828
+ tan's netrc reaching the wire.
829
+ 4. Every environment variable the file reads is in
830
+ [`WEST_SDK_KNOWN_ENV_READS`]. (Nothing here checks that the flag still
831
+ feeds a header; fact 1 is the whole of what stands behind it.)
832
+
833
+ **Fact 3 is why 1, 2 and 4 are not enough.** An `Authorization` header on
834
+ the no-token branch does NOT make tan's credential inert -- the netrc match
835
+ OVERWRITES it (see [`NETRC_ENV_VAR`]) -- so a check watching only for that
836
+ would guard none of the vectors this predicate exists for.
837
+
838
+ **It fails toward telling the user, but only for shapes its substrings and
839
+ its regex see, and both directions are real** -- the measured trips and the
840
+ measured silences are recorded on [`_WEST_SDK_ENV_READ`]. The false trip is
841
+ the direction to prefer: it costs one warning and an issue report, while a
842
+ missed one costs a user who is told they are authenticated and is not.
843
+
844
+ **STILL UNCOVERED, said plainly rather than left to be inferred.** Two
845
+ drifts satisfy all four facts: a west that stops using `requests`
846
+ altogether (`urllib`, `httpx`), or moves the GitHub call into a module this
847
+ file merely imports -- `$NETRC` then means nothing and no single-file
848
+ substring check can see it -- and a PARTIAL move off
849
+ `--personal-access-token`, since fact 1 is a substring and a rewrite
850
+ leaving `args.personal_access_token` in a deprecation shim or a help string
851
+ still passes it (measured: replacing BOTH of `sdk.py:473`/`:475` trips it,
852
+ replacing only `:473` does not). The absence of a
853
+ `bootstrap.sdk-credential-unverified` warning is not evidence that neither
854
+ happened.
855
+ """
856
+ if WEST_SDK_TOKEN_FLAG_ATTR not in source:
857
+ return False
858
+ if WEST_SDK_NO_TOKEN_HEADERS not in source:
859
+ return False
860
+ if WEST_SDK_TRUST_ENV in source or _WEST_SDK_REQUESTS_AUTH_ARG.search(source):
861
+ return False
862
+ return not (set(_WEST_SDK_ENV_READ.findall(source)) - WEST_SDK_KNOWN_ENV_READS)
863
+
864
+
865
+ def west_sdk_netrc_drift_message(var: str, version: str) -> str:
866
+ """The `bootstrap.sdk-credential-unverified` message.
867
+
868
+ **Names what the reader should DO, never what west does internally**
869
+ (tan-cli#1154 acceptance). `req_headers`, `os.environ` and
870
+ `prepare_request` are facts about somebody else's file; a customer whose
871
+ SDK download is about to go out on the anonymous quota needs the two
872
+ commands that get them unstuck and the URL that gets tan fixed.
873
+ """
874
+ return (
875
+ "this workspace's Zephyr handles `west sdk install` credentials "
876
+ "differently from the version tan was measured against, so tan cannot "
877
+ f"confirm the token in ${var} reaches the download -- treat this run as "
878
+ "unauthenticated. If it fails on a GitHub rate limit, retry from a "
879
+ "network with its own egress address, or run `west sdk install "
880
+ f"--version {version} -t {TOOLCHAIN_COMPONENT}` by hand from your west "
881
+ "workspace's top-level directory with a `~/.netrc` entry for "
882
+ f"{GITHUB_API_HOST}. Please report this at "
883
+ "https://github.com/alplabai/tan-cli/issues so tan can be updated."
884
+ )
885
+
886
+
887
+ #: The host `west sdk install`'s rate-limited call goes to (`fetch_releases`
888
+ #: against the GitHub REST API). The staged netrc names ONLY this machine, so
889
+ #: the credential is never offered to the release CDN the archive itself is
890
+ #: fetched from, nor to anything else the child happens to talk to.
891
+ GITHUB_API_HOST = "api.github.com"
892
+
893
+ #: The prefix of the private directory the staged netrc lives in, created
894
+ #: under the TOOLCHAIN ROOT rather than under `$TMPDIR` -- see
895
+ #: [`netrc_scratch_glob_pattern`] for why that placement is what makes the
896
+ #: crash-residue sweep safe. Leading dot, so it can never collide with a
897
+ #: [`store_dir_name`] result or with a [`TMP_SUFFIX_PREFIX`] sibling.
898
+ NETRC_SCRATCH_PREFIX = ".alp-netrc-"
899
+
900
+
901
+ def netrc_scratch_glob_pattern() -> str:
902
+ """The glob that reclaims a PRIOR run's staged credential -- the
903
+ crash-residue half of the cleanup, since a `finally` does not run on
904
+ SIGKILL, an OOM kill or power loss, and a secret accumulating one copy
905
+ per crash with nothing sweeping it is the wrong steady state.
906
+
907
+ The reason this pattern is rooted at the toolchain root and not at
908
+ `$TMPDIR` is that the sweep's only proof of provenance is the NAME, and a
909
+ name is only proof inside a namespace tan owns. `$TMPDIR` is shared by
910
+ every process on the box, so a `.alp-netrc-*` found there is not
911
+ necessarily one tan wrote; the toolchain root (or the directory
912
+ `$ALP_TOOLCHAIN_ROOT` deliberately named) is the same ownership argument
913
+ [`wreckage_glob_pattern`] already relies on, so this sweep is exactly as
914
+ well-founded as the one running beside it.
915
+
916
+ **Not, as an earlier revision claimed, because sweeping `/tmp` would be
917
+ exploitable** -- corrected on measurement (tan-cli#1148 round 2): `/tmp`
918
+ is `0o1777`, but `shutil.rmtree.avoids_symlink_attacks` is `True` here,
919
+ so no escalation was ever demonstrated and this should not have asserted
920
+ one. What the move measurably DID buy is a swept namespace that is
921
+ NARROWER than `$TMPDIR`, not one that is unconditionally private, and the
922
+ difference is the caller's `umask`. Measured under `umask 022`: root
923
+ `0o755`, scratch `0o700`, netrc `0o600` -- no other user can create a
924
+ `.alp-netrc-*` here. Measured under `umask 002` (this repo's own CI and
925
+ developer default): root `0o775`, so the root is GROUP-WRITABLE and a
926
+ member of that group can. The credential itself is unreadable either way
927
+ (`mkdtemp` forces `0o700` and the netrc `0o600` regardless of umask); the
928
+ part that varies is who can put a directory into the namespace the sweep
929
+ globs.
930
+
931
+ Two residuals follow from that and are recorded rather than claimed away:
932
+ a group-writable root under `umask 002`, and an `$ALP_TOOLCHAIN_ROOT`
933
+ that the operator deliberately pointed at a SHARED directory. In both,
934
+ the sweep can be handed a `.alp-netrc-*` tan did not write. What that
935
+ buys an attacker is bounded by what the sweep does -- `rmtree` of a
936
+ directory, with `avoids_symlink_attacks` true -- so it is a nuisance
937
+ (deleting their own planted directory) rather than an escalation; but
938
+ "narrower than `$TMPDIR`, and private at `umask 022`" is the honest
939
+ statement, not "not creatable at all".
940
+ """
941
+ return f"{NETRC_SCRATCH_PREFIX}*"
942
+
943
+
944
+ #: netrc's `login` for a GitHub token. GitHub ignores the username on a
945
+ #: token-as-password Basic credential; `x-access-token` is the spelling
946
+ #: GitHub's own docs use for it.
947
+ NETRC_LOGIN = "x-access-token"
948
+
949
+ #: A token this module is willing to write into a netrc file. Deliberately a
950
+ #: WHITELIST, not a blacklist of separators: `netrc`'s parser is
951
+ #: whitespace-and-quote-sensitive, so a value carrying a space, a newline or
952
+ #: a `#` does not merely fail to authenticate -- it can make the parser
953
+ #: mis-split the file, and `netrc.NetrcParseError` quotes the offending token
954
+ #: back in its message, which is precisely a secret arriving in a child's
955
+ #: stderr and from there in `capture_tail`. Every GitHub credential format
956
+ #: (`ghp_`/`gho_`/`ghs_`/`github_pat_`, and a bare 40-hex classic token) is
957
+ #: inside this class, so the guard costs nothing real and closes the
958
+ #: injection path completely.
959
+ _SDK_TOKEN_ALLOWED = set(
960
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."
961
+ )
962
+
963
+
964
+ @dataclass(frozen=True)
965
+ class SdkToken:
966
+ """A usable GitHub credential and the NAME of the variable it came from.
967
+
968
+ `source` is the only half of this that is ever printed, logged or put in
969
+ an envelope: it is a variable name the customer chose, never the secret.
970
+ """
971
+
972
+ #: The secret. Never logged, never in an argv, never in an envelope.
973
+ value: str
974
+ #: The environment variable it was read from -- safe to print.
975
+ source: str
976
+
977
+
978
+ def usable_sdk_token(raw: str | None) -> str | None:
979
+ """`raw` stripped, or `None` when it is empty or carries a character
980
+ [`_SDK_TOKEN_ALLOWED`] refuses. Surrounding whitespace is forgiven (a
981
+ `GH_TOKEN` set from a file read is routinely `"...\\n"`); interior
982
+ whitespace is not, because a value that cannot be written into a netrc
983
+ line unambiguously must not be written into one at all."""
984
+ if raw is None:
985
+ return None
986
+ token = raw.strip()
987
+ if not token:
988
+ return None
989
+ if any(ch not in _SDK_TOKEN_ALLOWED for ch in token):
990
+ return None
991
+ return token
992
+
993
+
994
+ def resolve_sdk_token(environ: Mapping[str, str]) -> SdkToken | None:
995
+ """The first usable credential among [`SDK_TOKEN_ENV_VARS`], in order, or
996
+ `None` when the environment names none -- in which case the download
997
+ stays exactly as unauthenticated as it has always been. A variable that
998
+ is set but empty, or set to something [`usable_sdk_token`] refuses, is
999
+ SKIPPED rather than ending the search: `GITHUB_TOKEN=` is what an unset
1000
+ workflow secret expands to, and letting that shadow a perfectly good
1001
+ `GH_TOKEN` behind it would be the surprise."""
1002
+ for name in SDK_TOKEN_ENV_VARS:
1003
+ token = usable_sdk_token(environ.get(name))
1004
+ if token is not None:
1005
+ return SdkToken(token, name)
1006
+ return None
1007
+
1008
+
1009
+ def rejected_sdk_token_vars(environ: Mapping[str, str]) -> tuple[str, ...]:
1010
+ """The [`SDK_TOKEN_ENV_VARS`] that are SET to a non-blank value this
1011
+ module will not use -- [`usable_sdk_token`] refused the characters.
1012
+
1013
+ Exists so the refusal is not silent. The realistic way to land here is a
1014
+ `.env` line or a shell export that kept its literal quotes
1015
+ (`GH_TOKEN="ghp_..."` read by something that does not strip them), and
1016
+ the symptom without this is a download that is anonymous for a reason
1017
+ nothing on screen names, followed by a remedy telling the reader to set
1018
+ a variable they can see is already set.
1019
+
1020
+ A variable that is unset or blank is NOT reported: `GITHUB_TOKEN=` is
1021
+ what an unset workflow secret expands to, and warning about it on every
1022
+ CI run would be noise, not signal.
1023
+ """
1024
+ return tuple(
1025
+ name
1026
+ for name in SDK_TOKEN_ENV_VARS
1027
+ if (environ.get(name) or "").strip() and usable_sdk_token(environ.get(name)) is None
1028
+ )
1029
+
1030
+
1031
+ def shadowed_sdk_token_vars(
1032
+ environ: Mapping[str, str], winner: SdkToken | None
1033
+ ) -> tuple[str, ...]:
1034
+ """The [`rejected_sdk_token_vars`] that would have WON precedence over
1035
+ `winner` -- all of them when `winner` is `None`.
1036
+
1037
+ `winner` is a REQUIRED argument rather than something this re-derives:
1038
+ the one caller has already called [`resolve_sdk_token`], and resolving
1039
+ the same three variables twice invites the two answers to disagree the
1040
+ day anything about the environment is not stable between them
1041
+ (tan-cli#1148 round 3).
1042
+
1043
+ This, not the raw rejected set, is what is worth telling a customer
1044
+ about. A variable BEHIND the one that won was never going to be consulted
1045
+ (`resolve_sdk_token` takes the first usable and stops), so reporting it
1046
+ is noise about a value that changed nothing; a variable AHEAD of it lost
1047
+ a race it would otherwise have won, which is a real surprise worth
1048
+ naming.
1049
+ """
1050
+ order = SDK_TOKEN_ENV_VARS
1051
+ limit = order.index(winner.source) if winner is not None else len(order)
1052
+ return tuple(name for name in rejected_sdk_token_vars(environ) if order.index(name) < limit)
1053
+
1054
+
1055
+ #: What a customer is told about a variable [`usable_sdk_token`] refused.
1056
+ #: `{name}` is the variable, never the value -- a rejected token is as much a
1057
+ #: secret as an accepted one.
1058
+ _UNUSABLE_TOKEN_PREFIX = (
1059
+ "${name} is set but is not a value tan can use as a GitHub token (letters, "
1060
+ "digits, `_`, `-` and `.` only -- a quoted `.env` value keeps its quotes)"
1061
+ )
1062
+
1063
+ #: The two possible ENDINGS, chosen from what actually happened rather than
1064
+ #: from what was about to be attempted. tan-cli#1148 review round 2: this
1065
+ #: warning used to be raised before the resolve, so with
1066
+ #: `TAN_GITHUB_TOKEN='"quoted"'` AND a good `GH_TOKEN` the envelope carried a
1067
+ #: registered issue code asserting the download "will go out unauthenticated"
1068
+ #: while it went out authenticated. A wire surface stating the opposite of
1069
+ #: what happened is worse than the silence this warning replaced.
1070
+ _UNUSABLE_TOKEN_FELL_BACK = "; tan authenticated the download with ${fallback} instead."
1071
+ _UNUSABLE_TOKEN_UNAUTHENTICATED = "; the Zephyr SDK download will go out unauthenticated."
1072
+
1073
+
1074
+ def unusable_token_message(name: str, *, authenticated_as: str | None) -> str:
1075
+ """The `bootstrap.sdk-credential-unstaged` message for one refused
1076
+ variable. `authenticated_as` is the variable whose token really reached
1077
+ the download by the time this is rendered -- `None` if none did."""
1078
+ text = _UNUSABLE_TOKEN_PREFIX.format(name=name)
1079
+ if authenticated_as is not None:
1080
+ return text + _UNUSABLE_TOKEN_FELL_BACK.format(fallback=authenticated_as)
1081
+ return text + _UNUSABLE_TOKEN_UNAUTHENTICATED
1082
+
1083
+
1084
+ def netrc_text(token: str) -> str:
1085
+ """The netrc document staged for the `west sdk install` child -- one
1086
+ machine, [`GITHUB_API_HOST`], and nothing else. Trailing newline: Python's
1087
+ `netrc` parser is line-oriented and a file whose last line is unterminated
1088
+ is a needless edge case."""
1089
+ return f"machine {GITHUB_API_HOST}\n login {NETRC_LOGIN}\n password {token}\n"
1090
+
1091
+
1092
+ #: The one narrowly-matched marker that says a `west sdk install` failure was
1093
+ #: GitHub's API quota rather than anything about the pin, the network or the
1094
+ #: host. `clean-host.yml`'s own rate-limit exclusion already settled the
1095
+ #: shape of this test in this repo -- match the literal `rate limit`, not a
1096
+ #: bare `403`, so a CA/permission 403 (the #304 shape) cannot be mistaken for
1097
+ #: a quota one and handed a remedy that would not fix it.
1098
+ _RATE_LIMIT_MARKER = "rate limit"
1099
+
1100
+
1101
+ #: The AUTHENTICATED-quota branch of [`rate_limit_note`]. `{source}` is the
1102
+ #: variable name, never the value. The closing sentence is deliberate: an
1103
+ #: authenticated download that is rate-limited anyway is the only live symptom
1104
+ #: a SUPPRESSED netrc would ever produce (see [`NETRC_ENV_VAR`]), and this is
1105
+ #: exactly the moment it would show.
1106
+ #:
1107
+ #: **It named the wrong shape until tan-cli#1170, and this is the only
1108
+ #: USER-FACING string that carried the claim.** It used to close with "a
1109
+ #: `west` that sets its own Authorization header would silently bypass it".
1110
+ #: Measured on `requests` 2.34.2, a header does not bypass anything -- the
1111
+ #: netrc match OVERWRITES it, so tan's credential is the one that goes out. A
1112
+ #: customer following that sentence would have filed a west bug about a header
1113
+ #: that demonstrably suppresses nothing, and gone on believing the netrc route
1114
+ #: was the suspect. The two shapes that really do leave it unread are the
1115
+ #: `auth=` argument and `trust_env=False`, so those are what it names now, and
1116
+ #: what [`west_sdk_netrc_assumptions_hold`] watches the file for.
1117
+ _AUTHENTICATED_QUOTA_NOTE = (
1118
+ "That is GitHub's AUTHENTICATED quota, not the anonymous per-IP one: tan "
1119
+ "already handed this download the credential in ${source}. Check that the token "
1120
+ "is valid, unexpired and not already exhausted by other traffic, or wait for the "
1121
+ "quota window to reset and re-run `tan bootstrap`. If you are confident the token "
1122
+ "is good and this persists, the credential may not be reaching `west sdk install` "
1123
+ "at all -- tan hands it over through a netrc, which a `west` that passes its HTTP "
1124
+ "client its own credential argument, or that builds a session with the "
1125
+ "environment switched off, would leave unread; please report it with your "
1126
+ "`west --version` (tan-cli#1143)."
1127
+ )
1128
+
1129
+ #: The branch for "tan staged and passed a credential, but this run had
1130
+ #: already withdrawn the claim that it reaches the download" -- the state
1131
+ #: `bootstrap.sdk-credential-unverified` puts the run into. `{source}` is the
1132
+ #: variable name, never the value.
1133
+ #:
1134
+ #: It exists so the two surfaces cannot contradict each other inside one run
1135
+ #: (tan-cli#1170). The thing it must NOT do is what
1136
+ #: [`_AUTHENTICATED_QUOTA_NOTE`] does -- open with "that is GitHub's
1137
+ #: AUTHENTICATED quota". That sentence is an INFERENCE from "tan handed over a
1138
+ #: credential", and the `sdk-credential-unverified` warning is precisely tan
1139
+ #: saying it can no longer draw it. So this branch names both possibilities and
1140
+ #: hands the reader back to the remedies already printed above, rather than
1141
+ #: repeating them or picking a quota it cannot see.
1142
+ _UNVERIFIED_QUOTA_NOTE = (
1143
+ "tan cannot tell you which of GitHub's two quotas that is. It staged the "
1144
+ "credential in ${source} and passed it to `west sdk install`, but it also warned "
1145
+ "above (`sdk-credential-unverified`) that this workspace's `west` no longer "
1146
+ "matches the shape that route was measured against. So this is either the "
1147
+ "AUTHENTICATED quota -- check the token is valid, unexpired and not already "
1148
+ "exhausted by other traffic, then wait for the window to reset and re-run "
1149
+ "`tan bootstrap` -- or the anonymous per-IP one, shared with everyone behind the "
1150
+ "same egress address. The two remedies in that warning cover both cases."
1151
+ )
1152
+
1153
+ #: The branch for "the environment HELD a credential and this download went
1154
+ #: out anonymous anyway". Points at the `bootstrap.sdk-credential-unstaged`
1155
+ #: warning that says WHY, instead of repeating advice the reader has already
1156
+ #: followed.
1157
+ _UNUSED_CREDENTIAL_NOTE = (
1158
+ "That is GitHub's anonymous per-IP API quota, and this download went out "
1159
+ "anonymous even though ${source} is set -- tan could not use it, and said so in "
1160
+ "the warning above rather than here. Fix that first: re-running with a usable "
1161
+ "credential is what lifts this limit."
1162
+ )
1163
+
1164
+ #: The plain anonymous branch -- the one a customer behind a shared egress
1165
+ #: address actually hits, and the only one that names a variable to set.
1166
+ _ANONYMOUS_QUOTA_NOTE = (
1167
+ "That is GitHub's anonymous per-IP API quota -- shared with everyone behind the "
1168
+ "same egress address, so it can be exhausted by traffic that is not yours. "
1169
+ "Authenticate the download by setting ${primary} (or {alternatives}, read in that "
1170
+ "order) to a GitHub token with no scopes -- listing public releases needs none -- "
1171
+ "and re-run `tan bootstrap`. Ignore the `--personal-access-token` flag named "
1172
+ "above: that is `west`'s own flag, and `tan bootstrap` does not accept it. tan "
1173
+ "passes the token to `west sdk install` out of band, so it never appears in an "
1174
+ "argv, a log or a `--dry-run` plan."
1175
+ )
1176
+
1177
+
1178
+ def rate_limit_note(
1179
+ detail: str,
1180
+ *,
1181
+ authenticated_as: str | None,
1182
+ credential_seen: str | None = None,
1183
+ verified: bool = True,
1184
+ ) -> str | None:
1185
+ """The tan-side remedy to append to a rate-limited `west sdk install`
1186
+ failure, or `None` when `detail` says nothing about a quota.
1187
+
1188
+ The same shape as [`low_disk_note`] and [`augment_acquisition_failure`]:
1189
+ the child's own message is kept verbatim and a note is appended, because
1190
+ that message is actively MISLEADING here -- west tells the reader to
1191
+ "try executing install script with --personal-access-token argument",
1192
+ which is `west`'s flag on a command the customer never typed and which
1193
+ `tan bootstrap` does not accept. Naming tan's own surface is the whole
1194
+ point of the note.
1195
+
1196
+ FOUR states, because there are four genuinely different situations and
1197
+ collapsing any two of them hands somebody advice that cannot help:
1198
+ a credential that reached the download
1199
+ ([`_AUTHENTICATED_QUOTA_NOTE`]), one that was staged and passed but whose
1200
+ transport tan could not vouch for ([`_UNVERIFIED_QUOTA_NOTE`]), one that
1201
+ was present but unusable ([`_UNUSED_CREDENTIAL_NOTE`]), and none at all
1202
+ ([`_ANONYMOUS_QUOTA_NOTE`]).
1203
+
1204
+ `verified` is the fourth, added in tan-cli#1170 because without it this
1205
+ function contradicted a warning printed minutes earlier in the same run:
1206
+ `bootstrap.sdk-credential-unverified` says "treat this run as
1207
+ unauthenticated", and the authenticated branch then said "tan already
1208
+ handed this download the credential in ${source}". Two wire surfaces
1209
+ asserting opposite facts about one download is the shape
1210
+ `bootstrap_cmd._sdk_credential`'s own docstring calls worse than the
1211
+ silence the warning replaced.
1212
+ """
1213
+ if _RATE_LIMIT_MARKER not in detail.lower():
1214
+ return None
1215
+ if authenticated_as is not None:
1216
+ if not verified:
1217
+ return _UNVERIFIED_QUOTA_NOTE.format(source=authenticated_as)
1218
+ return _AUTHENTICATED_QUOTA_NOTE.format(source=authenticated_as)
1219
+ if credential_seen is not None:
1220
+ return _UNUSED_CREDENTIAL_NOTE.format(source=credential_seen)
1221
+ primary, *fallbacks = SDK_TOKEN_ENV_VARS
1222
+ return _ANONYMOUS_QUOTA_NOTE.format(
1223
+ primary=primary, alternatives="/".join(f"${name}" for name in fallbacks)
1224
+ )