@hominis/fireforge 0.34.1 → 0.35.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 (34) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +17 -4
  3. package/dist/bin/fireforge.d.ts +4 -0
  4. package/dist/bin/fireforge.js +4 -0
  5. package/dist/src/commands/build.js +13 -0
  6. package/dist/src/commands/discard.js +6 -2
  7. package/dist/src/commands/download.js +23 -2
  8. package/dist/src/commands/run.js +14 -2
  9. package/dist/src/commands/test.js +46 -6
  10. package/dist/src/core/build-prepare.js +19 -5
  11. package/dist/src/core/firefox-cache.js +7 -4
  12. package/dist/src/core/firefox-extract.d.ts +21 -1
  13. package/dist/src/core/firefox-extract.js +117 -7
  14. package/dist/src/core/git.js +2 -2
  15. package/dist/src/core/license-headers.d.ts +16 -0
  16. package/dist/src/core/license-headers.js +19 -0
  17. package/dist/src/core/mach-error-hints.js +27 -0
  18. package/dist/src/core/mach-resource-shim.d.ts +42 -7
  19. package/dist/src/core/mach-resource-shim.js +227 -25
  20. package/dist/src/core/patch-apply.js +51 -21
  21. package/dist/src/core/patch-lint.js +12 -1
  22. package/dist/src/core/patch-manifest-io.js +7 -7
  23. package/dist/src/core/patch-manifest-query.d.ts +6 -0
  24. package/dist/src/core/patch-manifest-query.js +9 -2
  25. package/dist/src/core/test-harness-crash.js +12 -0
  26. package/dist/src/core/test-path-scope.d.ts +54 -0
  27. package/dist/src/core/test-path-scope.js +133 -0
  28. package/dist/src/core/toolchain-preflight.d.ts +64 -0
  29. package/dist/src/core/toolchain-preflight.js +195 -0
  30. package/dist/src/core/typecheck-shim.d.ts +4 -1
  31. package/dist/src/core/typecheck-shim.js +48 -17
  32. package/dist/src/errors/download.js +2 -0
  33. package/dist/src/types/commands/options.d.ts +7 -0
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0
4
+
5
+ - Fixed the **patch symlink-escape guard being dead code**: the check meant to reject patches targeting a symlink that points outside `engine/` normalized the path text with `resolve()` but never followed the link, so it could not reject anything (the path had already passed the relative-containment check). Target validation now resolves the physical write destination with `realpath` — covering the direct symlink, a _dangling_ symlink (whose destination a write would still create), and a symlinked parent directory — and compares it against the real engine root. Regression tests pin all three escape shapes plus the two accept shapes (inside-tree link, plain new file).
6
+ - Fixed `stampPatchVersions` mutating `patches.json` **without the shared patch-directory lock**: version stamping after `re-export --stamp` and at rebase completion now runs the manifest read-modify-write under `withPatchDirectoryLock`, so a concurrent export/reorder/metadata update can no longer interleave with the stamp and lose version stamps or clobber another manifest change. The function documents that the lock is not reentrant (callers must not already hold it), and a regression test pins that a held lock blocks the stamp until release.
7
+ - Switched authoritative-state existence probes from the error-swallowing `pathExists` to `pathExistsStrict`, so an EACCES/EPERM now surfaces as an error instead of masquerading as "missing": the patches manifest probes (`loadPatchesManifestState`, `mutatePatchRowsInManifest`, the renumber phase checks, and `removePatchFileAndManifest`), the Firefox archive cache probes (`cacheEntryExists` and the tarball/metadata check in `validateCachedArchive` — a permission error probing the cache no longer reads as "invalid cache" and silently triggers a re-download into a directory that cannot be written anyway), and `isGitRepository` (an unreadable engine checkout no longer reports as "not a git repository", which used to route operators toward re-download advice). Soft probes (lock-file cleanup, optional-file checks) intentionally keep the forgiving behavior.
8
+ - Added a **tar extraction preflight** to `extractTarXz`: before anything is written to disk, the archive listing (`tar -tf` for member names, `tar -tvf` for link targets, `LC_ALL=C`) is validated and the archive is rejected if any member name is absolute (POSIX or Windows drive/backslash rooted) or contains a `..` segment, or if any symlink/hardlink target is absolute or `..`-escaping (a relative target without `..` cannot leave the extraction root). Modern GNU tar / bsdtar refuse most of these shapes at extraction time already; the preflight makes the guarantee explicit and independent of the host tar's defaults. Each listing pass costs one full decompression, so the two run **concurrently** and overlap almost perfectly. Measured on a real Firefox ESR 140.9 source tarball (601 MB, bsdtar, macOS): each listing takes ~22 s, both concurrently still ~22 s wall, and extraction itself 58 s — so the preflight adds ~22 s (~38%) to this one-time step of `fireforge download`, versus ~44 s if the listings ran sequentially. Member names are deliberately taken from `-tf` (where the whole line is the name) rather than parsed out of the `-tvf` columns, because the adjacent uname/gname fields are attacker-controlled in a crafted archive and a date-shaped owner name could shift the column boundary and hide an absolute member name from the check. `ExtractionError.userMessage` now includes the underlying reason (this also surfaces the previously hidden tar exit code/stderr detail for ordinary extraction failures).
9
+ - Extended the CI matrix with a **cross-platform unit-test subset**: the macOS and Windows smoke jobs now also run the path/fs/process/git/file-lock unit suites (previously Ubuntu-only via `release:check`), and the README's "never tested on Windows" claim was replaced with an accurate statement of what CI does and does not cover there.
10
+ - Consolidated the mutation lifecycle safety design into an internal architecture doc, **`docs/lifecycle-invariants.md`**: the six invariants the destructive-operation contract, file locks, furnace rollback, signal-critical sections, and the bin signal pipeline uphold together, who owns which piece, the end-to-end SIGINT/SIGTERM sequence, and a decision table for choosing the right primitive for a new mutating command. Added **scenario tests for a signal during a compound mutation** (`signal-compound-mutation-scenario.test.ts`) that replicate the bin-handler composition exactly: exit is held until an in-flight "engine apply + state persist" pair completes, the hold is bounded so a stuck section cannot postpone exit forever, and furnace rollback + critical-section drain + lock force-release compose correctly.
11
+ - Fixed `discard <directory>/` producing **doubled slashes** in the directory-fallback output: a trailing slash on the input was passed through and every message appended `/` again (`stories/furnace//`). The input is now normalized once on entry to the fallback. This was not purely cosmetic — the doubled slash also broke the Furnace-managed-prefix comparison when discarding a _parent_ directory of a managed prefix with a trailing-slash input, silently dropping the "managed by Furnace" warning; regression tests pin both the messages and the warning.
12
+ - The installed-package smoke tests now **skip with a clear message when no npm executable is available** (npm_execpath unset and `npm` not on PATH, e.g. a container with only node + node_modules) instead of failing all three with an opaque `spawn npm ENOENT`.
13
+ - The release workflow now installs the **exact npm version pinned in package.json's `packageManager` field** (derived at run time so the two cannot drift) instead of `npm@latest`, making releases reproducible and keeping a fresh npm major from changing publish behavior unreviewed.
14
+ - Added a **toolchain preflight for Firefox source hops** (152.0b7 → 153.0b8 source-refresh drill: after `download --force` moved the engine to a new major, the first `fireforge build` died ~8s into `mach configure` with `cbindgen version 0.29.1 is too old. At least version 0.29.4 is required.` — and mach's own remediation text names `./mach bootstrap`, the wrong tool for a FireForge-managed repo). Three layers: (1) `fireforge download` now prints a one-line nudge when the downloaded MAJOR version differs from the previously recorded one ("toolchain minimums may have moved — consider running `fireforge bootstrap`"; quiet on first downloads and same-major re-downloads, and also emitted on the resume path); (2) `fireforge build` runs a cheap fail-soft preflight before any pre-build work, comparing the minimums the tree itself declares (`cbindgen_min_version` in `build/moz.configure/bindgen.configure`, mozboot's `MINIMUM_RUST_VERSION`) against the host binaries configure will resolve (honouring the `CBINDGEN`/`RUSTC` env overrides) and failing fast with `fireforge bootstrap` as the named remedy — only a definitively parsed minimum vs a definitively probed host version can fail it, so a moved declaration file or a missing binary skips silently instead of false-positiving; (3) the mach-error-hints translator learned the `… is too old. At least version … is required` and `Rust compiler/Cargo package manager … is too old` shapes, so a failure that still reaches mach (including the auto-configure path in `prepareBuildEnvironment`, which now feeds its captured output through the translator) names `fireforge bootstrap` right below the raw error.
15
+ - Made the per-patch lint **Firefox-globals shim extensible and current**: the closed object-literal types for `ChromeUtils` and `Localization` in the checkJs/typecheck shim are now named global interfaces (`ChromeUtilsShim`, `LocalizationShim`/`LocalizationInstanceShim`), so a project's `patchLint.checkJsExtraShim` can ADD members through TypeScript interface merging (`interface ChromeUtilsShim { newApi(): any }`) instead of having no extension point at all (a second `declare var ChromeUtils` is a duplicate-identifier error — that asymmetry forced a drill workaround with a module-local typedef + bounded any-cast while whole-project `fireforge typecheck` accepted the same call via the engine's Gecko types). Added the Firefox 153 member that triggered this, `ChromeUtils.predictRemoteTypeForURI(uri, options)` (loose signature per the shim's pragmatic posture), and documented that the shim tracks upstream WebIDL additions per Firefox release. Regression tests pin both halves: the new member passes out of the box, and an extra-shim interface merge is typed (correct use passes, misuse is still flagged).
16
+ - Changed `fireforge test <directory>` to select **exactly the named directory**: mach resolves test paths by string prefix, so a bare directory argument silently swept in prefix-named siblings (drill: `…/test/hominis` also ran `…/test/hominis-tiles` — 1224 tests instead of ~200, with no indication the scope widened). FireForge now normalizes directory arguments with a trailing `/` (which makes the prefix match exact) and, when prefix-matching sibling test directories exist, echoes what was selected and what was excluded with per-directory test-file counts plus how to include them as separate paths. Since FireForge already rejected non-existent paths, raw prefix-widening was never something an operator could invoke deliberately — it only ever happened by accident. Documented in `test --help`.
17
+ - Made per-file test sharding **announce itself**: multi-path `fireforge test` runs print one notice that N paths run in isolated browser instances (one mach invocation per path) and that cross-file state is NOT exercised, pointing at `--no-shard` for a combined single-instance run. The silent default cost a real misdiagnosis during the drill: a two-file cross-file pollution repro "passed" because the headed comparison run was silently sharded, briefly blaming a suite-context bug on an upstream headless regression. The `--no-shard` help text now carries the same caveat.
18
+ - Added a **headed-smoke contamination notice and `fireforge run --headless`**: when `--smoke-exit` launches headed on a non-CI host (CI env var unset), a one-line warning states that keyboard/mouse input during the window will contaminate the console capture and can fail the run (drill: a human interacted with the smoke window, the password-manager import scan probed an unreadable Chrome profile dir, and the resulting console errors initially read as a product regression). The new `--headless` flag forwards `--headless` to the browser in both smoke and plain launch modes for unattended checks.
19
+ - Accepted the **verbatim upstream MPL-2.0 block header on new JS files regardless of the project license**: the `missing-license-header` carve-out whose own comment describes the copied-from-upstream case was gated on `license === 'MPL-2.0'`, making it dead code for EUPL/GPL/0BSD projects — a file legitimately carrying Mozilla's block header (including behind a leading editor-directive comment) had no sanctioned path and consumers carried repo-side audit workarounds (recorded 2026-07-04 in the Hominis FORGE.md). Only the upstream `/* … */` block form is accepted; the `// `-style MPL header FireForge generates for MPL projects still requires the project's own header on non-MPL projects, and non-JS styles are unchanged.
20
+ - Added targeted tests and per-module coverage threshold pins for under-covered edge modules that the global threshold was masking: `signal-critical.ts` (first direct tests: body passthrough/cleanup, multi-section waits, bounded timeout, throwing sections), `bootstrap-checks.ts` (bootstrap output pattern detection incl. traceback/403 collapse and all origin-remote phrasings; SDK-probe branching), `discard.ts` (directory-recursion fallback incl. sibling-prefix safety, batch partial failures, dry-run/confirmation paths, Furnace-managed warnings), and the `furnace-validate-compatibility.ts` compose/CSS-hygiene warnings (`compose-not-referenced`/`compose-not-registered`, `excessive-important`, `missing-reduced-motion`).
21
+
3
22
  ## 0.34.0
4
23
 
5
24
  - Reworked the macOS resource-monitor (psutil) crash mitigation to an **in-process guard FireForge owns on its mach dispatches**: a `fireforge_mach_guard.pth` + module pair is installed directly into every discovered mach virtualenv site-packages (objdir `_virtualenvs` and the `~/.mozbuild` state dir) before each dispatch, because mach's venv re-exec drops `PYTHONPATH` and the 0.33.0 `sitecustomize.py` route never loaded (the env-var shim is retained only for the pre-venv bootstrap phase). The guard now covers the whole crash family: `psutil.virtual_memory`/`swap_memory`/`cpu_percent`/`cpu_times`/`disk_io_counters` are wrapped module-wide (which also covers the direct `psutil.virtual_memory()` call in `mozbuild.base._run_make`), and `SystemResourceMonitor` **construction** is guarded via an import hook that pre-populates `poll_interval` and degrades a partially-constructed monitor to a no-op — so the `AttributeError: ... poll_interval` variant can no longer escape a polling-only patch.
@@ -15,6 +34,10 @@
15
34
  - Added `furnace scan --track`, which persists every discovered untracked component into the `stock` section of furnace.json non-interactively (same locked, rollback-journaled write path as the interactive confirm flow). Scan's help and its non-interactive output now state that scan is report-only by default and where the inventory is consumed, ending the report-persist-nothing-explain-nothing behavior.
16
35
  - Added `furnace chrome-doc create --browser-window`, a browser.xhtml-like scaffold for the document that ships as the fork's main browser window: `<html id="main-window">` root with the `windowtype="navigator:browser"`/`chromehidden`/geometry-`persist` attributes platform C++ reads before scripts run, while keeping the generic scaffold's bootstrap wiring, sentinel, and (already-correct) jar.mn registrations. When the target document matches a configured `tokenHostDocuments` entry and the flag was not passed, create warns that the browser-window variant is probably intended.
17
36
  - Added `furnace create --test-dir <dir>` to redirect the `--with-tests` scaffold (browser-chrome and xpcshell styles) to any engine-relative directory under `browser/base/content/test/` — nested manifests register correctly — and made all test scaffolds collision-safe: existing `browser.toml`/`xpcshell.toml`/`chrome.toml` manifests are appended to (with a shared-manifest notice) instead of scaffolded over, and existing `head.js`/test implementation files are never overwritten.
37
+ - Fixed the degraded-psutil guard wedging `mach build` at exit on flapping hosts (0.34.1 post-upgrade field report: psutil's vm/swap syscalls flap between working and degraded, the fallback swap reading arrived svmem-shaped (8 fields) where mozsystemmonitor's parent reconstructs a 6-field `sswap` via `self._swap_type(*swap_mem)`, so every collector sample was rejected — `resourcemonitor.py:766: UserWarning: failed to read the received data` — upstream's handler breaks its drain loop, the pipe fills, the collector child blocks in `send()` and never reads the terminate sentinel, and mach hangs forever in its atexit `waitpid`). The guard's fallback readings are now **per-function, arity-correct, module-level namedtuples** (svmem 8 / sswap 6 / scputimes 4 / sdiskio 6; psutil's own result classes are still preferred when resolvable) that pickle by reference across the collector pipe and survive `type(reading)(*values)` reconstruction in **either** flapping direction (real type + degraded reading, or degraded type + real reading); `cpu_percent`/`cpu_times` fallbacks honour `percpu=True` (return `[]`) for the collector child's per-CPU sampling, and the last-resort `_DegradedReading` now tolerates positional-field construction. The report's "factory resolution bypassed in the collector child" observation is consistent with the two-copy load (`sitecustomize` via PYTHONPATH plus the in-venv `.pth`) or degraded-import-time `psutil._psplatform` resolution — the fix no longer depends on psutil internals resolving, so it is robust to both.
38
+ - Added **collector suppression on degradation** to the guard: any observed psutil degradation sets a process-wide flag, after which monitors are kept inert — `start` never spawns the mozsystemmonitor collector child — and a degraded transition (any wrapped monitor method raising) or a raising `stop` best-effort terminates a live collector child (`_process.terminate()` + bounded join) and drains the parent end of the pipe, so a malformed sample stream can never fail the command or wedge shutdown even if a future shape mismatch slips through. (The upstream enabler — mozsystemmonitor warning then `break`ing out of its drain loop without draining, leaving the pipe to fill — is worth reporting to Mozilla but is not FireForge-fixable.)
39
+ - Fixed the same degradation **failing `mach build` after a fully successful compile** ("Error running mach" with complete, valid `dist/` artifacts): degraded aggregate methods now return zeroed shapes instead of `None` where callers subscript the result (`aggregate_io` → zeroed io namedtuple, so mozbuild's `log_resource_usage` no longer dies on `usage["io"].read_bytes` at `mozbuild/controller/building.py:526`; `aggregate_cpu_percent`/`aggregate_cpu_times` return `[]`/`0.0`/zeroed shapes per their `per_cpu` mode), and the guard additionally wraps `mozbuild.controller.building.BuildMonitor.log_resource_usage` to warn-and-continue on any exception. Runtime-verified by new python3-executed regression tests covering per-function fallback arity, pickle/`type(*values)` round-trips in both flapping directions, collector suppression (stub monitor with upstream-named `_process`/`_pipe`), and the BuildMonitor wrap.
40
+ - Taught the harness-crash classifier the post-success `AttributeError: 'NoneType' object has no attribute 'read_bytes'` traceback (protected builds retry it as crash-not-failure — with the fixed guard the incremental retry is cheap and green; real test failures still take precedence), and exempted the upstream drain-loop `failed to read the received data` warning from crash evidence so runs that complete despite the rejected-sample stream never classify as crashes.
18
41
 
19
42
  ## 0.33.0
20
43
 
package/README.md CHANGED
@@ -88,13 +88,26 @@ Queue maintenance lives under `fireforge patch`: `patch compact` closes ordinal
88
88
  new one as a single transaction — including staged-dependency owner rewrites — with
89
89
  `--dry-run` support.
90
90
 
91
- `fireforge test` runs multiple test paths as sequential per-file shards by default
92
- (`--no-shard` restores one combined invocation), retries recognized harness crashes up to
93
- `--harness-retries <n>` times (default 2), and can publish a perf-sample artifact path to
94
- the harness via `--perf-samples <path>` (exported as `<BINARYNAME>_PERF_SAMPLE_JSON`).
91
+ `fireforge test <directory>` runs exactly that directory: the argument is normalized with a
92
+ trailing `/` so mach's prefix-based path matching cannot silently sweep in sibling
93
+ directories sharing the name prefix (excluded siblings are echoed with their test-file
94
+ counts). Multiple test paths run as sequential per-file shards by default — announced with
95
+ a notice, since isolated instances do not exercise cross-file state (`--no-shard` restores
96
+ one combined invocation). Recognized harness crashes retry up to `--harness-retries <n>`
97
+ times (default 2), and `--perf-samples <path>` publishes a perf-sample artifact path to the
98
+ harness (exported as `<BINARYNAME>_PERF_SAMPLE_JSON`).
95
99
  Design tokens are managed with `fireforge token add`; pass `--create-category` to declare a
96
100
  new category banner and insert the token in one step.
97
101
 
102
+ For unattended smoke checks, `fireforge run --smoke-exit <s> --headless` launches the
103
+ browser headless — a headed smoke window on a shared desktop absorbs live input, which
104
+ contaminates the console capture (headed non-CI launches print a warning saying so).
105
+
106
+ Per-patch lint type-checks plain Firefox JS against a built-in Firefox-globals shim that
107
+ tracks upstream WebIDL additions per release; a project's `patchLint.checkJsExtraShim` can
108
+ add members to the structured shim globals via TypeScript interface merging (e.g.
109
+ `interface ChromeUtilsShim { newApi(): any }`).
110
+
98
111
  ## Rebasing Firefox Source
99
112
 
100
113
  When Mozilla publishes a new Firefox source release you need to update the configured version/product, download the new source code and reapply the patches:
@@ -6,5 +6,9 @@
6
6
  * All shared library code propagates errors via CommandError or
7
7
  * FireForgeError — never by terminating the process directly.
8
8
  *
9
+ * The signal pipeline below composes several lifecycle modules; the
10
+ * invariants it upholds (and who owns what) are documented in
11
+ * docs/lifecycle-invariants.md, and the composed behavior is pinned by
12
+ * src/core/__tests__/signal-compound-mutation-scenario.test.ts.
9
13
  */
10
14
  export {};
@@ -7,6 +7,10 @@
7
7
  * All shared library code propagates errors via CommandError or
8
8
  * FireForgeError — never by terminating the process directly.
9
9
  *
10
+ * The signal pipeline below composes several lifecycle modules; the
11
+ * invariants it upholds (and who owns what) are documented in
12
+ * docs/lifecycle-invariants.md, and the composed behavior is pinned by
13
+ * src/core/__tests__/signal-compound-mutation-scenario.test.ts.
10
14
  */
11
15
  import { installBrokenPipeHandler, main } from '../src/cli.js';
12
16
  import { forceReleaseFurnaceLocksForActiveOperations, isSignalRollbackInFlight, rollbackActiveOperationsForSignal, } from '../src/core/furnace-operation.js';
@@ -7,6 +7,7 @@ import { prepareBuildEnvironment } from '../core/build-prepare.js';
7
7
  import { getProjectPaths, loadConfig } from '../core/config.js';
8
8
  import { attemptMozinfoRewrite, build, buildArtifactMismatchMessage, buildUI, hasBuildArtifacts, hasRunnableBundle, runMach, withBuildLock, } from '../core/mach.js';
9
9
  import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
10
+ import { formatToolchainMismatchMessage, runToolchainPreflight, } from '../core/toolchain-preflight.js';
10
11
  import { GeneralError } from '../errors/base.js';
11
12
  import { AmbiguousBuildArtifactsError, BuildError } from '../errors/build.js';
12
13
  import { toError } from '../utils/errors.js';
@@ -176,6 +177,18 @@ export async function buildCommand(projectRoot, options) {
176
177
  if (!(await pathExists(paths.engine))) {
177
178
  throw new GeneralError('Firefox source not found. Run "fireforge download" first.');
178
179
  }
180
+ // Toolchain preflight: compare the minimums the tree itself declares
181
+ // (cbindgen / rust) against the host binaries mach configure will
182
+ // resolve, and fail fast naming `fireforge bootstrap` instead of dying
183
+ // ~8s into configure with mach's "./mach bootstrap" remediation text
184
+ // (152.0b7 → 153.0b8 source-refresh drill). Fail-soft by design: only
185
+ // a definitively parsed minimum vs a definitively probed host version
186
+ // can fail here; anything uncertain proceeds to mach, where the
187
+ // mach-error-hints translator still names the right remedy.
188
+ const toolchainMismatches = await runToolchainPreflight(paths.engine);
189
+ if (toolchainMismatches.length > 0) {
190
+ throw new BuildError(formatToolchainMismatchMessage(toolchainMismatches), 'toolchain preflight');
191
+ }
179
192
  const buildCheck = await hasBuildArtifacts(paths.engine);
180
193
  if (buildCheck.ambiguous && buildCheck.objDirs && buildCheck.objDirs.length > 0) {
181
194
  throw new AmbiguousBuildArtifactsError(buildCheck.objDirs);
@@ -118,10 +118,14 @@ export async function discardCommand(projectRoot, file, options = {}) {
118
118
  // directory-with-trailing-slash form so a path like `foo/bar` doesn't
119
119
  // accidentally match `foo/bar2/file`.
120
120
  if (!statusEntry) {
121
- const dirPrefix = file.endsWith('/') ? file : `${file}/`;
121
+ // Normalize the operator's input once: `discard foo/` (or `foo//`) must
122
+ // not produce doubled slashes in the messages and prefix comparisons
123
+ // below, which all append `/` themselves.
124
+ const dirPath = file.replace(/\/+$/, '');
125
+ const dirPrefix = `${dirPath}/`;
122
126
  const dirEntries = statusEntries.filter((entry) => entry.file.startsWith(dirPrefix) || entry.originalPath?.startsWith(dirPrefix));
123
127
  if (dirEntries.length > 0) {
124
- await discardDirectoryEntries(projectRoot, paths.engine, file, dirEntries, options);
128
+ await discardDirectoryEntries(projectRoot, paths.engine, dirPath, dirEntries, options);
125
129
  return;
126
130
  }
127
131
  throw new GeneralError(`File "${file}" has no changes to discard.`);
@@ -2,7 +2,7 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { rename } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { getProjectPaths, loadConfig, updateState } from '../core/config.js';
5
+ import { getProjectPaths, loadConfig, loadState, updateState } from '../core/config.js';
6
6
  import { withFileLock } from '../core/file-lock.js';
7
7
  import { downloadFirefoxSource, formatBytes } from '../core/firefox.js';
8
8
  import { getFurnacePaths, updateFurnaceState } from '../core/furnace-config.js';
@@ -10,6 +10,7 @@ import { getHead, initRepository, isGitRepository, isMissingHeadError, resumeRep
10
10
  import { restoreTrackedPath } from '../core/git-file-ops.js';
11
11
  import { getDirtyFiles } from '../core/git-status.js';
12
12
  import { loadPatchesManifest } from '../core/patch-manifest.js';
13
+ import { formatMajorVersionHopNotice } from '../core/toolchain-preflight.js';
13
14
  import { EngineExistsError, PartialEngineExistsError } from '../errors/download.js';
14
15
  import { toError } from '../utils/errors.js';
15
16
  import { checkDiskSpace, ensureDir, pathExists, pathExistsStrict, removeDir } from '../utils/fs.js';
@@ -196,8 +197,22 @@ async function downloadAndExtractFirefox(args) {
196
197
  throw error;
197
198
  }
198
199
  }
200
+ /**
201
+ * Prints the major-version-hop toolchain nudge when this download moved
202
+ * the engine across a Firefox MAJOR version (152.0b7 → 153.0b8 drill:
203
+ * the first post-hop build died in `mach configure` on a moved cbindgen
204
+ * minimum, and nothing in the download output suggested re-running
205
+ * `fireforge bootstrap`). Quiet on first downloads and same-major
206
+ * re-downloads.
207
+ */
208
+ function noteMajorVersionHop(previousVersion, version) {
209
+ const hopNotice = formatMajorVersionHopNotice(previousVersion, version);
210
+ if (hopNotice) {
211
+ info(hopNotice);
212
+ }
213
+ }
199
214
  async function initializeDownloadedEngine(args) {
200
- const { projectRoot, patchesDir, version, engineDir, replacementActivated, backupEngineDir } = args;
215
+ const { projectRoot, patchesDir, version, previousVersion, engineDir, replacementActivated, backupEngineDir, } = args;
201
216
  // Finding #17: the git indexing phase of `download` can block for
202
217
  // minutes on a ~600 MB Firefox tree. Emit a one-line heads-up banner
203
218
  // before the spinner starts so CI logs show the expected duration.
@@ -239,6 +254,7 @@ async function initializeDownloadedEngine(args) {
239
254
  baseCommit,
240
255
  });
241
256
  await noteUnappliedPatches(patchesDir);
257
+ noteMajorVersionHop(previousVersion, version);
242
258
  if (backupEngineDir) {
243
259
  await removeDir(backupEngineDir);
244
260
  }
@@ -264,6 +280,9 @@ export async function downloadCommand(projectRoot, options) {
264
280
  intro('FireForge Download');
265
281
  const config = await loadConfig(projectRoot), version = config.firefox.version;
266
282
  const paths = getProjectPaths(projectRoot);
283
+ // Captured BEFORE any state update so the post-download major-hop
284
+ // notice compares against what was actually on disk until now.
285
+ const previousVersion = (await loadState(projectRoot)).downloadedVersion;
267
286
  info(`Firefox version: ${version}`);
268
287
  await checkDiskSpace(projectRoot, 5 * 1024 * 1024 * 1024, warn);
269
288
  await withFileLock(join(paths.fireforgeDir, 'download.fireforge.lock'), async () => {
@@ -318,6 +337,7 @@ export async function downloadCommand(projectRoot, options) {
318
337
  baseCommit,
319
338
  });
320
339
  await noteUnappliedPatches(paths.patches);
340
+ noteMajorVersionHop(previousVersion, version);
321
341
  outro(`Firefox ${version} is ready! (resumed from partial init)`);
322
342
  return;
323
343
  }
@@ -388,6 +408,7 @@ export async function downloadCommand(projectRoot, options) {
388
408
  projectRoot,
389
409
  patchesDir: paths.patches,
390
410
  version,
411
+ previousVersion,
391
412
  engineDir: installEngineDir,
392
413
  replacementActivated,
393
414
  ...(backupEngineDir !== undefined ? { backupEngineDir } : {}),
@@ -124,7 +124,7 @@ export async function runCommand(projectRoot, options = {}) {
124
124
  return;
125
125
  }
126
126
  info('Launching browser...\n');
127
- const exitCode = await run(paths.engine);
127
+ const exitCode = await run(paths.engine, options.headless ? ['--headless'] : []);
128
128
  // Exit-code whitelist:
129
129
  // 0 — clean shutdown
130
130
  // 130 — SIGINT (Ctrl+C), user-initiated termination
@@ -212,11 +212,22 @@ async function runSmokeExit(engineDir, options) {
212
212
  }
213
213
  findings.push({ stream, line });
214
214
  };
215
+ // A headed smoke window on a developer desktop absorbs live input:
216
+ // during the drill a human interacted with the window mid-run, the
217
+ // password-manager import scan probed an unreadable Chrome profile
218
+ // dir, and the resulting console errors failed the smoke run looking
219
+ // like a product regression. CI hosts (CI env var set) are assumed
220
+ // display-free/unattended, so the notice stays quiet there.
221
+ if (!options.headless && !process.env['CI']) {
222
+ warn('Headed smoke window: keyboard/mouse input during the window will contaminate the ' +
223
+ 'console capture and can fail the run. Pass --headless (or run on an unattended host) ' +
224
+ 'for reliable smoke checks.');
225
+ }
215
226
  info(`Launching browser (smoke-exit after ${String(smokeExit)}s)...\n`);
216
227
  const startedAt = Date.now();
217
228
  let result;
218
229
  try {
219
- result = await runMachSmoke(['run'], engineDir, {
230
+ result = await runMachSmoke(options.headless ? ['run', '--headless'] : ['run'], engineDir, {
220
231
  smokeTimeoutMs,
221
232
  onStdoutLine: (line) => {
222
233
  handleLine('stdout', line);
@@ -332,6 +343,7 @@ export function registerRun(program, { getProjectRoot, withErrorHandling }) {
332
343
  }, [])
333
344
  .option('--console-allow-file <path>', 'Newline-delimited allowlist regex file. Blank lines and # comments are ignored.')
334
345
  .option('--capture-console <file>', 'Mirror captured console output to <file> for post-exit inspection.')
346
+ .option('--headless', 'Launch the browser with --headless. Recommended for --smoke-exit on a shared desktop: input into a headed smoke window contaminates the console capture.')
335
347
  .action(withErrorHandling(async (options) => {
336
348
  await runCommand(getProjectRoot(), pickDefined(options));
337
349
  }));
@@ -8,6 +8,7 @@ import { assertMarionettePortAvailable, extractForwardedMarionettePort, forwarde
8
8
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
9
9
  import { buildHarnessCrashMessage } from '../core/test-harness-crash.js';
10
10
  import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
11
+ import { analyzeTestPathScopes, formatScopeNotice } from '../core/test-path-scope.js';
11
12
  import { checkStaleBuildForTest, formatStaleBuildWarning } from '../core/test-stale-check.js';
12
13
  import { findNearestXpcshellManifest } from '../core/xpcshell-appdir.js';
13
14
  import { GeneralError } from '../errors/base.js';
@@ -359,11 +360,27 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
359
360
  extraArgs.push(...forwardedMachArgs);
360
361
  }
361
362
  appendMarionetteForwardingArgs(extraArgs, options, forwardedPort);
363
+ // Directory arguments mean EXACTLY that directory: mozbuild's test
364
+ // resolver matches paths by string prefix, so a bare directory arg
365
+ // silently swept in prefix-named siblings (152.0b7 → 153.0b8 drill:
366
+ // `…/test/hominis` also ran `…/test/hominis-tiles`, 1224 tests instead
367
+ // of ~200, with no indication the scope widened). The dispatch form
368
+ // gains a trailing `/` (making the prefix match exact) and any
369
+ // excluded prefix-siblings are echoed so the narrowed scope is
370
+ // visible. Classification above intentionally used the un-slashed
371
+ // forms; only the mach dispatch needs the exact-match shape.
372
+ const scopes = await analyzeTestPathScopes(paths.engine, normalizedPaths);
373
+ const dispatchPaths = scopes.map((scope) => scope.dispatchPath);
374
+ for (const scope of scopes) {
375
+ const notice = formatScopeNotice(scope);
376
+ if (notice)
377
+ info(notice);
378
+ }
362
379
  // xpcshell appdir auto-injection happens per harness invocation inside
363
380
  // `runTestsWithRetries` (src/commands/test-run.ts) so sharded runs probe
364
381
  // the manifest for each file individually. See src/core/xpcshell-appdir.ts
365
382
  // for the full motivation.
366
- logTestSelection(normalizedPaths);
383
+ logTestSelection(dispatchPaths);
367
384
  const perfSampleEnv = buildPerfSampleEnv(projectRoot, projectConfig.binaryName, options.perfSamples);
368
385
  const runCtx = {
369
386
  engineDir: paths.engine,
@@ -380,14 +397,22 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
380
397
  // Multi-file requests shard into sequential single-file harness runs by
381
398
  // default (field report C3): one shared mochitest profile across files
382
399
  // bleeds pref/media-query state into later files. --no-shard restores
383
- // the combined invocation.
384
- if (normalizedPaths.length > 1 && options.shard !== false) {
385
- await runShardedTests(runCtx, normalizedPaths, (outcome, path) => diagnoseShardOutcome(outcome, path, projectConfig.binaryName, postRebuildContext));
400
+ // the combined invocation. The default must not be SILENT (drill
401
+ // finding: a two-file cross-file pollution repro "passed" because the
402
+ // headed comparison run was sharded without saying so, briefly
403
+ // misattributing a suite-context bug to an upstream headless
404
+ // regression), so a one-line notice states what sharding does and
405
+ // does not exercise.
406
+ if (dispatchPaths.length > 1 && options.shard !== false) {
407
+ info(`Sharding: running ${dispatchPaths.length} test paths in isolated browser instances ` +
408
+ '(one mach invocation per path). Cross-file state is NOT exercised — pass --no-shard ' +
409
+ 'for a combined single-instance run.');
410
+ await runShardedTests(runCtx, dispatchPaths, (outcome, path) => diagnoseShardOutcome(outcome, path, projectConfig.binaryName, postRebuildContext));
386
411
  return;
387
412
  }
388
413
  let outcome;
389
414
  try {
390
- outcome = await runTestsWithRetries(runCtx, normalizedPaths);
415
+ outcome = await runTestsWithRetries(runCtx, dispatchPaths);
391
416
  }
392
417
  catch (error) {
393
418
  throw new BuildError('Test process failed to start', 'mach test', error instanceof Error ? error : undefined);
@@ -427,7 +452,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
427
452
  return n;
428
453
  })
429
454
  .option('--generic-mach-test', 'Force dispatch through generic `mach test` instead of the suite-specific `mach xpcshell-test` / `mach mochitest` a single-suite run auto-selects (the suite-specific commands skip the mozlog resource monitor that crashes `mach test` on some hosts).')
430
- .option('--no-shard', 'Run multiple test paths in one combined mach invocation instead of sequential per-file shards')
455
+ .option('--no-shard', 'Run multiple test paths in one combined mach invocation instead of sequential per-file shards (isolated instances do not exercise cross-file state, so use this to reproduce cross-file pollution bugs)')
431
456
  .option('--perf-samples <path>', 'Publish a perf-sample artifact path to the harness via <BINARYNAME>_PERF_SAMPLE_JSON (resolved against the project root)')
432
457
  .option('--marionette-port <port>', 'Override the Marionette control port (default 2828) for the stale-browser probe, the --doctor preflight, and (unless --mach-arg includes --flavor=xpcshell) auto-forwarded mach args: --setpref=marionette.port=<n> (browser listener) and --marionette=127.0.0.1:<n> (mochitest client). Omits the client flag when --mach-arg already sets --marionette. Use when 2828 is busy or CI assigns another port.', (raw) => {
433
458
  const n = Number.parseInt(raw, 10);
@@ -436,6 +461,21 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
436
461
  }
437
462
  return n;
438
463
  })
464
+ .addHelpText('after', [
465
+ '',
466
+ '[paths...] semantics: a directory argument selects EXACTLY that',
467
+ 'directory. FireForge normalizes it with a trailing "/" before',
468
+ 'handing it to mach, because mach resolves test paths by string',
469
+ 'prefix and a bare directory name would silently sweep in sibling',
470
+ 'directories sharing the prefix (e.g. foo also running foo-extras).',
471
+ 'When such siblings exist, the excluded directories are listed with',
472
+ 'their test-file counts; pass them as separate paths to include them.',
473
+ '',
474
+ 'Multiple paths shard into sequential isolated harness runs (one',
475
+ 'browser instance per path) by default, which does not exercise',
476
+ 'cross-file state; --no-shard restores the combined single-instance',
477
+ 'invocation.',
478
+ ].join('\n'))
439
479
  .action(withErrorHandling(async (paths, options) => {
440
480
  await testCommand(getProjectRoot(), paths, pickDefined(options));
441
481
  }));
@@ -15,6 +15,7 @@ import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceSta
15
15
  import { runFurnaceMutation } from './furnace-operation.js';
16
16
  import { cleanStories } from './furnace-stories.js';
17
17
  import { generateMozconfig, runMachCapture } from './mach.js';
18
+ import { explainMachError } from './mach-error-hints.js';
18
19
  /** Path fragments of files whose edits invalidate the recursive-make backend. */
19
20
  const BACKEND_INVALIDATING_SUFFIXES = ['moz.build', 'moz.configure', 'Makefile.in'];
20
21
  /**
@@ -31,6 +32,21 @@ function extractMachConfigureError(result) {
31
32
  return '';
32
33
  return combined.split('\n').slice(-40).join('\n').trim();
33
34
  }
35
+ /**
36
+ * Builds the `BuildError` for a non-zero auto-configure exit: the output
37
+ * tail (so the underlying mozbuild failure survives), any matched
38
+ * mach-error hints (so e.g. a toolchain minimum that moved with a source
39
+ * hop names `fireforge bootstrap` on this path too, exactly like the
40
+ * protected build dispatch), and the stop rationale.
41
+ */
42
+ function buildConfigureFailureError(captured) {
43
+ const detail = extractMachConfigureError(captured);
44
+ const hints = explainMachError(`${captured.stderr}\n${captured.stdout}`);
45
+ return new BuildError(`Backend regeneration failed: mach configure exited with code ${captured.exitCode}.` +
46
+ (detail ? `\n\nmach configure output (tail):\n${detail}` : '') +
47
+ (hints.length > 0 ? `\n\n${hints.map((hint) => `Hint: ${hint}`).join('\n')}` : '') +
48
+ '\n\nBuild stopped because continuing would hide the real configure failure.', 'mach configure');
49
+ }
34
50
  /**
35
51
  * Returns true when the file path matches a pattern that forces
36
52
  * `mach configure` to regenerate the backend. Exported for testing.
@@ -84,11 +100,9 @@ export async function prepareBuildEnvironment(projectRoot, paths, config, option
84
100
  if (exitCode !== 0) {
85
101
  configureSpinner.error(`mach configure failed with exit code ${exitCode}`);
86
102
  // Surface the underlying mozbuild error (e.g. UnsortedError) instead
87
- // of a bare exit code — the generic message hid the actual cause.
88
- const detail = extractMachConfigureError(captured);
89
- throw new BuildError(`Backend regeneration failed: mach configure exited with code ${exitCode}.` +
90
- (detail ? `\n\nmach configure output (tail):\n${detail}` : '') +
91
- '\n\nBuild stopped because continuing would hide the real configure failure.', 'mach configure');
103
+ // of a bare exit code — the generic message hid the actual cause
104
+ // plus any matched mach-error hints (see the helper).
105
+ throw buildConfigureFailureError(captured);
92
106
  }
93
107
  else {
94
108
  configureSpinner.stop('Backend regenerated successfully (mach configure exit code 0)');
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { pipeline } from 'node:stream/promises';
10
10
  import { ChecksumMismatchError } from '../errors/download.js';
11
11
  import { toError } from '../utils/errors.js';
12
- import { pathExists, readJson, removeFile, writeJson } from '../utils/fs.js';
12
+ import { pathExistsStrict, readJson, removeFile, writeJson } from '../utils/fs.js';
13
13
  import { verbose } from '../utils/logger.js';
14
14
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
15
15
  import { validateArchiveMetadata } from './firefox-archive.js';
@@ -49,8 +49,8 @@ export async function ensureCachedArchive(archive, cacheDir, onProgress, expecte
49
49
  });
50
50
  }
51
51
  async function cacheEntryExists(archive, cacheDir) {
52
- return ((await pathExists(join(cacheDir, archive.filename))) ||
53
- (await pathExists(join(cacheDir, archive.metadataFilename))));
52
+ return ((await pathExistsStrict(join(cacheDir, archive.filename))) ||
53
+ (await pathExistsStrict(join(cacheDir, archive.metadataFilename))));
54
54
  }
55
55
  /**
56
56
  * Validates a cached archive using sidecar metadata and SHA-256 checksum.
@@ -61,7 +61,10 @@ async function cacheEntryExists(archive, cacheDir) {
61
61
  async function validateCachedArchive(archive, cacheDir, expectedSha256) {
62
62
  const tarballPath = join(cacheDir, archive.filename);
63
63
  const metadataPath = join(cacheDir, archive.metadataFilename);
64
- if (!(await pathExists(tarballPath)) || !(await pathExists(metadataPath))) {
64
+ // Deliberately outside the try/catch below: a permission error probing the
65
+ // cache must propagate instead of reading as "invalid cache" and triggering
66
+ // a re-download into a directory we cannot write anyway.
67
+ if (!(await pathExistsStrict(tarballPath)) || !(await pathExistsStrict(metadataPath))) {
65
68
  return false;
66
69
  }
67
70
  try {
@@ -2,7 +2,27 @@
2
2
  * Firefox source extraction and installed version detection.
3
3
  */
4
4
  /**
5
- * Extracts a tar.xz archive.
5
+ * Validates entry names from a `tar -tf` listing.
6
+ * @param names - Listing lines (one member name per line)
7
+ * @returns The first unsafe member name, or null when all are safe
8
+ */
9
+ export declare function findUnsafeArchiveEntryName(names: readonly string[]): string | null;
10
+ /**
11
+ * Validates symlink and hardlink targets from a `tar -tvf` listing.
12
+ *
13
+ * A relative link target without `..` segments can only resolve inside the
14
+ * extraction root, so only absolute or `..`-containing targets are rejected.
15
+ * Symlinks are `l`-typed lines with a ` -> target` suffix (the LAST arrow is
16
+ * taken, so a link whose own name contains ` -> ` still parses to its real
17
+ * target); hardlinks print ` link to target` on GNU tar and bsdtar alike.
18
+ *
19
+ * @param verboseLines - `tar -tvf` listing lines
20
+ * @returns A description of the first unsafe link found, or null
21
+ */
22
+ export declare function findUnsafeArchiveLink(verboseLines: readonly string[]): string | null;
23
+ /**
24
+ * Extracts a tar.xz archive after a listing preflight that rejects
25
+ * path-traversal member names and escaping link targets.
6
26
  * @param archivePath - Path to the archive
7
27
  * @param destDir - Destination directory
8
28
  */
@@ -8,7 +8,111 @@ import { elapsedSince } from '../utils/elapsed.js';
8
8
  import { ensureDir, pathExists } from '../utils/fs.js';
9
9
  import { exec, executableExists } from '../utils/process.js';
10
10
  /**
11
- * Extracts a tar.xz archive.
11
+ * Returns true when an archive member path could land outside the
12
+ * extraction root: absolute (POSIX or Windows drive/backslash rooted) or
13
+ * containing a `..` segment.
14
+ */
15
+ function isUnsafeArchivePath(path) {
16
+ if (path.startsWith('/') || path.startsWith('\\'))
17
+ return true;
18
+ if (/^[A-Za-z]:[\\/]/.test(path))
19
+ return true;
20
+ return path.split(/[\\/]/).includes('..');
21
+ }
22
+ /**
23
+ * Validates entry names from a `tar -tf` listing.
24
+ * @param names - Listing lines (one member name per line)
25
+ * @returns The first unsafe member name, or null when all are safe
26
+ */
27
+ export function findUnsafeArchiveEntryName(names) {
28
+ for (const raw of names) {
29
+ const name = raw.trim();
30
+ if (name.length === 0)
31
+ continue;
32
+ if (isUnsafeArchivePath(name))
33
+ return name;
34
+ }
35
+ return null;
36
+ }
37
+ /**
38
+ * Validates symlink and hardlink targets from a `tar -tvf` listing.
39
+ *
40
+ * A relative link target without `..` segments can only resolve inside the
41
+ * extraction root, so only absolute or `..`-containing targets are rejected.
42
+ * Symlinks are `l`-typed lines with a ` -> target` suffix (the LAST arrow is
43
+ * taken, so a link whose own name contains ` -> ` still parses to its real
44
+ * target); hardlinks print ` link to target` on GNU tar and bsdtar alike.
45
+ *
46
+ * @param verboseLines - `tar -tvf` listing lines
47
+ * @returns A description of the first unsafe link found, or null
48
+ */
49
+ export function findUnsafeArchiveLink(verboseLines) {
50
+ for (const line of verboseLines) {
51
+ if (line.startsWith('l')) {
52
+ const arrow = line.lastIndexOf(' -> ');
53
+ if (arrow !== -1) {
54
+ const target = line.slice(arrow + 4).trim();
55
+ if (isUnsafeArchivePath(target))
56
+ return `symlink target: ${target}`;
57
+ continue;
58
+ }
59
+ }
60
+ const hardlink = line.lastIndexOf(' link to ');
61
+ if (hardlink !== -1) {
62
+ const target = line.slice(hardlink + 9).trim();
63
+ if (isUnsafeArchivePath(target))
64
+ return `hardlink target: ${target}`;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Lists the archive and rejects members that could escape the extraction
71
+ * root before anything is written to disk: absolute names, `..` traversal,
72
+ * and symlink/hardlink targets that are absolute or `..`-escaping. Modern
73
+ * GNU tar / bsdtar refuse most of these at extraction time; the preflight
74
+ * makes the guarantee explicit and independent of the host tar's defaults.
75
+ *
76
+ * Two listings are needed because they answer different questions and
77
+ * neither is safe to derive from the other: member names come from `-tf`,
78
+ * where the whole line IS the name, while link targets only appear in
79
+ * `-tvf`. Names are deliberately NOT parsed out of the `-tvf` columns — the
80
+ * adjacent uname/gname fields are attacker-controlled in a crafted archive,
81
+ * so a date-shaped owner name could shift the column boundary and hide an
82
+ * absolute member name from the check. Each listing pass costs one full
83
+ * decompression, so the two run concurrently and overlap almost perfectly
84
+ * (they are CPU-bound on separate cores). Measured on a real Firefox ESR
85
+ * 140.9 source tarball (601 MB, bsdtar 3.5.3, macOS): -tf 21.7 s, -tvf
86
+ * 22.1 s, both concurrent 22 s wall, extraction itself 58 s (file-creation
87
+ * syscalls dominate it, not decompression) — so the preflight adds ~22 s
88
+ * (~38%) to this one-time extraction step, versus ~44 s if run
89
+ * sequentially.
90
+ */
91
+ async function preflightArchiveEntries(archivePath) {
92
+ // LC_ALL=C keeps the -tvf column format stable for the link parse.
93
+ 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 }),
97
+ ]);
98
+ if (nameListing.exitCode !== 0) {
99
+ throw new ExtractionError(archivePath, new Error(`tar -tf preflight exited with code ${nameListing.exitCode}:\n${nameListing.stderr}`));
100
+ }
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}`));
104
+ }
105
+ if (verboseListing.exitCode !== 0) {
106
+ throw new ExtractionError(archivePath, new Error(`tar -tvf preflight exited with code ${verboseListing.exitCode}:\n${verboseListing.stderr}`));
107
+ }
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})`));
111
+ }
112
+ }
113
+ /**
114
+ * Extracts a tar.xz archive after a listing preflight that rejects
115
+ * path-traversal member names and escaping link targets.
12
116
  * @param archivePath - Path to the archive
13
117
  * @param destDir - Destination directory
14
118
  */
@@ -18,18 +122,24 @@ export async function extractTarXz(archivePath, destDir, onProgress) {
18
122
  }
19
123
  await ensureDir(destDir);
20
124
  const startedAt = Date.now();
21
- onProgress?.(`Extracting source archive (${elapsedSince(startedAt)} elapsed)...`);
125
+ onProgress?.(`Validating source archive entries (${elapsedSince(startedAt)} elapsed)...`);
22
126
  const heartbeat = onProgress
23
127
  ? setInterval(() => {
24
128
  onProgress(`Extracting source archive (${elapsedSince(startedAt)} elapsed)...`);
25
129
  }, 15_000)
26
130
  : null;
27
131
  heartbeat?.unref();
28
- const result = await exec('tar', ['-xf', archivePath, '-C', destDir]);
29
- if (heartbeat)
30
- clearInterval(heartbeat);
31
- if (result.exitCode !== 0) {
32
- throw new ExtractionError(archivePath, new Error(`tar exited with code ${result.exitCode}:\n${result.stderr}`));
132
+ try {
133
+ await preflightArchiveEntries(archivePath);
134
+ onProgress?.(`Extracting source archive (${elapsedSince(startedAt)} elapsed)...`);
135
+ const result = await exec('tar', ['-xf', archivePath, '-C', destDir]);
136
+ if (result.exitCode !== 0) {
137
+ throw new ExtractionError(archivePath, new Error(`tar exited with code ${result.exitCode}:\n${result.stderr}`));
138
+ }
139
+ }
140
+ finally {
141
+ if (heartbeat)
142
+ clearInterval(heartbeat);
33
143
  }
34
144
  onProgress?.(`Source archive extracted (${elapsedSince(startedAt)} elapsed)`);
35
145
  }