Skip to content

Fix user-visible correctness bugs in sitemap, clean, retry and links - #13

Open
Hugoer wants to merge 7 commits into
mainfrom
fix/correctness-sitemap-clean-retry-links
Open

Fix user-visible correctness bugs in sitemap, clean, retry and links#13
Hugoer wants to merge 7 commits into
mainfrom
fix/correctness-sitemap-clean-retry-links

Conversation

@Hugoer

@Hugoer Hugoer commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Closes #10.

Five correctness bugs found reviewing main at d55335e, one commit each, plus the logger
sweep and regenerated types.

Behaviour changes

Sitemap URLs with entities are now decoded. extractUrls returned the raw text between
<loc> tags, so every sitemap entry carrying a query string came out corrupted:
https://a.com/p?x=1&amp;y=2 instead of https://a.com/p?x=1&y=2. The bad URL flowed
straight into --urls-file for psi, lab and crux, which then audited a URL that does not
exist. Decoding covers the five predefined entities plus decimal and hex character
references, in a single pass so &amp;lt; yields the literal &lt; rather than decoding
twice. Unknown entities and out-of-range references are left as written.

clean now exits 1 on a missing input. It logged "Input not found" but returned an
all-empty result, printed "0 succeeded, 0 failed" and exited 0 — a mistyped path was
indistinguishable from a legitimate no-op. Existence is checked directly rather than
inferred from an empty file list, so a directory that exists but holds no .json files, and
a glob matching nothing, both still exit 0.

withRetry now retries network faults. The retryable check keyed entirely off
err.statusCode, but global fetch rejects with a bare TypeError: fetch failed and hangs
the reason off cause, so a dropped connection or DNS blip carried no status and threw on
the first attempt. isRetryableError now walks the cause chain for an OS or undici code.
ENOTFOUND and ECONNREFUSED stay excluded on purpose: a hostname that does not resolve is
nearly always a permanent mistake, and retrying costs the full backoff on every URL of a
batch that was never going to succeed. An explicit status still wins over any code.

links now launches Chrome with the full flag set. It launched on --headless alone,
missing --no-sandbox and --disable-dev-shm-usage, so it failed in exactly the Docker and
CI environments where lab works. It now reuses the CHROME_FLAGS array lab exports.

Sitemap index following is now scoped to the starting origin. parseSitemaps followed
<loc> to any host, so an index from an untrusted domain could steer the crawler at
http://localhost:... or a link-local metadata address and copy any <loc>-shaped
substring of the response into the output file. The scope is fixed by the URL the crawl
started from and carried down unchanged, so a nested index cannot widen it one hop at a time.

Worth naming: origin comparison includes scheme, host and port, so an index at
https://example.com that lists https://www.example.com children now skips them, with a
warning. That is out of spec — the sitemaps protocol scopes a sitemap to its own host and
requires verification for cross-host submission — but it is visible to anyone relying on it.

Not a behaviour change

sitemap.js wrote straight to console.log / console.warn while every other module goes
through lib/logger. Routed through the logger; the literal Warning: prefixes were
dropped because logger.warn supplies its own.

Verification

  • npm run lint — 0 errors, the same 6 pre-existing max-len warnings, no files modified.
  • npm test — 544 passed, up from 506. New coverage for entity decoding, the missing-input
    exit path, network retry classification, and origin partitioning.
  • npm run generate-types — committed. Also sweeps one pre-existing drift: runBatch's
    onProgress already declared statusCode in the source at d55335e but not in the
    checked-in .d.ts.
  • End-to-end, with a stubbed fetch: a sitemap index listing 169.254.169.254 and
    evil.com children fetches neither, and ?a=1&amp;b=2 arrives as ?a=1&b=2.
  • End-to-end, real CLI: clean does-not-exist.json exits 1; clean <empty-dir> exits 0.

Known gap

The links fix has no regression test. chrome-launcher is required at module scope and lib
modules load through require(), which bypasses the test runner's module graph, so
vi.mock cannot intercept it. Covering it needs an injectable launcher seam like the one
runLabPlan takes — a structural change that belongs with #11, not in a behaviour fix.

