@hominis/fireforge 0.35.0 → 0.37.0

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 (369) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +34 -9
  3. package/dist/bin/fireforge.js +21 -6
  4. package/dist/src/cli.js +22 -5
  5. package/dist/src/commands/bootstrap-checks.js +0 -1
  6. package/dist/src/commands/bootstrap.d.ts +2 -1
  7. package/dist/src/commands/bootstrap.js +16 -7
  8. package/dist/src/commands/build.js +5 -4
  9. package/dist/src/commands/config.js +30 -10
  10. package/dist/src/commands/discard.js +18 -3
  11. package/dist/src/commands/doctor/post-rebase-audit.js +0 -1
  12. package/dist/src/commands/doctor-check-core.js +0 -1
  13. package/dist/src/commands/doctor-external-toolchains.d.ts +3 -0
  14. package/dist/src/commands/doctor-external-toolchains.js +54 -0
  15. package/dist/src/commands/doctor-furnace-jar.js +0 -1
  16. package/dist/src/commands/doctor-furnace-manifest-sync.js +0 -1
  17. package/dist/src/commands/doctor-furnace.js +2 -2
  18. package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
  19. package/dist/src/commands/doctor-orphaned-harness.js +132 -0
  20. package/dist/src/commands/doctor-working-tree.js +0 -1
  21. package/dist/src/commands/doctor.js +6 -1
  22. package/dist/src/commands/download.js +28 -13
  23. package/dist/src/commands/export-all.js +5 -3
  24. package/dist/src/commands/export-flow.js +12 -7
  25. package/dist/src/commands/export-placement-gate.js +0 -1
  26. package/dist/src/commands/export-placement-policy.js +0 -1
  27. package/dist/src/commands/export-shared.d.ts +5 -1
  28. package/dist/src/commands/export-shared.js +18 -6
  29. package/dist/src/commands/export.js +22 -10
  30. package/dist/src/commands/furnace/apply.js +22 -6
  31. package/dist/src/commands/furnace/chrome-doc-remove.js +0 -1
  32. package/dist/src/commands/furnace/chrome-doc-templates.js +0 -1
  33. package/dist/src/commands/furnace/chrome-doc-tests.js +0 -1
  34. package/dist/src/commands/furnace/chrome-doc.js +0 -1
  35. package/dist/src/commands/furnace/create-browser-test.js +0 -1
  36. package/dist/src/commands/furnace/create-dry-run.js +0 -1
  37. package/dist/src/commands/furnace/create-features.js +0 -1
  38. package/dist/src/commands/furnace/create-mochikit.js +0 -1
  39. package/dist/src/commands/furnace/create-readback.js +0 -1
  40. package/dist/src/commands/furnace/create-templates.js +0 -1
  41. package/dist/src/commands/furnace/create-validation.js +0 -1
  42. package/dist/src/commands/furnace/create-xpcshell.js +0 -1
  43. package/dist/src/commands/furnace/create.js +0 -1
  44. package/dist/src/commands/furnace/deploy.js +7 -83
  45. package/dist/src/commands/furnace/diff.js +0 -1
  46. package/dist/src/commands/furnace/index.js +59 -25
  47. package/dist/src/commands/furnace/init.js +0 -1
  48. package/dist/src/commands/furnace/list.js +0 -1
  49. package/dist/src/commands/furnace/override.js +0 -1
  50. package/dist/src/commands/furnace/preview.js +3 -3
  51. package/dist/src/commands/furnace/refresh.js +0 -1
  52. package/dist/src/commands/furnace/remove-state.d.ts +15 -0
  53. package/dist/src/commands/furnace/remove-state.js +47 -1
  54. package/dist/src/commands/furnace/remove.js +3 -9
  55. package/dist/src/commands/furnace/rename-browser-test.js +1 -5
  56. package/dist/src/commands/furnace/rename-helpers.d.ts +6 -0
  57. package/dist/src/commands/furnace/rename-helpers.js +30 -1
  58. package/dist/src/commands/furnace/rename-xpcshell.js +7 -6
  59. package/dist/src/commands/furnace/rename.js +15 -36
  60. package/dist/src/commands/furnace/scan.js +0 -1
  61. package/dist/src/commands/furnace/status.js +0 -1
  62. package/dist/src/commands/furnace/sync.js +76 -4
  63. package/dist/src/commands/furnace/validate.js +0 -1
  64. package/dist/src/commands/furnace/validation-output.js +0 -1
  65. package/dist/src/commands/import.js +35 -9
  66. package/dist/src/commands/lint-per-patch.js +19 -7
  67. package/dist/src/commands/lint.js +13 -3
  68. package/dist/src/commands/manifest.js +1 -2
  69. package/dist/src/commands/package.js +2 -1
  70. package/dist/src/commands/patch/compact.js +1 -1
  71. package/dist/src/commands/patch/delete.js +1 -1
  72. package/dist/src/commands/patch/index.js +1 -1
  73. package/dist/src/commands/patch/lint-ignore.js +21 -6
  74. package/dist/src/commands/patch/move-files.js +1 -1
  75. package/dist/src/commands/patch/patch-context.js +0 -1
  76. package/dist/src/commands/patch/rename.js +4 -4
  77. package/dist/src/commands/patch/reorder.js +3 -4
  78. package/dist/src/commands/patch/split-plan.js +2 -1
  79. package/dist/src/commands/patch/split.js +4 -4
  80. package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
  81. package/dist/src/commands/patch/staged-dependency.js +114 -52
  82. package/dist/src/commands/patch/tier.js +18 -5
  83. package/dist/src/commands/re-export-bulk-scan.js +0 -1
  84. package/dist/src/commands/re-export-files.js +5 -1
  85. package/dist/src/commands/re-export-options.js +0 -1
  86. package/dist/src/commands/re-export-scan.js +0 -1
  87. package/dist/src/commands/re-export.js +14 -4
  88. package/dist/src/commands/rebase/abort.js +2 -9
  89. package/dist/src/commands/rebase/confirm.js +0 -1
  90. package/dist/src/commands/rebase/conflict-summary.js +0 -1
  91. package/dist/src/commands/rebase/continue.js +0 -1
  92. package/dist/src/commands/rebase/index.d.ts +1 -1
  93. package/dist/src/commands/rebase/index.js +10 -15
  94. package/dist/src/commands/rebase/patch-loop.js +38 -7
  95. package/dist/src/commands/rebase/summary.js +2 -3
  96. package/dist/src/commands/rebase.js +0 -1
  97. package/dist/src/commands/register.js +1 -1
  98. package/dist/src/commands/reset.js +16 -9
  99. package/dist/src/commands/resolve.js +2 -2
  100. package/dist/src/commands/run.js +4 -4
  101. package/dist/src/commands/setup-support.js +0 -1
  102. package/dist/src/commands/setup.js +1 -2
  103. package/dist/src/commands/source.js +1 -2
  104. package/dist/src/commands/status-output.js +1 -12
  105. package/dist/src/commands/status.js +147 -133
  106. package/dist/src/commands/test-appdir.js +0 -1
  107. package/dist/src/commands/test-diagnose.js +38 -3
  108. package/dist/src/commands/test-modes.d.ts +15 -0
  109. package/dist/src/commands/test-modes.js +61 -0
  110. package/dist/src/commands/test-register.d.ts +4 -0
  111. package/dist/src/commands/test-register.js +64 -0
  112. package/dist/src/commands/test-run.d.ts +23 -7
  113. package/dist/src/commands/test-run.js +15 -14
  114. package/dist/src/commands/test.d.ts +0 -4
  115. package/dist/src/commands/test.js +147 -96
  116. package/dist/src/commands/token-coverage.js +0 -1
  117. package/dist/src/commands/token.js +1 -2
  118. package/dist/src/commands/typecheck.js +1 -1
  119. package/dist/src/commands/verify.js +3 -9
  120. package/dist/src/commands/watch.js +5 -1
  121. package/dist/src/commands/wire.js +1 -1
  122. package/dist/src/core/ast-utils.js +0 -1
  123. package/dist/src/core/brand-validation.js +0 -1
  124. package/dist/src/core/branding.js +0 -1
  125. package/dist/src/core/browser-wire.js +0 -1
  126. package/dist/src/core/build-audit-platform.js +0 -1
  127. package/dist/src/core/build-audit-registration.js +0 -1
  128. package/dist/src/core/build-audit-resolve.js +0 -1
  129. package/dist/src/core/build-audit-transforms.js +0 -1
  130. package/dist/src/core/build-audit.js +0 -1
  131. package/dist/src/core/build-baseline-types.d.ts +24 -0
  132. package/dist/src/core/build-baseline-types.js +0 -1
  133. package/dist/src/core/build-baseline.d.ts +5 -2
  134. package/dist/src/core/build-baseline.js +5 -2
  135. package/dist/src/core/build-prepare.js +2 -2
  136. package/dist/src/core/config-mutate.js +0 -1
  137. package/dist/src/core/config-paths.d.ts +2 -2
  138. package/dist/src/core/config-paths.js +6 -1
  139. package/dist/src/core/config-state.js +0 -1
  140. package/dist/src/core/config-validate-patch-policy.js +0 -1
  141. package/dist/src/core/config-validate-test-toolchains.d.ts +8 -0
  142. package/dist/src/core/config-validate-test-toolchains.js +105 -0
  143. package/dist/src/core/config-validate.js +3 -1
  144. package/dist/src/core/config.js +0 -1
  145. package/dist/src/core/destructive.js +0 -1
  146. package/dist/src/core/diff-hunks.js +13 -7
  147. package/dist/src/core/engine-changes.js +0 -1
  148. package/dist/src/core/engine-session-lock.d.ts +12 -0
  149. package/dist/src/core/engine-session-lock.js +45 -0
  150. package/dist/src/core/file-lock.d.ts +8 -0
  151. package/dist/src/core/file-lock.js +157 -48
  152. package/dist/src/core/firefox-archive.d.ts +4 -0
  153. package/dist/src/core/firefox-archive.js +6 -1
  154. package/dist/src/core/firefox-cache.js +60 -4
  155. package/dist/src/core/firefox-download.js +0 -1
  156. package/dist/src/core/firefox-extract.js +75 -16
  157. package/dist/src/core/firefox-ignorefile.js +0 -1
  158. package/dist/src/core/firefox.d.ts +12 -0
  159. package/dist/src/core/firefox.js +64 -2
  160. package/dist/src/core/furnace-apply-dry-run.js +0 -1
  161. package/dist/src/core/furnace-apply-ftl.d.ts +7 -3
  162. package/dist/src/core/furnace-apply-ftl.js +10 -5
  163. package/dist/src/core/furnace-apply-helpers.js +0 -1
  164. package/dist/src/core/furnace-apply-output.js +2 -2
  165. package/dist/src/core/furnace-apply.d.ts +7 -1
  166. package/dist/src/core/furnace-apply.js +47 -13
  167. package/dist/src/core/furnace-checksum-utils.js +0 -1
  168. package/dist/src/core/furnace-config-array-utils.js +0 -1
  169. package/dist/src/core/furnace-config-custom.js +10 -1
  170. package/dist/src/core/furnace-config-order.js +0 -1
  171. package/dist/src/core/furnace-config-tokens.js +0 -1
  172. package/dist/src/core/furnace-config-validate.js +0 -1
  173. package/dist/src/core/furnace-config.d.ts +11 -0
  174. package/dist/src/core/furnace-config.js +37 -1
  175. package/dist/src/core/furnace-constants.js +0 -1
  176. package/dist/src/core/furnace-css-fragments.js +0 -1
  177. package/dist/src/core/furnace-graph-utils.js +0 -1
  178. package/dist/src/core/furnace-jsconfig.d.ts +2 -3
  179. package/dist/src/core/furnace-jsconfig.js +25 -18
  180. package/dist/src/core/furnace-marker.js +0 -1
  181. package/dist/src/core/furnace-operation.js +0 -1
  182. package/dist/src/core/furnace-refresh.js +0 -1
  183. package/dist/src/core/furnace-registration-ast.d.ts +14 -0
  184. package/dist/src/core/furnace-registration-ast.js +1 -2
  185. package/dist/src/core/furnace-registration-remove.js +0 -1
  186. package/dist/src/core/furnace-registration-validate.js +1 -5
  187. package/dist/src/core/furnace-registration.js +0 -1
  188. package/dist/src/core/furnace-rollback.js +0 -1
  189. package/dist/src/core/furnace-scanner.js +0 -1
  190. package/dist/src/core/furnace-stale-export.d.ts +59 -0
  191. package/dist/src/core/furnace-stale-export.js +123 -0
  192. package/dist/src/core/furnace-staleness.js +0 -1
  193. package/dist/src/core/furnace-state-persist.d.ts +72 -0
  194. package/dist/src/core/furnace-state-persist.js +118 -0
  195. package/dist/src/core/furnace-step-errors.d.ts +23 -0
  196. package/dist/src/core/furnace-step-errors.js +25 -0
  197. package/dist/src/core/furnace-stories.js +0 -1
  198. package/dist/src/core/furnace-validate-accessibility.js +0 -1
  199. package/dist/src/core/furnace-validate-checks.js +0 -1
  200. package/dist/src/core/furnace-validate-compatibility.js +9 -3
  201. package/dist/src/core/furnace-validate-helpers.js +0 -1
  202. package/dist/src/core/furnace-validate-registration.js +7 -7
  203. package/dist/src/core/furnace-validate-structure.d.ts +8 -0
  204. package/dist/src/core/furnace-validate-structure.js +14 -4
  205. package/dist/src/core/furnace-validate.js +0 -1
  206. package/dist/src/core/furnace-version-drift.d.ts +6 -3
  207. package/dist/src/core/furnace-version-drift.js +6 -4
  208. package/dist/src/core/git-base.js +0 -1
  209. package/dist/src/core/git-diff.js +0 -1
  210. package/dist/src/core/git-file-ops.js +0 -1
  211. package/dist/src/core/git-status.d.ts +13 -0
  212. package/dist/src/core/git-status.js +32 -2
  213. package/dist/src/core/git.d.ts +10 -1
  214. package/dist/src/core/git.js +63 -6
  215. package/dist/src/core/license-headers.js +0 -1
  216. package/dist/src/core/lint-cache.js +0 -1
  217. package/dist/src/core/lint-projection.js +0 -1
  218. package/dist/src/core/mach-build-artifacts.js +0 -1
  219. package/dist/src/core/mach-error-hints.js +0 -1
  220. package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
  221. package/dist/src/core/mach-known-noise-filter.js +193 -0
  222. package/dist/src/core/mach-mozconfig.js +0 -1
  223. package/dist/src/core/mach-python.js +0 -1
  224. package/dist/src/core/mach-resource-shim.js +0 -1
  225. package/dist/src/core/mach.d.ts +8 -0
  226. package/dist/src/core/mach.js +36 -4
  227. package/dist/src/core/manifest-helpers.js +0 -1
  228. package/dist/src/core/manifest-register.js +0 -1
  229. package/dist/src/core/manifest-rules.js +0 -1
  230. package/dist/src/core/manifest-tokenizers.js +0 -1
  231. package/dist/src/core/marionette-port.d.ts +8 -0
  232. package/dist/src/core/marionette-port.js +34 -3
  233. package/dist/src/core/marionette-preflight.js +0 -1
  234. package/dist/src/core/markdown-table.js +0 -1
  235. package/dist/src/core/ownership-table.js +2 -9
  236. package/dist/src/core/parser-fallback.js +0 -1
  237. package/dist/src/core/patch-apply-fuzz.d.ts +27 -9
  238. package/dist/src/core/patch-apply-fuzz.js +71 -34
  239. package/dist/src/core/patch-apply.d.ts +30 -0
  240. package/dist/src/core/patch-apply.js +101 -18
  241. package/dist/src/core/patch-artifact-normalize.d.ts +18 -5
  242. package/dist/src/core/patch-artifact-normalize.js +19 -15
  243. package/dist/src/core/patch-export-coverage.js +0 -1
  244. package/dist/src/core/patch-export-metadata.js +0 -1
  245. package/dist/src/core/patch-export-update.js +2 -3
  246. package/dist/src/core/patch-export.d.ts +2 -6
  247. package/dist/src/core/patch-export.js +24 -48
  248. package/dist/src/core/patch-files.js +0 -1
  249. package/dist/src/core/patch-identifier-suggest.js +0 -1
  250. package/dist/src/core/patch-lint-checkjs.js +0 -1
  251. package/dist/src/core/patch-lint-chrome-jsdoc.js +0 -1
  252. package/dist/src/core/patch-lint-cross.js +80 -3
  253. package/dist/src/core/patch-lint-css.js +0 -1
  254. package/dist/src/core/patch-lint-diff-tag.js +0 -1
  255. package/dist/src/core/patch-lint-diff.d.ts +2 -1
  256. package/dist/src/core/patch-lint-diff.js +17 -32
  257. package/dist/src/core/patch-lint-imports.js +0 -1
  258. package/dist/src/core/patch-lint-jsdoc.js +0 -1
  259. package/dist/src/core/patch-lint-observer.js +0 -1
  260. package/dist/src/core/patch-lint-ownership.js +0 -1
  261. package/dist/src/core/patch-lint-reexports.js +0 -1
  262. package/dist/src/core/patch-lint.d.ts +37 -2
  263. package/dist/src/core/patch-lint.js +47 -34
  264. package/dist/src/core/patch-lock.js +0 -1
  265. package/dist/src/core/patch-manifest-consistency.js +9 -1
  266. package/dist/src/core/patch-manifest-io.d.ts +19 -2
  267. package/dist/src/core/patch-manifest-io.js +57 -21
  268. package/dist/src/core/patch-manifest-query.js +18 -10
  269. package/dist/src/core/patch-manifest-resolve.js +0 -1
  270. package/dist/src/core/patch-manifest-validate.js +24 -1
  271. package/dist/src/core/patch-manifest.d.ts +1 -1
  272. package/dist/src/core/patch-manifest.js +1 -2
  273. package/dist/src/core/patch-parse.d.ts +59 -0
  274. package/dist/src/core/patch-parse.js +209 -92
  275. package/dist/src/core/patch-policy.js +0 -1
  276. package/dist/src/core/patch-registration-refs.js +16 -26
  277. package/dist/src/core/patch-source-metadata.js +0 -1
  278. package/dist/src/core/patch-transform.d.ts +4 -0
  279. package/dist/src/core/patch-transform.js +30 -38
  280. package/dist/src/core/rebase-session.js +0 -1
  281. package/dist/src/core/register-browser-content.js +0 -1
  282. package/dist/src/core/register-module.js +0 -1
  283. package/dist/src/core/register-result.js +0 -1
  284. package/dist/src/core/register-scaffold.js +0 -1
  285. package/dist/src/core/register-shared-css.js +0 -1
  286. package/dist/src/core/register-test-manifest.js +0 -1
  287. package/dist/src/core/register-xpcshell-test.js +0 -1
  288. package/dist/src/core/shared-ftl.js +0 -1
  289. package/dist/src/core/signal-critical.js +0 -1
  290. package/dist/src/core/smoke-patterns.js +0 -1
  291. package/dist/src/core/state-file.js +0 -1
  292. package/dist/src/core/status-classify.d.ts +16 -0
  293. package/dist/src/core/status-classify.js +24 -16
  294. package/dist/src/core/test-harness-crash.d.ts +80 -7
  295. package/dist/src/core/test-harness-crash.js +251 -15
  296. package/dist/src/core/test-harness-output.js +0 -1
  297. package/dist/src/core/test-path-scope.d.ts +21 -13
  298. package/dist/src/core/test-path-scope.js +43 -28
  299. package/dist/src/core/test-stale-check.d.ts +29 -1
  300. package/dist/src/core/test-stale-check.js +59 -1
  301. package/dist/src/core/test-stale-symlink.js +0 -1
  302. package/dist/src/core/test-xpcshell-retry.js +0 -1
  303. package/dist/src/core/token-coverage.js +0 -1
  304. package/dist/src/core/token-dark-mode.js +0 -1
  305. package/dist/src/core/token-docs.js +0 -1
  306. package/dist/src/core/token-manager.js +0 -1
  307. package/dist/src/core/token-scaffold.js +0 -1
  308. package/dist/src/core/token-variant.js +0 -1
  309. package/dist/src/core/toolchain-preflight.d.ts +31 -10
  310. package/dist/src/core/toolchain-preflight.js +97 -36
  311. package/dist/src/core/typecheck-shim.js +13 -1
  312. package/dist/src/core/typecheck.js +0 -1
  313. package/dist/src/core/wire-destroy.js +0 -1
  314. package/dist/src/core/wire-dom-fragment.js +0 -1
  315. package/dist/src/core/wire-init.js +0 -1
  316. package/dist/src/core/wire-subscript.js +0 -1
  317. package/dist/src/core/wire-targets.js +0 -1
  318. package/dist/src/core/wire-utils.js +0 -1
  319. package/dist/src/core/xpcshell-appdir.js +1 -4
  320. package/dist/src/errors/base.d.ts +29 -0
  321. package/dist/src/errors/base.js +35 -1
  322. package/dist/src/errors/build.js +0 -1
  323. package/dist/src/errors/codes.d.ts +6 -0
  324. package/dist/src/errors/codes.js +6 -1
  325. package/dist/src/errors/config.js +0 -1
  326. package/dist/src/errors/download.js +0 -1
  327. package/dist/src/errors/furnace.d.ts +11 -1
  328. package/dist/src/errors/furnace.js +6 -5
  329. package/dist/src/errors/git.js +0 -1
  330. package/dist/src/errors/patch.d.ts +17 -0
  331. package/dist/src/errors/patch.js +29 -1
  332. package/dist/src/errors/rebase.js +0 -1
  333. package/dist/src/errors/run.js +1 -1
  334. package/dist/src/index.js +0 -1
  335. package/dist/src/types/cli.js +0 -1
  336. package/dist/src/types/commands/index.d.ts +1 -1
  337. package/dist/src/types/commands/index.js +0 -1
  338. package/dist/src/types/commands/options.d.ts +40 -9
  339. package/dist/src/types/commands/options.js +0 -1
  340. package/dist/src/types/commands/patches.d.ts +37 -0
  341. package/dist/src/types/commands/patches.js +0 -1
  342. package/dist/src/types/commands/project.d.ts +11 -2
  343. package/dist/src/types/commands/project.js +0 -1
  344. package/dist/src/types/config.d.ts +32 -0
  345. package/dist/src/types/config.js +0 -1
  346. package/dist/src/types/furnace.d.ts +23 -0
  347. package/dist/src/types/furnace.js +0 -1
  348. package/dist/src/types/index.js +0 -1
  349. package/dist/src/types/typecheck.js +0 -1
  350. package/dist/src/utils/elapsed.js +0 -1
  351. package/dist/src/utils/errors.js +0 -1
  352. package/dist/src/utils/fs.js +37 -5
  353. package/dist/src/utils/logger.d.ts +7 -0
  354. package/dist/src/utils/logger.js +85 -3
  355. package/dist/src/utils/options.d.ts +15 -0
  356. package/dist/src/utils/options.js +25 -1
  357. package/dist/src/utils/package-root.js +11 -4
  358. package/dist/src/utils/parse.js +0 -1
  359. package/dist/src/utils/paths.js +0 -1
  360. package/dist/src/utils/platform.js +0 -1
  361. package/dist/src/utils/process-group.d.ts +33 -0
  362. package/dist/src/utils/process-group.js +161 -0
  363. package/dist/src/utils/process.d.ts +48 -4
  364. package/dist/src/utils/process.js +258 -59
  365. package/dist/src/utils/regex.d.ts +13 -3
  366. package/dist/src/utils/regex.js +53 -4
  367. package/dist/src/utils/validation.d.ts +0 -12
  368. package/dist/src/utils/validation.js +13 -26
  369. package/package.json +9 -8
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
- import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { toError } from '../utils/errors.js';
5
6
  import { ensureDir } from '../utils/fs.js';