The sitemaps protocol requires `&` to be escaped as `&amp;` inside <loc>, so extractUrls
returned a corrupted URL for every sitemap entry carrying a query string:
`https://a.com/p?x=1&amp;y=2` instead of `https://a.com/p?x=1&y=2`. The bad URL flowed
straight into --urls-file for psi, lab and crux, which then audited a URL that does not exist.

Decodes the five predefined entities plus decimal and hex character references in a single
pass, so an escaped entity such as `&amp;lt;` yields the literal `&lt;` rather than being
decoded twice. Unknown named entities and out-of-range character references are left as
written rather than throwing, so a malformed entity in someone else's sitemap cannot abort
the extraction.
runCleanCmd logged "Input not found" but returned an all-empty result, so the command
printed "0 succeeded, 0 failed" and exited 0. A mistyped path in a script or CI step was
indistinguishable from a run that legitimately had nothing to do.

It now throws, which withCatch in bin/web-perf.js already maps to exit 1.

Existence is checked directly instead of being inferred from an empty file list, so the
two states stay distinct: a directory that exists but holds no .json files, and a glob that
matches nothing, both remain empty result sets that exit 0.
The retryable check keyed entirely off err.statusCode, but global fetch rejects with a bare
`TypeError: fetch failed` and hangs the real reason off `cause`, so a dropped connection or
a DNS blip carried no statusCode at all. `undefined >= 500` is false, so the retry layer
threw on the first attempt for the most common transient failure it exists for. In a long
batch that meant dropping URLs a single retry would have saved.

isRetryableError now walks the cause chain for an OS or undici error code when no status is
present. ENOTFOUND and ECONNREFUSED are excluded on purpose: a hostname that does not
resolve, or a port with nothing behind it, is nearly always a permanent mistake, and
retrying one costs the full backoff on every URL of a batch that was never going to succeed.

An explicit statusCode still wins over any code, since an answered request is not transient.
runLinks launched on '--headless' alone, so it was missing --no-sandbox and
--disable-dev-shm-usage and failed in exactly the Docker and CI environments where lab
works. It now reuses the CHROME_FLAGS array lab already exports, taking all eleven flags.

No regression test: chrome-launcher is required at module scope and lib modules are loaded
with require(), which bypasses the test runner's module graph, so vi.mock cannot intercept
it. Covering this would need an injectable launcher seam like the one runLabPlan takes,
which is a structural change and does not belong in a behaviour fix.
parseSitemaps followed <loc> to any host, so a sitemap index fetched from an untrusted
domain could steer the crawler at http://localhost:... or a link-local metadata address, and
any <loc>-shaped substring of the response landed in the output file.

The scope is fixed by the URL the crawl started from and carried down the recursion
unchanged, so a nested index cannot widen it one hop at a time. Off-origin children are
logged and skipped rather than dropped silently.

Behaviour change worth naming: origin comparison includes scheme, host and port, so an index
that lists https://www.example.com children under https://example.com now skips them. That
is out of spec — the sitemaps protocol scopes a sitemap to its own host and requires
verification for cross-host submission — but it will be visible to anyone relying on it.
sitemap.js wrote straight to console.log and console.warn while every other module goes
through lib/logger — lab.js goes as far as reporting through hooks to stay console-free.
Progress lines become logger.info and the two failure paths become logger.warn.

logger.warn supplies its own "Warning: " prefix, so the literal one is dropped from both
messages rather than being printed twice.
Picks up the three new exports (decodeXmlEntities, partitionByOrigin, isRetryableError),
the withRetry doc change, and the @throws on runCleanCmd.

Also sweeps up one pre-existing drift: runBatch's onProgress already declared a statusCode
argument in lib/utils.js at d55335e, but the checked-in .d.ts did not, so generate-types had
not been run after that change landed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix user-visible correctness bugs in sitemap, clean, retry and links

1 participant