@@ -7,6 +8,15 @@ import { verbose, warn } from '../utils/logger.js';
7
8
  const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
8
9
  const DEFAULT_LOCK_POLL_MS = 50;
9
10
  const DEFAULT_STALE_LOCK_MS = 5 * 60_000;
11
+ /**
12
+ * Minimum interval between stale-lock probes while a waiter polls. The probe
13
+ * used to run exactly once per waiter: if the holder died *after* that single
14
+ * probe, the waiter polled a permanently dead lock until `timeoutMs` — for
15
+ * the build lock that meant up to 24 hours (see `withBuildLock` in mach.ts).
16
+ * Re-probing on a small interval bounds that hang to seconds while keeping
17
+ * the per-poll cost negligible (one stat + one small read every ~5 s).
18
+ */
19
+ const STALE_REPROBE_INTERVAL_MS = 5_000;
10
20
  function sleep(ms) {
11
21
  return new Promise((resolve) => {
12
22
  setTimeout(resolve, ms);
@@ -39,6 +49,100 @@ function isProcessAlive(pid) {
39
49
  return getNodeErrorCode(error) !== 'ESRCH';
40
50
  }
41
51
  }
52
+ /**
53
+ * Reads the owner PID (line 1) and acquisition token (line 2) from a lock
54
+ * directory's PID file. Returns `{ present: false }` when the PID file is
55
+ * missing or the PID does not parse as a finite integer (caller falls back
56
+ * to the age-only staleness heuristic).
57
+ *
58
+ * File format note: external readers (`doctor-furnace.ts`, older FireForge
59
+ * releases) do `parseInt(content.trim(), 10)`, which parses the leading
60
+ * digits of a multi-line file — so adding the token line stays compatible.
61
+ */
62
+ async function readLockOwner(lockPath) {
63
+ try {
64
+ const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
65
+ const [pidLine, tokenLine] = pidContent.split('\n');
66
+ const pid = parseInt((pidLine ?? '').trim(), 10);
67
+ if (Number.isFinite(pid)) {
68
+ const token = tokenLine?.trim();
69
+ if (token !== undefined && token.length > 0) {
70
+ return { present: true, pid, token };
71
+ }
72
+ return { present: true, pid };
73
+ }
74
+ }
75
+ catch {
76
+ // PID file missing or unreadable — treat as absent.
77
+ }
78
+ return { present: false };
79
+ }
80
+ /**
81
+ * Reaps a lock directory believed to be stale, safely against concurrent
82
+ * reapers and a concurrent legitimate re-acquisition.
83
+ *
84
+ * The naive stat → read-PID → `rm` sequence had a TOCTOU hole: two waiters
85
+ * could both observe the dead owner, one would remove the stale dir and
86
+ * `mkdir` its own lock, and the other's already-decided `rm` then deleted
87
+ * the *fresh* lock — two processes inside the critical section at once.
88
+ *
89
+ * Rename-aside closes it: `rename()` is atomic, so exactly one reaper wins
90
+ * ownership of the directory (the loser gets ENOENT and simply re-polls),
91
+ * and nothing ever deletes the live path directly. After winning the rename
92
+ * we re-read the owner from the renamed dir — if it is now a *live* process
93
+ * (the lock was released and re-acquired between our staleness read and the
94
+ * rename), we rename it back instead of reaping.
95
+ *
96
+ * @returns true when the caller should immediately retry `mkdir` (the lock
97
+ * path is now free or was freed by someone else), false when the lock is
98
+ * legitimately held and the caller should keep waiting.
99
+ */
100
+ async function reapStaleLock(lockPath, ageMs, onStaleLockMessage) {
101
+ const reapPath = `${lockPath}.reaping-${String(process.pid)}-${randomUUID()}`;
102
+ try {
103
+ await rename(lockPath, reapPath);
104
+ }
105
+ catch (error) {
106
+ if (getNodeErrorCode(error) === 'ENOENT') {
107
+ // Another reaper won, or the owner released — either way, retry mkdir.
108
+ verbose(`Stale lock disappeared before reaping completed: ${lockPath}`);
109
+ return true;
110
+ }
111
+ verbose(`Could not rename stale lock aside (${lockPath}): ${toError(error).message}`);
112
+ return false;
113
+ }
114
+ // We exclusively own reapPath now. Re-verify: the owner may have changed
115
+ // between the staleness probe and the rename (release + fresh acquire).
116
+ const owner = await readLockOwner(reapPath);
117
+ if (owner.present && isProcessAlive(owner.pid)) {
118
+ try {
119
+ await rename(reapPath, lockPath);
120
+ verbose(`Aborted stale-lock reap of ${lockPath}: owner PID ${String(owner.pid)} is alive (lock was re-acquired mid-probe)`);
121
+ }
122
+ catch (restoreError) {
123
+ // Rename-back can only fail if a third process mkdir'd the lock path
124
+ // in this instant. The displaced live owner keeps running without its
125
+ // lock — surface it loudly instead of hiding the lost exclusion.
126
+ warn(`Could not restore live lock for PID ${String(owner.pid)} at ${lockPath} ` +
127
+ `(${toError(restoreError).message}). Displaced lock kept at ${reapPath} for inspection.`);
128
+ }
129
+ return false;
130
+ }
131
+ const staleMessage = onStaleLockMessage?.(ageMs);
132
+ if (staleMessage) {
133
+ warn(staleMessage);
134
+ }
135
+ else {
136
+ verbose(`Removing stale lock ${lockPath} (age: ${Math.round(ageMs / 1000)}s)`);
137
+ }
138
+ await rm(reapPath, { recursive: true, force: true });
139
+ return true;
140
+ }
141
+ /**
142
+ * Probes a contended lock for staleness and reaps it when the owner is gone.
143
+ *
144
+ * @returns true when the caller should immediately retry `mkdir`.
145
+ */
42
146
  async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
43
147
  try {
44
148
  const lockStat = await stat(lockPath);
@@ -48,27 +152,19 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
48
152
  // 5 minutes) was never removed even when its PID file pointed at a
49
153
  // process that had already exited. That's the exact situation an
50
154
  // operator lands in after SIGINT'ing `furnace preview` — the signal
51
- // handler calls `process.exit`, `withFileLock`'s `finally { rm }`
52
- // never runs, and the next command has to either wait 5 minutes for
53
- // the staleness gate or manually remove the lock directory. With the
54
- // PID-first check, an explicitly-dead owner unblocks immediately.
55
- const pidCheck = await readLockOwnerPid(lockPath);
56
- if (pidCheck.present) {
57
- if (!isProcessAlive(pidCheck.pid)) {
58
- const staleMessage = onStaleLockMessage?.(ageMs);
59
- if (staleMessage) {
60
- warn(staleMessage);
61
- }
62
- else {
63
- verbose(`Lock at ${lockPath} owner PID ${pidCheck.pid} is no longer running — removing (age: ${Math.round(ageMs / 1000)}s)`);
64
- }
65
- await rm(lockPath, { recursive: true, force: true });
66
- return true;
155
+ // handler calls `process.exit`, `withFileLock`'s release never runs,
156
+ // and the next command has to either wait 5 minutes for the staleness
157
+ // gate or manually remove the lock directory. With the PID-first check,
158
+ // an explicitly-dead owner unblocks immediately.
159
+ const owner = await readLockOwner(lockPath);
160
+ if (owner.present) {
161
+ if (!isProcessAlive(owner.pid)) {
162
+ return await reapStaleLock(lockPath, ageMs, onStaleLockMessage);
67
163
  }
68
164
  // PID is alive — respect it regardless of age. A slow `mach build`
69
165
  // legitimately holds the lock past the stale threshold and we don't
70
166
  // want to race-remove it.
71
- verbose(`Lock at ${lockPath} is ${Math.round(ageMs / 1000)}s old but PID ${pidCheck.pid} is still running — not removing`);
167
+ verbose(`Lock at ${lockPath} is ${Math.round(ageMs / 1000)}s old but PID ${String(owner.pid)} is still running — not removing`);
72
168
  return false;
73
169
  }
74
170
  // No readable PID file. Fall back to the age-only heuristic so locks
@@ -77,12 +173,7 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
77
173
  if (ageMs <= staleMs) {
78
174
  return false;
79
175
  }
80
- const staleMessage = onStaleLockMessage?.(ageMs);
81
- if (staleMessage) {
82
- warn(staleMessage);
83
- }
84
- await rm(lockPath, { recursive: true, force: true });
85
- return true;
176
+ return await reapStaleLock(lockPath, ageMs, onStaleLockMessage);
86
177
  }
87
178
  catch (error) {
88
179
  const code = getNodeErrorCode(error);
@@ -95,30 +186,38 @@ async function removeIfStaleLock(lockPath, staleMs, onStaleLockMessage) {
95
186
  }
96
187
  }
97
188
  /**
98
- * Reads the owner PID from a lock directory's PID file. Returns `{ present:
99
- * false }` when the PID file is missing or the content does not parse as a
100
- * finite integer (caller falls back to the age-only staleness heuristic).
189
+ * Releases a lock previously acquired by this process, verifying ownership
190
+ * first. The unconditional `finally { rm }` this replaces would remove
191
+ * *whichever* lock directory was present if a stale-lock reaper had (in a
192
+ * pathological race) replaced our lock, we deleted the new owner's lock and
193
+ * compounded the double-acquisition. When the PID file no longer names us,
194
+ * warn and leave the directory alone.
195
+ *
196
+ * `ownerFileWritten` is false when our own owner-file write failed at
197
+ * acquisition (non-fatal there); ownership then cannot be verified and we
198
+ * fall back to unconditional removal — identical to the historical behavior
199
+ * and strictly better than leaking the lock.
101
200
  */
102
- async function readLockOwnerPid(lockPath) {
103
- try {
104
- const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
105
- const pid = parseInt(pidContent.trim(), 10);
106
- if (Number.isFinite(pid)) {
107
- return { present: true, pid };
201
+ async function releaseLock(lockPath, ownerToken, ownerFileWritten) {
202
+ if (ownerFileWritten) {
203
+ const owner = await readLockOwner(lockPath);
204
+ if (owner.present &&
205
+ (owner.pid !== process.pid || (owner.token !== undefined && owner.token !== ownerToken))) {
206
+ warn(`Not removing lock ${lockPath}: it is now owned by PID ${String(owner.pid)} — ` +
207
+ 'this process no longer holds it.');
208
+ return;
108
209
  }
109
210
  }
110
- catch {
111
- // PID file missing or unreadable — treat as absent.
112
- }
113
- return { present: false };
211
+ await rm(lockPath, { recursive: true, force: true });
114
212
  }
115
213
  /** Runs an async operation while holding a directory lock, with stale-lock recovery. */
116
214
  export async function withFileLock(lockPath, operation, options = {}) {
117
215
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
118
216
  const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
119
217
  const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
218
+ const staleReprobeMs = options.staleReprobeMs ?? STALE_REPROBE_INTERVAL_MS;
120
219
  const deadline = Date.now() + timeoutMs;
121
- let attemptedStaleRecovery = false;
220
+ let lastStaleProbeAt;
122
221
  await ensureDir(dirname(lockPath));
123
222
  for (;;) {
124
223
  try {
@@ -134,32 +233,42 @@ export async function withFileLock(lockPath, operation, options = {}) {
134
233
  if (!isAlreadyLocked) {
135
234
  throw error;
136
235
  }
137
- if (!attemptedStaleRecovery) {
138
- attemptedStaleRecovery = true;
236
+ if (lastStaleProbeAt === undefined || Date.now() - lastStaleProbeAt >= staleReprobeMs) {
237
+ lastStaleProbeAt = Date.now();
139
238
  if (await removeIfStaleLock(lockPath, staleMs, options.onStaleLockMessage)) {
140
239
  continue;
141
240
  }
142
241
  }
143
242
  if (Date.now() >= deadline) {
144
- throw new Error(options.onTimeoutMessage ??
145
- `Timed out waiting for file lock ${lockPath}. Remove the lock directory if it is stale.`, { cause: error });
243
+ const owner = await readLockOwner(lockPath);
244
+ const holderHint = owner.present && isProcessAlive(owner.pid)
245
+ ? ` It is held by running process ${String(owner.pid)} — wait for it to finish, or stop it and retry.`
246
+ : ' If no other FireForge process is running, remove the lock directory and retry.';
247
+ throw new Error(options.onTimeoutMessage ?? `Timed out waiting for file lock ${lockPath}.${holderHint}`, { cause: error });
146
248
  }
147
249
  await sleep(pollMs);
148
250
  }
149
251
  }
150
- // Write PID into the lock directory so stale-lock recovery can check
151
- // whether the owning process is still alive before removing.
252
+ // Stamp ownership (PID + per-acquisition token) into the lock directory so
253
+ // stale-lock recovery can check liveness and release can verify the lock
254
+ // is still ours before removing it.
255
+ const ownerToken = randomUUID();
256
+ let ownerFileWritten = false;
152
257
  try {
153
- await writeFile(join(lockPath, LOCK_PID_FILE), String(process.pid), 'utf-8');
258
+ const metadata = options.ownerMetadata
259
+ ?.map((line) => line.replace(/\r?\n/g, ' ').trim())
260
+ .filter((line) => line.length > 0) ?? [];
261
+ await writeFile(join(lockPath, LOCK_PID_FILE), `${String(process.pid)}\n${ownerToken}\n${metadata.length > 0 ? `${metadata.join('\n')}\n` : ''}`, 'utf-8');
262
+ ownerFileWritten = true;
154
263
  }
155
264
  catch {
156
- // Non-fatal: stale recovery falls back to age-only heuristic
265
+ // Non-fatal: stale recovery falls back to age-only heuristic and release
266
+ // falls back to unconditional removal.
157
267
  }
158
268
  try {
159
269
  return await operation();
160
270
  }
161
271
  finally {
162
- await rm(lockPath, { recursive: true, force: true });
272
+ await releaseLock(lockPath, ownerToken, ownerFileWritten);
163
273
  }
164
274
  }
165
- //# sourceMappingURL=file-lock.js.map
@@ -12,6 +12,10 @@ export interface ResolvedArchive {
12
12
  url: string;
13
13
  filename: string;
14
14
  metadataFilename: string;
15
+ /** URL of Mozilla's published SHA256SUMS file for the release. */
16
+ checksumsUrl: string;
17
+ /** The archive's path as listed inside SHA256SUMS (relative to the release dir). */
18
+ pathInChecksums: string;
15
19
  }
16
20
  /**
17
21
  * Sidecar metadata stored alongside a cached archive.
@@ -62,6 +62,11 @@ export function resolveArchive(version, product = 'firefox') {
62
62
  url: `${getArchiveBaseUrl(product)}/${archiveVersion}/source/firefox-${archiveVersion}.source.tar.xz`,
63
63
  filename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz`,
64
64
  metadataFilename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz.json`,
65
+ // Mozilla publishes one SHA256SUMS per release directory listing every
66
+ // artifact by its release-relative path. Downloads verify against it by
67
+ // default (fail closed on mismatch), so a compromised CDN response
68
+ // cannot silently become the trusted git baseline.
69
+ checksumsUrl: `${getArchiveBaseUrl(product)}/${archiveVersion}/SHA256SUMS`,
70
+ pathInChecksums: `source/firefox-${archiveVersion}.source.tar.xz`,
65
71
  };
66
72
  }
67
- //# sourceMappingURL=firefox-archive.js.map
@@ -10,7 +10,7 @@ import { pipeline } from 'node:stream/promises';
10
10
  import { ChecksumMismatchError } from '../errors/download.js';
11
11
  import { toError } from '../utils/errors.js';
12
12
  import { pathExistsStrict, readJson, removeFile, writeJson } from '../utils/fs.js';
13
- import { verbose } from '../utils/logger.js';
13
+ import { verbose, warn } from '../utils/logger.js';
14
14
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
15
15
  import { validateArchiveMetadata } from './firefox-archive.js';
16
16
  import { downloadFile } from './firefox-download.js';
@@ -24,6 +24,39 @@ async function sha256File(filePath) {
24
24
  await pipeline(stream, hash);
25
25
  return hash.digest('hex');
26
26
  }
27
+ /**
28
+ * Fetches Mozilla's published SHA256SUMS for the release and extracts the
29
+ * digest for this archive. Returns null when the checksum file cannot be
30
+ * fetched or does not list the archive — the caller warns loudly and
31
+ * continues (offline mirrors and staging hosts legitimately lack it), while
32
+ * a FETCHED-but-mismatching digest always fails closed.
33
+ *
34
+ * Line format (both variants appear in the wild):
35
+ * `<64-hex-digest> <release-relative-path>`
36
+ * `<64-hex-digest> *<release-relative-path>`
37
+ */
38
+ async function fetchPublishedSha256(archive) {
39
+ try {
40
+ const response = await fetch(archive.checksumsUrl);
41
+ if (!response.ok) {
42
+ verbose(`SHA256SUMS fetch returned HTTP ${String(response.status)} for ${archive.checksumsUrl}`);
43
+ return null;
44
+ }
45
+ const body = await response.text();
46
+ for (const line of body.split('\n')) {
47
+ const match = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim());
48
+ if (match?.[1] && match[2] === archive.pathInChecksums) {
49
+ return match[1];
50
+ }
51
+ }
52
+ verbose(`SHA256SUMS at ${archive.checksumsUrl} does not list ${archive.pathInChecksums}`);
53
+ return null;
54
+ }
55
+ catch (error) {
56
+ verbose(`SHA256SUMS fetch failed for ${archive.checksumsUrl}: ${toError(error).message}`);
57
+ return null;
58
+ }
59
+ }
27
60
  /**
28
61
  * Ensures a valid cached archive exists, downloading it if needed.
29
62
  * @param archive - Resolved archive descriptor
@@ -117,8 +150,32 @@ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256, on
117
150
  promotedTarball = true;
118
151
  onCacheProgress?.(`Calculating source archive SHA-256 for ${archive.filename}...`);
119
152
  const sha256 = await sha256File(tarballPath);
120
- if (expectedSha256 && sha256 !== expectedSha256) {
121
- throw new ChecksumMismatchError(archive.product, expectedSha256, sha256, archive.url);
153
+ if (expectedSha256) {
154
+ // Operator-pinned digest (firefox.sha256 config): takes precedence
155
+ // and always fails closed.
156
+ if (sha256 !== expectedSha256) {
157
+ throw new ChecksumMismatchError(archive.product, expectedSha256, sha256, archive.url);
158
+ }
159
+ }
160
+ else {
161
+ // Default integrity check: verify against Mozilla's published
162
+ // SHA256SUMS. TLS alone is thin trust for the artifact that becomes
163
+ // the git baseline every patch is built on — a compromised CDN
164
+ // response would otherwise be trusted with no signal. Mismatch fails
165
+ // closed (the catch below deletes the artifact); an unfetchable
166
+ // SHA256SUMS degrades to a loud warning so offline/mirror workflows
167
+ // keep working (pin firefox.sha256 in fireforge.json to fail closed
168
+ // even then).
169
+ onCacheProgress?.(`Verifying archive against published SHA256SUMS...`);
170
+ const publishedSha256 = await fetchPublishedSha256(archive);
171
+ if (publishedSha256 === null) {
172
+ warn(`Could not verify ${archive.filename} against Mozilla's published SHA256SUMS ` +
173
+ `(${archive.checksumsUrl} unavailable). The download is trusted on TLS alone — ` +
174
+ 'set firefox.sha256 in fireforge.json to require checksum verification.');
175
+ }
176
+ else if (sha256 !== publishedSha256) {
177
+ throw new ChecksumMismatchError(archive.product, publishedSha256, sha256, archive.url);
178
+ }
122
179
  }
123
180
  onCacheProgress?.(`Writing source archive cache metadata for ${archive.metadataFilename}...`);
124
181
  await writeJson(metadataPath, {
@@ -167,4 +224,3 @@ async function removeArchivePartFiles(archive, cacheDir) {
167
224
  // Cache dir may not exist yet — that's fine.
168
225
  }
169
226
  }
170
- //# sourceMappingURL=firefox-cache.js.map
@@ -134,4 +134,3 @@ export async function downloadFile(url, destPath, onProgress) {
134
134
  }
135
135
  return totalSize > 0 ? totalSize : undefined;
136
136
  }
137
- //# sourceMappingURL=firefox-download.js.map
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  import { ExtractionError } from '../errors/download.js';
7
7
  import { elapsedSince } from '../utils/elapsed.js';
8
8
  import { ensureDir, pathExists } from '../utils/fs.js';
9
- import { exec, executableExists } from '../utils/process.js';
9
+ import { exec, execStream, executableExists } from '../utils/process.js';
10
10
  /**
11
11
  * Returns true when an archive member path could land outside the
12
12
  * extraction root: absolute (POSIX or Windows drive/backslash rooted) or
@@ -88,26 +88,86 @@ export function findUnsafeArchiveLink(verboseLines) {
88
88
  * (~38%) to this one-time extraction step, versus ~44 s if run
89
89
  * sequentially.
90
90
  */
91
- async function preflightArchiveEntries(archivePath) {
91
+ /**
92
+ * Incremental line splitter for streamed listings. Keeps only the current
93
+ * partial line in memory, so a 350k-entry Firefox listing costs O(longest
94
+ * line), not O(listing).
95
+ */
96
+ function createLineScanner(onLine) {
97
+ let buffer = '';
98
+ return {
99
+ push(chunk) {
100
+ buffer += chunk;
101
+ let idx;
102
+ while ((idx = buffer.indexOf('\n')) !== -1) {
103
+ onLine(buffer.slice(0, idx));
104
+ buffer = buffer.slice(idx + 1);
105
+ }
106
+ },
107
+ flush() {
108
+ if (buffer.length > 0) {
109
+ onLine(buffer);
110
+ buffer = '';
111
+ }
112
+ },
113
+ };
114
+ }
115
+ /**
116
+ * Runs one tar listing pass, streaming lines through `checkLine` and
117
+ * returning the first unsafe finding (or null) plus the exit code.
118
+ *
119
+ * Streaming matters for safety, not just memory: the buffered `exec`
120
+ * collector silently truncates at 50 MB, and a full Firefox `-tvf` listing
121
+ * already sits in the 40–50 MB range — so the buffered implementation could
122
+ * scan only the head of the listing while reporting the archive safe. A
123
+ * crafted archive could exploit exactly that by padding benign entries
124
+ * first and hiding a traversal name or absolute link target past the cap.
125
+ * With per-line streaming EVERY entry is scanned, with bounded memory.
126
+ */
127
+ async function runListingScan(archivePath, tarFlag, checkLine) {
92
128
  // LC_ALL=C keeps the -tvf column format stable for the link parse.
93
129
  const listEnv = { LC_ALL: 'C', LANG: 'C' };
94
- const [nameListing, verboseListing] = await Promise.all([
95
- exec('tar', ['-tf', archivePath], { env: listEnv }),
96
- exec('tar', ['-tvf', archivePath], { env: listEnv }),
130
+ let unsafe = null;
131
+ let stderrTail = '';
132
+ const scanner = createLineScanner((line) => {
133
+ if (unsafe === null) {
134
+ unsafe = checkLine(line);
135
+ }
136
+ });
137
+ const exitCode = await execStream('tar', [tarFlag, archivePath], {
138
+ env: listEnv,
139
+ onStdout: (chunk) => {
140
+ scanner.push(chunk);
141
+ },
142
+ onStderr: (chunk) => {
143
+ // Keep a small diagnostic tail; the listing itself is the big stream.
144
+ stderrTail = (stderrTail + chunk).slice(-4096);
145
+ },
146
+ });
147
+ scanner.flush();
148
+ return { unsafe, exitCode, stderrTail };
149
+ }
150
+ async function preflightArchiveEntries(archivePath) {
151
+ const [nameScan, linkScan] = await Promise.all([
152
+ runListingScan(archivePath, '-tf', (line) => {
153
+ const name = line.trim();
154
+ if (name.length === 0)
155
+ return null;
156
+ return isUnsafeArchivePath(name) ? name : null;
157
+ }),
158
+ runListingScan(archivePath, '-tvf', (line) => findUnsafeArchiveLink([line])),
97
159
  ]);
98
- if (nameListing.exitCode !== 0) {
99
- throw new ExtractionError(archivePath, new Error(`tar -tf preflight exited with code ${nameListing.exitCode}:\n${nameListing.stderr}`));
160
+ if (nameScan.exitCode !== 0) {
161
+ throw new ExtractionError(archivePath, new Error(`tar -tf preflight exited with code ${nameScan.exitCode}:\n${nameScan.stderrTail}`));
100
162
  }
101
- const unsafeName = findUnsafeArchiveEntryName(nameListing.stdout.split('\n'));
102
- if (unsafeName !== null) {
103
- throw new ExtractionError(archivePath, new Error(`Archive rejected: member name could escape the extraction root: ${unsafeName}`));
163
+ if (nameScan.unsafe !== null) {
164
+ throw new ExtractionError(archivePath, new Error(`Archive rejected: member name could escape the extraction root: ${nameScan.unsafe}`));
104
165
  }
105
- if (verboseListing.exitCode !== 0) {
106
- throw new ExtractionError(archivePath, new Error(`tar -tvf preflight exited with code ${verboseListing.exitCode}:\n${verboseListing.stderr}`));
166
+ if (linkScan.exitCode !== 0) {
167
+ throw new ExtractionError(archivePath, new Error(`tar -tvf preflight exited with code ${linkScan.exitCode}:\n${linkScan.stderrTail}`));
107
168
  }
108
- const unsafeLink = findUnsafeArchiveLink(verboseListing.stdout.split('\n'));
109
- if (unsafeLink !== null) {
110
- throw new ExtractionError(archivePath, new Error(`Archive rejected: link could escape the extraction root (${unsafeLink})`));
169
+ if (linkScan.unsafe !== null) {
170
+ throw new ExtractionError(archivePath, new Error(`Archive rejected: link could escape the extraction root (${linkScan.unsafe})`));
111
171
  }
112
172
  }
113
173
  /**
@@ -172,4 +232,3 @@ export function formatBytes(bytes) {
172
232
  }
173
233
  return `${size.toFixed(1)} ${units[unitIndex]}`;
174
234
  }
175
- //# sourceMappingURL=firefox-extract.js.map
@@ -28,4 +28,3 @@ export async function ensureFirefoxIgnorefileCompatibility(engineDir) {
28
28
  await writeText(hgignorePath, await readText(gitignorePath));
29
29
  return 'created';
30
30
  }
31
- //# sourceMappingURL=firefox-ignorefile.js.map
@@ -35,6 +35,18 @@ export type FirefoxSourcePhase = 'download' | 'extract';
35
35
  /** Callback fired at phase transitions during {@link downloadFirefoxSource}. */
36
36
  export type FirefoxSourcePhaseCallback = (phase: FirefoxSourcePhase) => void;
37
37
  export type FirefoxSourceProgressCallback = (message: string) => void;
38
+ /**
39
+ * Sweeps orphaned working directories left behind by interrupted downloads
40
+ * (`<engine>.tmp-*` from extraction, `<engine>.replacement-*` from --force
41
+ * replacement). Both hold multi-GB partial trees, are worthless after the
42
+ * owning process died, and nothing else ever reclaimed them. Deliberately
43
+ * does NOT touch `<engine>.backup-*`: a backup may hold the operator's
44
+ * previous engine after a failed forced replacement and is their recovery
45
+ * copy — callers should surface its existence instead.
46
+ *
47
+ * @returns names of the directories that were removed
48
+ */
49
+ export declare function sweepOrphanedEngineWorkDirs(destDir: string): Promise<string[]>;
38
50
  /**
39
51
  * Downloads and extracts Firefox source.
40
52
  * @param version - Firefox version to download
@@ -33,6 +33,63 @@ export function getDownloadUrl(version, product = 'firefox') {
33
33
  export function getTarballFilename(version, product = 'firefox') {
34
34
  return resolveArchive(version, product).filename;
35
35
  }
36
+ /**
37
+ * Classifies an extraction failure as archive corruption (invalidate the
38
+ * cache and re-download) versus an environmental problem (keep the cached
39
+ * archive; the retry can reuse it). Environmental signatures are the
40
+ * conservative list — anything unrecognized is treated as corruption so a
41
+ * genuinely bad archive can never survive in the cache.
42
+ */
43
+ function isLikelyArchiveCorruptionError(error) {
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ const code = error instanceof Error ? error.code : undefined;
46
+ if (code === 'ENOSPC' || code === 'EACCES' || code === 'EPERM' || code === 'EDQUOT') {
47
+ return false;
48
+ }
49
+ if (/No space left on device|Permission denied|Disk quota exceeded/i.test(message)) {
50
+ return false;
51
+ }
52
+ // Missing tar binary / preflight refusing an unsafe archive both carry
53
+ // ExtractionError text that names the condition; a missing tar is
54
+ // environmental, an unsafe-entry rejection indicts the archive.
55
+ if (/"tar" command was not found/i.test(message)) {
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ /**
61
+ * Sweeps orphaned working directories left behind by interrupted downloads
62
+ * (`<engine>.tmp-*` from extraction, `<engine>.replacement-*` from --force
63
+ * replacement). Both hold multi-GB partial trees, are worthless after the
64
+ * owning process died, and nothing else ever reclaimed them. Deliberately
65
+ * does NOT touch `<engine>.backup-*`: a backup may hold the operator's
66
+ * previous engine after a failed forced replacement and is their recovery
67
+ * copy — callers should surface its existence instead.
68
+ *
69
+ * @returns names of the directories that were removed
70
+ */
71
+ export async function sweepOrphanedEngineWorkDirs(destDir) {
72
+ const { readdir } = await import('node:fs/promises');
73
+ const { basename, dirname } = await import('node:path');
74
+ const parent = dirname(destDir);
75
+ const enginePrefixTmp = `${basename(destDir)}.tmp-`;
76
+ const enginePrefixReplacement = `${basename(destDir)}.replacement-`;
77
+ let entries;
78
+ try {
79
+ entries = await readdir(parent);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ const removed = [];
85
+ for (const name of entries) {
86
+ if (name.startsWith(enginePrefixTmp) || name.startsWith(enginePrefixReplacement)) {
87
+ await removeDir(join(parent, name));
88
+ removed.push(name);
89
+ }
90
+ }
91
+ return removed;
92
+ }
36
93
  /**
37
94
  * Downloads and extracts Firefox source.
38
95
  * @param version - Firefox version to download
@@ -60,7 +117,13 @@ export async function downloadFirefoxSource(version, product, destDir, cacheDir,
60
117
  }
61
118
  catch (error) {
62
119
  await removeDir(tempDir);
63
- await invalidateArchiveCache(archive, cacheDir);
120
+ // Only throw away the ~600 MB cached archive when the failure indicts
121
+ // the ARCHIVE, not the environment. ENOSPC, a permission problem in
122
+ // destDir, or tar disappearing mid-run used to invalidate a perfectly
123
+ // good download and force a full re-fetch on retry.
124
+ if (isLikelyArchiveCorruptionError(error)) {
125
+ await invalidateArchiveCache(archive, cacheDir);
126
+ }
64
127
  throw error;
65
128
  }
66
129
  // Firefox source extracts to a subdirectory (e.g., firefox-140.0/)
@@ -80,4 +143,3 @@ export async function downloadFirefoxSource(version, product, destDir, cacheDir,
80
143
  await rename(tempDir, destDir);
81
144
  }
82
145
  }
83
- //# sourceMappingURL=firefox.js.map
@@ -102,4 +102,3 @@ export async function buildCustomDryRunActions(name, componentDir, engineDir, co
102
102
  }
103
103
  return { actions, stepErrors };
104
104
  }
105
- //# sourceMappingURL=furnace-apply-dry-run.js.map
@@ -4,9 +4,13 @@
4
4
  * per-file LOC budget.
5
5
  *
6
6
  * Every helper here degrades gracefully: if the locale jar.mn is missing or
7
- * the FTL tree is non-standard, apply logs a `stepError` rather than
8
- * aborting the whole command. Missing jar.mn on a fork without a locale
9
- * package should not block a working `.mjs`/`.css` from shipping.
7
+ * the FTL tree is non-standard, apply logs an ADVISORY `stepError`
8
+ * (`advisory: true`) rather than aborting the whole command. Advisory step
9
+ * errors are reported as warnings and never trigger rollback — missing
10
+ * jar.mn on a fork without a locale package should not block a working
11
+ * `.mjs`/`.css` from shipping (the blocking/advisory split exists because
12
+ * this contract used to be contradicted: any FTL step error rolled back
13
+ * the whole apply).
10
14
  */
11
15
  import type { CustomComponentConfig, DryRunAction, StepError } from '../types/furnace.js';
12
16
  import { type RollbackJournal } from './furnace-rollback.js';