Skip to content

the one PR - #53

Open
eilandert wants to merge 443 commits into
tokers:masterfrom
myguard-labs:master
Open

eilandert wants to merge 443 commits into
tokers:masterfrom
myguard-labs:master

Conversation

@eilandert

Copy link
Copy Markdown
Contributor

https://deb.myguard.nl/2026/05/zstd-nginx-module-what-it-does-bugs-fixed/

New Directives & Features

  • zstd_long: Enable long-distance matching to achieve ~2:1 compression improvement on large repetitive bodies (concatenated JSON, HTML boilerplate, logs) with configurable memory ceiling via zstd_window_log.
  • zstd_bypass: Per-request compression opt-out via nginx predicates for BREACH mitigation (exclude endpoints mixing secrets with user-controlled input; requires application-level defenses).
  • zstd_static startup warning: Warn operators at nginx startup if zstd_static enabled without gzip_vary to prevent CDN cache collision issues.
  • Comprehensive debug logging: Added ngx_log_debug diagnostic logging for all 8 header-filter rejection reasons, buffer decisions, compression state, and emit decisions (zero cost in release builds, visible with error_log debug).
  • zstd_max_length on chunked: Extended zstd_max_length enforcement to track running input total on chunked responses (no Content-Length) to prevent DoS from unbounded upstreams.

Added optimisations:

  • Per-request context reuse done right. Each request gets its own zstd context, but the underlying allocation is reused across requests in a worker via reset — correctness and speed.
  • Modern single-call streaming API. Replaced three legacy streaming calls and a deprecated init API with one ZSTD_compressStream2(), and dropped a custom allocator that added nothing.
  • Pledged source size. When Content-Length is known, the exact size is pledged to zstd up front, producing a more compact frame header and slightly better speed and ratio at zero cost.
  • Output buffers default to ZSTD_CStreamOutSize(). The encoder’s own recommended granularity (~128 KB), so zstd never has to fragment a block across calls. Two buffers, so one fills while the other is in flight.
  • Hot-path loc-conf hoist. The location config is resolved once per request instead of once per inner-loop iteration — measurable on large responses.
  • Single-division ratio math for $zstd_ratio instead of dividing twice.
  • Skip a redundant loop iteration for an empty terminal buffer in the add-data path.
  • New operator capabilities: zstd_max_length (cap huge responses), zstd_window_log (bound per-request memory — predictable RSS under load), zstd_long (long-distance matching for big repetitive bodies), zstd_bypass (the BREACH lever), and $zstd_bytes_in / $zstd_bytes_out log variables for real observability.

Fixed bugs:

  • Silent truncation at 131072 bytes. Any response larger than zstd’s internal stream buffer (ZSTD_CStreamInSize, exactly 128 KB) was chopped off at that boundary. Big CSS bundles and JavaScript files simply ended mid-stream. This is the bug that makes a site look fine on the homepage and shatter the moment a real asset loads.
  • Terminal frame never emitted on empty end. When the final compression call produced zero output bytes (it happens — the encoder had already flushed everything), the module forgot to write zstd’s end-of-frame marker. Decoders saw a stream that just stopped and rejected it.
  • Truncation/abort with proxy_buffering off (the “bug B” saga). This was the production incident that took down deb.myguard.nl‘s wp-admin. With unbuffered proxying (the FastCGI/PHP path), the upstream forces a flush around a chunk that zstd buffers internally. The directive logic never told libzstd to actually flush, so it sat on the bytes forever — manifesting as a truncated response and a worker spinning at 100% CPU in a livelock. The fix has two halves: map a pending flush to ZSTD_e_flush, and clear the flush state when the encoder reports it’s fully drained so the loop can terminate.
  • NULL-deref / worker SIGSEGV on multi-buffer chunked responses. On any response big enough to need more than one output buffer (chunked or no-Content-Length is the common trigger), a recycled buffer pointer was dereferenced after being invalidated — a clean worker segfault that only appeared past the first ~128 KB.
  • 100% CPU infinite loop. A separate flush state-machine defect (PR perf: default zstd_buffers to 4x32k; document stacked-PR workflow myguard-labs/nginx-zstd-module#23 + Test/expand coverage myguard-labs/nginx-zstd-module#49 class) where the inner compression loop never reached a termination condition, pinning a core until the request timed out.
  • ZSTD_CDict leaked on every config reload. If you used a compression dictionary, every nginx -s reload leaked the entire dictionary. On a busy box that reloads config often, that’s a steady climb to OOM.
  • Cleanup handler registered with the wrong size. The dictionary cleanup used a non-zero size in ngx_pool_cleanup_add(), which subtly mismanaged the cleanup bookkeeping. Fixed to pass size=0 as the API intends.
  • Shared compression context across requests. The original reused one zstd context worker-wide. Overlapping requests in a single worker could stomp each other’s compression state — intermittent corruption that’s almost impossible to reproduce on demand. Now every request gets its own context, attached to the request’s cleanup chain.
  • Version-specific dictionary init error handling. On older libzstd, the dictionary init path used a different API whose error return wasn’t checked correctly, so a failed init could proceed as if it had succeeded.
  • Buffer overflow appending the .zst extension. The static module built the .zst path without reserving enough room, so a long enough request URI could write past the buffer. Classic overflow, now bounded and regression-tested with a 2000-character URI.
  • Integer overflow in the compression-ratio calculation. The $zstd_ratio math could overflow on large responses, producing garbage stats. Reworked with 64-bit scaling.
  • Reject oversized numeric directives (INT_MAX hardening). zstd_window_log and zstd_target_cblock_size are narrowed to int before being handed to libzstd. A configured value above INT_MAX would silently truncate (possibly to a negative). Now rejected at config load with a clear error.
  • Dictionary-file size cap. A 10 MB ceiling on zstd_dict_file prevents a memory-exhaustion denial of service via a giant dictionary.
  • BREACH containment lever (zstd_bypass). A new per-request opt-out so operators can serve identity (uncompressed) on endpoints that mix a secret with attacker-influenced reflected input — the precondition for a BREACH-style attack — without splitting the location config.
  • Unbounded input cap for chunked responses. A streaming response has no Content-Length, so the size check was skipped and a runaway upstream could feed the compressor forever. zstd_max_length is now enforced against the running input total too.
  • The -fPIC linking failure. The config preferred a static libzstd.a that lacks position-independent code and cannot link into a shared dynamic module. Now prefers dynamic linking with a pkg-config fallback.
  • Global CFLAGS mutation. The static module’s config leaked compiler flags into the global build, polluting other modules. Removed.
  • Cross-platform portability. Build logic hardened for Linux, the BSDs, RHEL/Rocky, and Gentoo — different pkg-config tools, different library locations.
  • Cross-compilation support. Set ngx_feature_run=no so the feature probe doesn’t try to execute a test binary (impossible when cross-compiling).
  • Doubled source path. A bug in the addon config concatenated the source directory twice; fixed by scoping ngx_addon_dir per sub-config.
  • Typo: SAVED_CC_TAST_FLAGS. A real, shipped typo that silently failed to restore test flags. One character; genuinely broke the build path.
  • Filter ordering vs. brotli. If both zstd and brotli are enabled, zstd must run first or the ordering produces wrong results. Now enforced.
  • RPATH embedding documented for custom ZSTD_LIB paths, plus advisories when building against libzstd older than 1.4.0 or 1.5.0 (missing APIs).
  • Non-redistributable test fixture replaced with a BSD-2-Clause original, so the whole repo is cleanly licensed.
  • Accept-Encoding parsing was wrong. The header matcher could mis-detect zstd as a substring of another token. Rewritten to tokenize properly.
  • Quality values (q=) ignored. Accept-Encoding: zstd;q=0 means “do NOT send me zstd.” The module compressed anyway. Now it honors RFC 7231 qvalues, including the strict 0–1 range.
  • Content-Type detection used the wrong filename in the static module, so .zst files could get the wrong MIME type. Fixed to use the original (pre-.zst) name.
  • Only 200/403/404 were compressed. Other perfectly compressible 2xx responses (201, 206-not-applicable aside) were skipped. Broadened correctly.
  • 204 and 205 were compressed. Bodyless responses must not carry a Content-Encoding. Now excluded.
  • Accept-Ranges not cleared on compressed static files. Byte ranges into a .zst file are meaningless (offsets don’t map to the original). Now cleared per RFC 9110.
  • Default compression level changed 1 → 3. Level 1 left obvious ratio on the table for negligible CPU savings; 3 is the sane default.
  • Missing gzip_vary warnings. If zstd is enabled but gzip_vary is off, proxies and CDNs can cache a compressed response and serve it to a client that can’t read it. Both the filter and static modules now warn loudly at config load. (We added the static-module half this week.)
  • Two no-op / unreachable code blocks removed during audit, plus a cluster of eight smaller correctness fixes batched from the first full codebase audit (dead code, deduplication, minor bugs).
  • Skip-reason and diagnostic debug logging. “Why isn’t my response compressed?” was previously undiagnosable without a rebuild. Every eligibility-rejection path, the buffer-acquire decision, and the compression-context setup now emit structured debug logs (zero cost in release builds, visible under error_log debug). The permanent emit-decision probe for the truncation bug class is part of this group too.

Added CI pipeline:

  • Build & Test workflow with NGINX 1.31.0 and Angie 1.15.4, regression tests for every bug in the gitlog.
  • ASAN + UBSAN build and soak
  • Monthly valgrind Memcheck soak
  • CodeQL, security scanners, and fuzzing

And more:

@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

mreiden and others added 29 commits August 12, 2026 21:56
…f stat-ing it (#111)

The check has twice failed CI with no leak in sight, both times with
the same signature: "WARNING: ptrace appears to be blocked (is seccomp
enabled?). LeakSanitizer may hang." / "Child exited with signal ...".
LeakSanitizer's exit-time check ptrace-attaches a stop-the-world
tracer; on runners whose LXC seccomp/yama policy blocks ptrace the
tracer dies, LSan writes that WARNING into log_path and reports
nothing -- and this script's verdict was "any asan* file exists ==
leak", so the warning banner alone flipped the check red. Worse, the
same lockdown can also produce NO file at all, which the old check
read as a clean pass: a vacuous verdict from a detector that never
ran. The stray "curl: (7)" line long recorded as part of the flake
signature was a red herring -- the readiness poll's first refused
attempt printing to stderr while the loop retried.

Fixes, in the order they run:

- Positive control: compile a four-line deliberately-leaky canary with
  -fsanitize=address and require LSan to flag it (exitcode 23,
  timeout-bounded since a ptrace-blocked LSan can hang). If the
  detector cannot see a deliberate leak, the environment cannot do
  leak detection -- emit a ::warning:: annotation naming the runner
  fix and exit 0: an infrastructure finding, not a leak, and no longer
  a silent vacuous pass.
- Verdict triage reads log CONTENT: "ERROR:/SUMMARY: (Leak|Address)
  Sanitizer" fails unconditionally (even when the watchdog had to kill
  a hung exit -- a written report stands); the ptrace-blocked warning
  signature without an error report is indeterminate (::warning::,
  exit 0); anything else unexpected still fails loudly.
- Shutdown watchdog (90s + SIGKILL) so "LeakSanitizer may hang" yields
  a verdict instead of eating the job timeout, and wait moved out from
  under set -e -- a real leak makes nginx exit 23, which previously
  aborted the script on the wait line before the report was printed.
- Readiness poll silenced (no more stderr red herring) and given an
  explicit failure when nginx never starts accepting.

A real leak still fails every time: it produces an actual
"ERROR: LeakSanitizer" report, which triage always treats as red.

Verified locally against an ASAN+UBSAN build using the CI job's exact
configure recipe: positive control passes and the run ends green;
triage patterns checked against the failing CI run's literal warning
lines (must not match the error pattern, must match the lockdown
pattern) and a synthetic LSan leak report (must match the error
pattern); shellcheck clean.
… is loaded (#110)

* conf: quiet the gzip_vary-off warnings when a compression_vary module is loaded

HanadaLee's ngx_http_compression_vary_filter_module documents itself as
"used instead of the gzip_vary directive": it keys on r->gzip_vary alone
and injects Accept-Encoding into its single merged Vary header regardless
of clcf->gzip_vary (verified empirically against all four
gzip_vary x compression_vary quadrants). With it loaded, "gzip_vary off"
is the intended configuration, and the filter/static config-load warnings
asking for "gzip_vary on" were pure noise -- one line per merged location
with zstd enabled, on configurations that are entirely correct.

ngx_http_zstd_common.h gains ngx_http_zstd_vary_handled_externally(),
which scans cf->cycle->modules for that exact module name; the scan works
for static linkage and load_module alike, since every load_module
directive is processed (core conf) before the http block's merge phase
runs. Both merge_loc_conf warnings now stay quiet when it returns true.
The suppression is only as correct as that module's documented flag-only
contract, which the helper's comment spells out.

t/02-conf-warn.t TESTs 12-15 pin the base behaviour first: the
warn/no-warn contract of both modules had no tests at all in this repo
(the brotli sibling's TESTs 12a-c never got zstd twins). TEST 12/14
assert the warnings fire without gzip_vary, TEST 13 that a paired config
loads silently, TEST 15 that "always" stays quiet (it never varies).

tools/ci-compression-vary-stub is a CI-only module claiming the exact
third-party name (export-ignore'd like the linkcheck stub -- it must
never ship). The linkage job builds it as a third dynamic module, now
does a full make (the witness needs the binary), and a new step asserts
both directions with nginx -t: without the stub both warnings fire (the
control that keeps the quiet grep from passing vacuously), with the stub
loaded both go quiet. Loading via load_module is deliberately the
detection case under test, being the harder of the two. Mutation-checked
locally: a typo'd detection string flips the witness red while the
control stays green.

* review: replace full suppression with a per-module summary warning

CodeRabbit's catch on the previous commit is correct: the
compression_vary module's own directive defaults to OFF
(ngx_conf_merge_value(conf->enabled, prev->enabled, 0)) and its
disabled path bypasses the header logic entirely, so module presence
alone does not prove Vary is being handled -- full suppression could
silence a real misconfiguration (module loaded, never enabled,
gzip_vary off, no Vary at all).

The literal fix -- reading that module's effective setting -- is not
safely implementable: its conf struct is private (no header to
include; mirroring the layout breaks silently on their next release),
and merge order between unrelated modules follows cycle->modules
order, so their merged values may not even exist when ours merge.

Instead, presence now degrades the warnings rather than deleting them:
merge_loc_conf withholds the per-location lines and counts them in a
cycle-owned main-conf counter (the static module grows a main conf for
this; same rejected-reload reasoning as the #103 dcz counter), and
postconfiguration -- which runs after every merge, so the count is
final -- emits one summary warning per module naming the location
count and exactly what to verify ("compression_vary on" covers them,
since it defaults to off). Never silent, bounded noise.

The stub witness now asserts the summaries (with exact counts, which
also mutation-checks the counter) alongside the absence of the
per-location lines; mutation-checked locally by removing the increment
-- the witness goes red on exactly the full-silence failure this
commit exists to prevent.

New cvary-interop CI job tests the REAL module, pinned by commit like
every other external input: config-load suppression + summary against
the real detection target, then runtime curl assertions of the
semantic contract the summary describes -- "compression_vary on" with
"gzip_vary off" yields exactly one Vary: Accept-Encoding (from
r->gzip_vary alone), "compression_vary off" (the default) yields none,
which is the residual risk the summary warning names. Both
--add-module orders were verified locally to inject identically.

Upstream has since replied to the duplicate-Vary report (their #1):
they will keep r->gzip_vary untouched and document "gzip_vary off" as
the recommended pairing with "compression_vary on", which is the
configuration this warning design steers operators toward.

* build: drop a shadowed local caught by the strict-compile job (-Wshadow)

merge_loc_conf already declares zmcf at function scope for the
dictionary loading; the warning-summary block redeclared it. Reuse the
outer variable.

* ci: assert warning cardinality, not just presence (review)

grep -q waves through duplicate warning emission — e.g. a
postconfiguration hook running twice would double every summary and
still pass. Count each expected line with grep -cF and require exactly
one, in both witness directions and the interop -t check. Verified
locally: all three count assertions pass against the current build.

---------

Co-authored-by: Thijs Eilander <eilander@myguard.nl>
…es (#113)

* ci: gate the ASAN smoke step on leaks, fix the two unstable check names

Three independent CI-integrity fixes, all previously Watch rows in
memory/labs/http-zstd/TODO.md.

1. The ASAN+UBSAN smoke step printed real LeakSanitizer reports and passed
   anyway, so a green run was never leak-free evidence. The leak was
   invisible twice over: LSan signals through the exit status of the nginx
   worker, and every harness accepts SIGTERM (-15) as a clean exit
   (tools/test_encoding.py's `returncode not in (0, -15)`), while the step
   itself never looked at LSan output at all.

   Reports now go to files via log_path and the step greps them after the
   harnesses run. The 56320 bytes / 3 allocations seen on every builder02
   run are nginx-core exit allocations (ngx_event_process_init, event
   arrays, zero zstd frames) and are suppressed by name in lsan.suppress;
   the suppression is deliberately narrow, so a leak from the module itself
   still fails the job. Verified against LSan directly: with the file in
   place a ngx_event_process_init leak is suppressed and a ngx_http_zstd_*
   leak still exits non-zero.

   The suppressions path is absolute. LSan resolves it against the CWD of
   the process it fires in -- an nginx worker started by a harness, not the
   workspace -- and a relative path there makes ASan hard-abort with
   "failed to read suppressions file" rather than degrade.

   A ptrace-blocked runner cannot run the exit-time check at all, which is
   INDETERMINATE rather than clean; that case warns instead of printing a
   checkmark, the same distinction tools/test_reload_leak.sh draws.

2. `build` and `tests` named themselves from the resolved nginx version, so
   the check name changed on every mainline release and neither could be a
   required context. Both names are now fixed and the version is reported
   in a step and the job summary instead. This unblocks adding them to
   ruleset 16557047, which closes the hole where a failed mainline build
   skips `tests` via `needs` and still merges on the 11 required checks.

   The new build step passes matrix values through `env:` rather than
   interpolating them into the shell body: they come from the resolve job's
   scrape of nginx.org, so a direct expansion is a template-injection sink.
   zizmor finding count is unchanged at 21.

3. .coderabbit.yaml sets chat.allow_non_org_members. On fork PR #110 a Major
   finding thread ended in an org-members-only refusal and rendered as
   resolved without any re-review. Note that this key defaults to true in
   CodeRabbit's schema, so the refusal was NOT caused by it being unset --
   it is disabled above the repo, most likely org-level. The file states the
   repo's intent and takes effect if repo config wins; if the refusal
   recurs, the fix belongs in the org dashboard. Either way an outside
   contributor's fix commit still gets verified by hand before merging.

* ci: keep matrix.flavor in the build job name to suppress the auto-suffix

The first attempt used a bare `name: Build`, which made the check name worse
rather than stable. A matrix job whose name contains no matrix expression
does not keep its literal name -- GitHub appends the whole matrix cell to
disambiguate it, so the check reported as

  Build (nginx, 1.31.3, https://nginx.org/download/nginx-1.31.3.tar.gz, nginx-1.31.3)

which puts the version back in the name along with the url and dir, and
churns on every nginx release exactly as before.

Interpolating `matrix.flavor` alone suppresses the auto-suffix and is stable
across releases, since flavor is `nginx` for every cell. Caught on the first
CI run of this branch, where `Tests` reported under its new fixed name and
`Build` did not.

The `tests` job has no matrix, so its bare `name: Tests` is correct and is
already reporting as such.

* ci: test for a leak before ptrace-blocked in the smoke verdict

The two conditions are not mutually exclusive and the ordering was wrong.
log_path writes one file per process, and both greps scan /tmp/lsan-smoke.*,
so a ptrace warning from any one nginx worker short-circuited the verdict and
masked a real ERROR: LeakSanitizer report from a different worker. ptrace
works only intermittently on builder02, which makes that combination likely
rather than theoretical -- and it is the same class of defect this step was
added to fix.

Leak check runs first now. The indeterminate path also no longer falls
through to the clean "All smoke tests clean under ASAN+UBSAN" line, which
claimed more than it had checked.

Reproduced both directions locally against real LSan output: with a ptrace
warning in one file and a module leak in another, the old order passed and
the new order exits 1.

Reported by CodeRabbit on PR #113.
The coverage job failed on this PR's first run with a TimeoutExpired
from tools/test_zstd_long_ldm.py — AFTER every assertion in the phase
had already passed. The tool's teardown is terminate() -> wait(5) ->
kill() -> wait(5), and a gcov-instrumented nginx flushing .gcda
profile data at exit on a loaded shared runner can outlive that budget
(uninterruptible I/O even shrugs off the SIGKILL for a while). The
verdict was decided; the reap alone failed the tool.

Same class as the reload-leak fix in the previous commit: a tight
infrastructure assumption failing on the slow runner with the module
verdict nowhere in sight. Every tool shares the same copy-pasted
teardown block, so all seven get the same change: 30 seconds for the
SIGTERM grace and 30 for the post-SIGKILL reap. Teardown runs after
the verdict in every tool (the blocks are all in `finally` after the
success return), so the larger budget costs nothing on healthy runs —
a normal exit reaps in well under a second — and only spends time
where 5s used to fail spuriously.

Verified: py_compile across all seven tools, the test_encoding
harness self-checks, and live runs of test_encoding.py and
test_zstd_long_ldm.py (the tool that flaked) against a local
ASAN-instrumented build.

Co-authored-by: Mark Reidenbach <mark@digitalmanagementteam.com>
…115)

#112 raised the nginx teardown reap budget to 30s in seven tools but the
coverage job drives nine; test_window_cap.py and test_dcz.py kept 10s.
test_dcz.py additionally had no TimeoutExpired guard at all, so a slow
exit propagated out of the finally and leaked the nginx process, and
test_window_cap.py never reaped after kill(), leaving a zombie until
interpreter exit. Same 30s budget and kill fallback as the other seven.

Also silences RUF059 on an unpacked-but-unused variable in test_dcz.py
(pre-existing, gated by the pre-commit ruff hook on the touched file).
…_in (#116)

The body filter inferred "first body data" from ctx->buffer_in.src ==
NULL, while add_data reloaded buffer_in.src from every incoming buffer.
The sub filter emits a data-less sync carrier (pos == NULL) whenever an
in-memory input buffer is entirely absorbed into a cross-buffer match
candidate and produces no output. Loading that carrier re-armed the
first-call check, so the next invocation re-ran ZSTD_CCtx_reset() mid-
stream and silently discarded everything libzstd had buffered but not
yet flushed. The response still ended as ONE well-formed frame (HTTP
200 + valid zstd), just with the pre-reset content missing -- observed
in production as deterministically truncated HTML on sub_filter-
rewritten pages fed by a slow chunked (relayed) upstream, where a
35 KB page compressed to a frame holding only ~262 bytes after 416
mid-stream re-inits in a single request.

Two changes, both required:

- ngx_http_zstd_ctx_t grows an explicit cctx_ready latch; init_cctx now
  runs exactly once per request, keyed on lifecycle state instead of
  data state.

- add_data never loads a zero-size buffer into buffer_in: its signal
  flags (last/flush) are captured first, and the buffer is otherwise
  skipped, so a NULL pos can neither clobber buffer_in.src nor reach
  ZSTD_compressStream2 as the src pointer.

TEST 85 reproduces the exact shape: a chunked upstream streamed in
three delayed segments where the middle segment lies strictly inside
the sub_filter FROM pattern (fully absorbed into the match state, no
output -> sync carrier), asserting the decoded body round-trips.

Co-authored-by: Haroldas Velioniskis <hvelioniskis@users.noreply.github.com>
…iles (#118)

* fix: guard zero-delta input-buffer pointer update; unique test temp files

Follow-ups from the #116 review:

- ngx_http_zstd_filter_compress() unconditionally executed
  ctx->in_buf->pos += ctx->buffer_in.pos - pos_in. When the dequeued
  link is a data-less carrier (pos == NULL), the delta is provably
  zero, but NULL + 0 is undefined pointer arithmetic: gcc's UBSan
  accepts it, clang's aborts with "applying zero offset to null
  pointer". Guard the update on a non-zero delta.

- Tests 43, 44 and 92 decoded through a fixed /tmp/zstd_tNN.out path,
  which two concurrent checkouts on one host can clobber. Use
  File::Temp::tempfile (UNLINK => 1) instead. TEST 92 also gains the
  decoder exit-status assertion TEST 44 already had, so a truncated
  frame reports ERR-DECODE rather than a body mismatch.

* test: decode through a list-form pipe instead of a shell redirect

Addresses both CodeRabbit findings on #118.

The decode helpers built a shell command string with the temp path
interpolated into it, and left the file behind on the early-return
paths. Writing the compressed body to the temp file and reading the
decoder back through a list-form open() removes the shell from the
path entirely, so the name is never re-parsed regardless of TMPDIR,
and drops the redirect that made cleanup conditional. Every return
path now unlinks.
…#119)

A phase0-branch run just failed its Build job in 'Install
dependencies' with 'Could not get lock /var/lib/dpkg/lock-frontend.
It is held by process 900 (apt-get)' — a background provisioning
apt-get racing the step on the runner, nothing to do with the diff.
Same disease as the ptrace and teardown-reap classes: a tight
infrastructure assumption (the lock is always free) failing on shared
runners with the verdict nowhere in sight.

apt has the built-in cure: -o DPkg::Lock::Timeout=120 makes every
apt-get wait up to two minutes for the lock rather than failing
instantly. Applied to all 36 invocations across the six workflows
(shape-swept: zero unconverted apt-get calls remain), uniformly on
update and install both — the race hits whichever runs first.
* build: test Windows toolchains

* ci: fetch zlib from release archive

* ci: order Windows toolchain setup

* ci: run Windows builds in dependency order
The cvary-interop and CodeQL jobs sat in Install dependencies until the 15-minute job cap cancelled them, hitting the azure.archive.ubuntu.com outage shape: every index fetch cycles through Ign: retries against the dead mirror before the runner's mirror list falls back to archive.ubuntu.com. The fallback works, but is gated behind roughly two minutes of default Acquire timeout per attempt across ~30 index files.

Each job now writes /etc/apt/apt.conf.d/99-ci-net at the top of its apt step, and the apt-get lines drop their -o flags (DPkg::Lock::Timeout=120 folded in). The two ubuntu-latest jobs, the only ones resolving through the Azure mirror, get 10s idle timeouts and 1 retry; the self-hosted builder02 jobs, which share a box across concurrent jobs and never touch that mirror, get 20s/3. The conf-file shape avoids an SC2086 exemption and covers apt-get invocations added later.

timeout-minutes: 5 on the two ubuntu-latest apt steps bounds step wall-clock, which Acquire::*::Timeout does not: a slow-rather-than-dead mirror could otherwise still crawl into the job cap and reproduce the same unreadable cancellation.

The timeouts are per-connection inactivity limits, not total-transfer limits, so slow large downloads are unaffected.
#122)

* fix(static): opt in to byte ranges like gzip_static (reverses f7e2ef3)

zstd_static has never served range requests, and f7e2ef3 retroactively
justified that with a clear_accept_ranges — but both halves of that
commit don't hold up:

- The bug it fixed didn't exist. The range filter bails unless a
  handler sets r->allow_ranges, which this handler never did, so
  nginx neither advertised nor honoured ranges either way; the clear
  was a no-op (the t/01-static.t Gap 2 coverage note recorded the
  empirical verification at the time).

- Its own RFC citation argues the other way. RFC 9110 §14.2 has
  ranges address the SELECTED REPRESENTATION — under Content-Encoding:
  zstd, the .zst bytes on disk. That is not an "undecipherable
  fragment" hazard: a client fetches bytes 0-999, resumes at 1000-,
  concatenates and decompresses, coherently because the validator is
  strong and the bytes are stable. It is exactly the semantics
  gzip_static has always provided by setting r->allow_ranges = 1; no
  client that cannot handle CE-plus-ranges sends Range at all. The
  FILTER module's clear remains correct for the opposite reason — a
  stream generated on the fly has nothing stable to seek into.

So: set r->allow_ranges = 1, matching gzip_static. TEST 36 pins
Accept-Ranges: bytes on a 200 and TEST 37 pins a 206 + Content-Range
slice byte-compared against a crafted 12-byte frame; both verified to
fail against the unfixed module (206 comes back 200, no headers). The
Gap 2 note is rewritten to record the reversal. Full local battery:
1654 assertions across the four Perl suites green.

The unified compression module on #117 received the same fix in
7a34356 (where its port had inherited this clear); ngx_brotli's
static module has the same gap, tracked separately.

* fix(static): take gzip_static parity for b->sync too (review catch)

Review caught the PR note claiming gzip_static:276 was unreachable
here: that claim leaned on the sub-4-byte frame-header probe, but the
probe sits inside both #if !(NGX_WIN32) and #if (NGX_HAVE_PREAD).
On a build where the probe compiles out, a zero-length .zst reaches
the serve path, and in a subrequest the buf carries neither in_file
nor last_buf — the zero-size buf case b->sync exists to mark.

Take the one-line parity instead of a platform-qualified footnote:
the line costs nothing on builds where the probe already guards the
path, and the parity argument no longer requires re-deriving the
preprocessor nesting to verify.

Full local battery: 1,654 assertions across the four Perl suites
green.

---------

Co-authored-by: Thijs Eilander <eilander@myguard.nl>
* test: force the two timing-dependent filter paths every run

The coverage floor failed on PR #124 -- a two-line Windows env change
-- at 81.9% against the 82% floor. Diffing that run's lcov tracefile
against the last green run isolated exactly 8 lost lines, all in the
filter: the nomem entry block (busy-buffer recovery, 951-961) and the
content-less flush completion (1265-1267). Neither is version
behavior: a run on the same runner with the same nginx 35 minutes
earlier hit both (8x and 7x), and local builds of 1.31.3 and 1.31.4
both hit them freely. They are races -- backpressure and data-less
flush arrival depend on scheduler timing, and one loaded run lost
every race at once. The floor's headroom is 0.6pp; the racy lines are
worth 0.67pp.

tools/test_slow_drain.py (ported from the unified-module branch on
PR #117, where it pins the same paths in the compression filter)
forces both deterministically:

- nomem: zstd_buffers 2 8k, a ~1 MB incompressible body,
  sndbuf=16384, a small client SO_RCVBUF and deliberately slow reads
  -- the write filter cannot drain within an invocation, so the cap
  trips ("no free buffer, nomem set") and the writer-driven re-entry
  runs the recovery block.

- content-less flush: ngx_http_upstream_send_response sends a
  data-less NGX_HTTP_FLUSH special at the start of every
  proxy_buffering-off response; a mock backend that sends headers
  alone and body only after a pause guarantees the special reaches
  an empty encoder ("content-less flush completed", exactly once per
  response).

Verified on a --coverage build of 1.31.4: this tool alone hits the
nomem block 43x and the flush branch 8x -- all 8 flake lines. Byte-
exact decode oracles on both scenarios; witness lines asserted, so
geometry drift fails loudly instead of silently un-covering the
paths. Wired into the tests, tests-asan, and coverage jobs.

* test: restore the fixture-permission lines the port dropped (ASAN catch)

The ASAN job failed the new tool with "Content-Encoding None": mkdtemp
creates the fixture tree 0700, and when nginx runs with privilege-
dropped workers they cannot traverse into it -- /drain/big serves
uncompressed instead of erroring, so the failure surfaces as a missing
CE header rather than a 403. The sibling tools all carry
os.umask(0o022) before the TemporaryDirectory plus a chmod 0755 on it
for exactly this reason (as did the unified-branch original of this
tool); the port dropped both lines and the one job that runs workers
under another account caught it. Both restored, with the comment.

Also from review:

- The content-less flush witness is now a per-response lower bound
  (>= PROXY_REPEAT) instead of merely nonzero -- the upstream FLUSH
  special is unconditional per response, so every successful proxied
  request owes one completion. A lower bound rather than an exact pin
  because nginx may legally emit further data-less specials mid-relay.
  The nomem count stays existence-only (drain scheduling decides it).

- Docstrings on every function for the docstring-coverage gate.

Verified: clean runs launched as the build user and as root (the
privilege-drop shape), flush witness exactly 8/8 in both.

* test: make the slow-drain tool tell us why it failed on builder02

The ASAN job failed again after the permissions fix, with a shape the
tool cannot currently explain: the whole run took 0.44s (the slow
drain alone reads for over a second), the drain "response" was tiny
with no Content-Encoding, and the very next connection was refused --
whatever answered the first request was gone by the second. That
pattern fits a bind conflict with a stale listener or a sanitizer
abort, but the tool swallowed every piece of evidence that would
distinguish them: no status line, no body size, nginx stdout piped to
a buffer nothing read (a deadlock risk in its own right on a chatty
ASan abort), and no liveness check on our own process.

Now: nginx stdout/stderr goes to a file; the status line is asserted
before Content-Encoding and included in errors with the body size; an
alive_or_die() check after startup and after each scenario fails
loudly with nginx's captured output if our process exited (a bind
conflict otherwise masquerades as responses from a stale listener on
the same port); and a failure ships the nginx-output and
non-debug error.log tails with the verdict, since builder02 runs are
not reproducible interactively.

No behavioral change to the passing path: witnesses and oracles
unchanged, still green locally launched as build user and as root.

* test: sanitized nginx cannot log at debug -- run the ASAN pass at warn

The diagnostics commit did its job in one round: the ASAN failure is
nginx core, not the module and not the tool. Reproduced locally with
the job's exact build flags: UBSAN (-fno-sanitize-recover=undefined)
fatally traps ngx_sprintf_str (ngx_string.c:586) whenever a debug-
level "%V" formats an empty string whose data pointer is NULL --
r->args and r->exten on any query-less URI. The request-line lines
("http args"), ngx_http_output_filter ("%V?%V"), and friends all
qualify, so a sanitized single-process nginx silently exits 1 on the
first debug-logged request. rc=1 with an empty stderr matched exactly.
Latent in nginx core for any UBSAN build; first hit here because
nothing else in CI ever ran the sanitized binary at debug level (the
sibling tools log at warn, and the perl ASAN passes only grep reports
mentioning ngx_http_zstd -- this trace is all core symbols).

Location-scoping the debug log was tried and does not survive: the
poisoned lines run inside location context too. So the tool grows
--log-level {debug,warn}: at warn the witness assertions are skipped
(with an honest note) while the forced geometry and byte-exact
roundtrip oracles still gate -- the paths run regardless of logging.
The ASAN job passes --log-level warn; the tests and coverage jobs,
whose binaries are not sanitized, keep the full witness proof.

Verified locally: ASAN build at warn round-trips clean with zero
sanitizer reports; plain builds at debug keep both witnesses (x149+
nomem, exactly 8 content-less flushes), as build user and as root.

---------

Co-authored-by: Thijs Eilander <eilander@myguard.nl>
* build: bump the Windows nginx pin to 1.31.4

nginx 1.31.4 released today; the Windows workflow is the one place
mainline is pinned rather than resolved at run time (deterministic
toolchain builds want a fixed input). New sha256 recorded the usual
way: tarball downloaded from nginx.org, PGP signature verified
against tools/keys/ (signed by s.kandaurov), then hashed.

PCRE2/zlib pins unchanged. bump.yml only tracks the nginx-stable and
Angie pins in ci-deep; extending it to watch this one is a possible
follow-up.

* build: move the build-windows.sh nginx pin with the workflow (review)

Review caught that tools/build-windows.sh:59-60 still pinned 1.31.3
with the old sha256 -- it is the local counterpart of
windows-build.yml, sits in that workflow's own paths: trigger, and
the two are meant to move together. Same verified digest as the
workflow bump (PGP against tools/keys, then sha256sum).

---------

Co-authored-by: Thijs Eilander <eilander@myguard.nl>
…ed (#126)

The review approved with two explicit leave-or-fix nits; fixing them
while they're fresh:

- The 5-line builder02 rationale was copy-pasted 16 times. Now a
  2-line comment per site that states the local facts (shared box,
  no Azure mirror) and points at PR #121 for the full reasoning --
  the next tuning pass edits prose in one place, the PR thread.

- The "covers apt-get calls from nested scripts" clause described a
  case that does not exist: no script in this repo calls apt-get.
  Dropped from both Azure-profile comments; the conf-file shape
  stands on the duplication argument alone, which was always the
  point.

No functional change; actionlint clean.

Co-authored-by: Thijs Eilander <eilander@myguard.nl>
Self-contained ast-grep config (11 own rules + 24 vendored C/C++ security
rules) so the hook gates identically in a standalone clone: ast-grep walks
UP from cwd looking for sgconfig.yml, and outside /opt/myguard that walk
finds nothing, leaving the scan green against an empty ruleset.

Refresh with /opt/myguard/tools/vendor-astgrep-rules.py.

Reports 11 nginx-unchecked-palloc and 4 nginx-raw-malloc against the current
tree; both are severity:info, so the hook does not block on them.
…teps 6-16) (#128)

* ci: install the linter toolchain and the tracked pre-commit hook

Steps 6-7 of the skeleton adoption (labs/nginx-skeleton-module@93b8ecc).

Ports ci/linter/ -- the installer, run-all.sh, the twelve lint-*.sh checkers,
workflow_policy.py with its fixtures and selftest -- plus .yamllint,
.perlcriticrc, ci/linter/codespell-ignore.txt and the tracked
.githooks/pre-commit. The checkers still select on the reference's paths;
retargeting them onto this module's tree is step 27, once src/ exists.

Three target-specific divergences from the reference:

- install-linters.sh gained gitleaks and ast-grep. Both are gates this module
  already has (.pre-commit-config.yaml, and ci/ast-grep/ from #127) and the
  skeleton has never heard of, so the ported installer would neither install
  nor report them while --check called the toolchain complete. Installed as
  pinned upstream releases, version and sha256 on adjacent lines and the digest
  compared, following install_actionlint. Only ast-grep's long name goes to
  /usr/local/bin: the release zip also ships an 'sg' alias and Debian already
  owns /usr/bin/sg.

- report() now sets rc=1 on a miss. It printed MISSING inside a command
  substitution, so --check exited 0 with every apt/pipx tool absent. lint.yml
  gates on the exit code, so a half-armed toolchain read as a pass. Proven both
  directions in this tree: full PATH exits 0, PATH=/usr/bin:/bin exits 1.

- lint-prompt-steps.* and lint-sync-stamp.sh are dropped with their selftest
  cases. They check ci/PROMPT.md step citations and the skeleton-shared
  .github/ content stamps; this module has neither. run-all.sh picks checkers
  up by glob, so no dispatch edit was needed.

selftest.sh passes except 'checkers absent from lint.yml LINT_ONLY', which is
honest: lint.yml arrives in step 17. Left red rather than stubbed.

Hook verified non-inert by the probe in its own header -- a staged file with
trailing whitespace and no closing newline is blocked by trim-trailing-whitespace
and end-of-file-fixer, and the commit is refused.

* ci: move t/, tools/ and fuzz/ under ci/ and repair every relative climb

Step 11 of the skeleton adoption. One commit on purpose: a git mv whose
relative paths are not fixed does not build, so the move and its repair
cannot be separated.

  t/     -> ci/t/
  tools/ -> ci/tools/
  fuzz/  -> ci/fuzz/

All 86 files moved with git mv, so blame survives. Module C (filter/,
static/, ngx_http_zstd_*.h) stays at the root until step 12.

Every path that climbed out of the moved directories, in the order the
prompt lists them:

- ci/tools/ci-build.sh, ci/tools/test_docs_ci_drift.sh: MODULE_DIR was one
  dirname off SCRIPT_DIR, correct while these lived in tools/ at the root.
  One climb now lands in ci/, where the module config, README.md and
  .github/ do not exist. Both take two levels. test_docs_ci_drift.sh was
  reporting every workflow undocumented before the fix.
- ci/tools/soak.sh, bump-versions.sh, test_sha256_unit.sh: same one-level
  climb, same +1.
- ci/tools/benchmark.py: parent.parent -> parent.parent.parent, and its
  fixture path picked up the ci/ prefix.
- ci/tools/test_sha256_unit.c: ../ngx_http_zstd_sha256.h -> ../../.
- ci/fuzz/extract_parser.sh: the shipped header it slices is two levels up.
- ci/tools/test_fuzz_seconds_validation.sh: the workflow it reads is two up.
- .gitignore, .pre-commit-config.yaml selectors and exclude, and 114 path
  references across the 8 workflows, in all four spellings the tree uses --
  bare, "$GITHUB_WORKSPACE"/, $GITHUB_WORKSPACE/ and ${{ github.workspace }}/.
  The last three were missed by the first sweep and are the reason the
  acceptance check enumerates every workflow path against the real tree.

01-static.t's fixture root is now "root ../suite" rather than a counted
climb. It resolves against the servroot's parent, so it holds for any
servroot that is a sibling of ci/t/suite instead of encoding how deep the
suite happens to sit. Every static servroot in CI is already
$GITHUB_WORKSPACE/ci/t/servroot-static*, which satisfies that.

Verified, not assumed:
- prove ci/t/ -- 1654 tests pass (1195 filter/conf-warn/dcz + 459 static),
  matching the step 3 count of 4 .t files. The first run failed 9 subtests
  against a .so from 2026-07-15; rebuilding fixed it, which also proves
  ci-build.sh works from its new location.
- ci/fuzz/build.sh builds, extracting the parser from the real header.
- ci/tools/test_sha256_unit.sh, test_docs_ci_drift.sh,
  test_fuzz_seconds_validation.sh and both python unittest drivers pass.
- actionlint clean over all 8 workflows.
- every ci/{t,tools,fuzz}/ path named in a workflow resolves in the tree,
  except seven runtime-generated *.log files.

run-all.sh reports pre-existing findings unrelated to the move: 20
ci-runners (the inline [self-hosted, builder02, lxc] pool -- step 13), 2
ci-ports, and perl/python/spelling/yaml noise. The checkers still select on
the reference's paths; retargeting is step 27. Left red rather than
weakened.

* ci: move the module C under src/ and repoint every consumer

Step 12 of the skeleton adoption, and the one this module most needed: with
no src/, lint-c.sh, lint-nginx.sh, the gcovr filter and the CodeQL TU filter
all select nothing and PASS. That is the prompt's named empty-selection class,
not a cosmetic layout preference.

  filter/ngx_http_zstd_filter_module.c -> src/
  static/ngx_http_zstd_static_module.c -> src/
  ngx_http_zstd_common.h               -> src/
  ngx_http_zstd_sha256.h               -> src/

filter/config and static/config stay where they are: each module keeps its
own declaration fragment. The root config now points ngx_addon_dir at src/
before sourcing each, so both fragments resolve their sources against it and
their ../ climbs to the shared headers drop out entirely.

Same commit as the move, because nginx's config is the one file whose
breakage stops the module building at all.

Consumers repointed:
- src/*.c: the shared headers are siblings now, so "../ngx_http_zstd_*.h"
  becomes a plain "ngx_http_zstd_*.h".
- ci/fuzz/extract_parser.sh: slices the parser out of ../../src/.
- ci/tools/test_sha256_unit.c: includes ../../src/ngx_http_zstd_sha256.h.
- 12 clang-tidy/flawfinder/cppcheck invocations across build-test.yml,
  security-scanners.yml and ci-deep.yml that named the old paths by hand.
- .pre-commit-config.yaml's four C selectors collapse to
  '^(src/.*\.[ch]|ci/tools/.*\.[ch])$'.
- ci/fuzz/README.md's link and three stale path comments.

Verified, not assumed:
- NO_CACHE=1 ci/tools/ci-build.sh nginx 1.30.3 -- clean from-scratch rebuild
  succeeds, both .so produced.
- git ls-files 'src/*.[ch]' lists all four module sources and nothing else.
- prove ci/t/ -- 1654 tests pass (1195 + 459 static), unchanged from step 11.
- ci/fuzz/build.sh extracts from src/ and links.
- ci/tools/test_sha256_unit.sh passes, compiling the real header.
- the pre-commit C selector matches 10 tracked files including all four
  module sources -- a non-zero selection, checked rather than assumed.
- actionlint and shellcheck clean over everything touched.

DEFERRED, and it gates PR2: immediately after step 27 retargets the linter
checkers, a malloc/strcpy probe planted beside the module's real C must make
LINT_ONLY="c nginx" ci/linter/run-all.sh exit 1. A green there means the
selection is still empty and this move bought nothing.

* ci: move the pool label out of the tree into the POOL repo variable

Step 13 of the skeleton adoption -- barrier A. POOL_OWNED=yes (origin is
myguard-labs), so the pool stays available, but as an opt-in variable rather
than 18 copies of a machine name in tracked YAML.

Every Linux runs-on becomes the approved portable selector:

    runs-on: ${{ fromJSON(vars.POOL || '["ubuntu-latest"]') }}

20 selectors across 7 workflows: the 18 that read [self-hosted, builder02, lxc]
and the 2 bare ubuntu-latest. windows-build.yml's two windows-latest jobs stay
as they are -- a different platform, not a pool choice.

POOL is now set on the repo to ["self-hosted","builder02","lxc"], the same
labels the tree used to hardcode, so where jobs actually run does not change.
What changes is that an adopter or a fork copying these workflows gets
ubuntu-latest from the unset-variable arm instead of dispatching at hardware
they do not own.

SELF_HOSTED_ALLOWED was already True in workflow_policy.py, which matches
POOL_OWNED=yes. Left as is.

One checker fix, and it is rule 2 -- a gate this module has that the reference
does not. HOSTED matched only ubuntu-*, because the skeleton has no Windows
workflow; here it made lint-ci-runners reject windows-build.yml's two
legitimate GitHub-hosted jobs, which is what the step's positive-control probe
caught. Widened to the ubuntu/windows/macos image families, deliberately as an
explicit list rather than a permissive '.*-latest': an unrecognised label must
stay a finding, or a pool named 'builder-latest' would sail through. Checked
both directions -- ubuntu-latest, ubuntu-24.04, windows-latest, windows-2022,
macos-14 and ubuntu-22.04-arm are hosted; self-hosted, builder02, lxc,
builder-latest, windows-builder and ubuntu-latest-selfhosted are still findings.

Security, found by this step and fixed outside the tree:

- the fork-PR approval policy was 'first_time_contributors', so a returning
  external contributor's PR would have run on the pool unreviewed. Now
  'all_external_contributors', which the prompt requires before POOL may be
  set at all. Verified via the actions/permissions API, not assumed.
- pull_request_target appears nowhere, and check_runners enforces that. It is
  the one remaining route by which fork code reaches the pool, since the
  approval policy does not gate it.

Verification, all three probes, pasted in the PR body:
  probe 1  grep for an inline pool or fork ternary  -> empty
  probe 2  inline pool selector                     -> exit 1
  probe 3  approved selector (positive control)     -> exit 0
Probe 3 is what makes probe 2 mean anything: without it a red is equally
consistent with a checker that flags everything.

actionlint clean over all 8 workflows; ruff clean on workflow_policy.py;
selftest 30 ok, with the known lint.yml red that step 17 closes.

* ci: add ci.yml as the single PR entry point, wire every member for call

Steps 14 and 15 of the skeleton adoption.

Step 14 -- long-runner demotion -- needed no tracked change, and the
classification is the deliverable rather than a diff. The bound is the job's
actual wall-clock budget, not its filename and not timeout-minutes, which is a
ceiling saying when CI gives up:

  valgrind.yml   BOUNDED    ci/tools/soak.sh <nginx> 60 4 -> 60s at conc 4
                            (timeout-minutes: 45 is the ceiling)
  fuzzing.yml    BOUNDED    -max_total_time=120 -> 2 minutes
                            (timeout-minutes: 30 is the ceiling)
  ci-deep.yml    UNBOUNDED  -max_total_time=$FUZZ_SECS, timeout-minutes: 600

Both bounded ones are legitimate PR-lane members and stay. ci-deep.yml already
carried schedule + workflow_dispatch and no pull_request/push, so it was
already where this step would have put it. Nothing was deleted, moved or
continue-on-error'd; every workflow keeps its badge and README row.

Step 15 -- the orchestrator. ci.yml calls all six PR members. Each member keeps
its pull_request: for now, which is the deliberate intermediate state: the
double-run on this PR is what proves the call graph is actually wired, and step
16 removes the standalone triggers only once that evidence exists. A member
that runs once here was never called.

Two members could not have double-run, and it would not have been a ci.yml bug.
A called workflow inherits the caller's github.workflow, so a concurrency group
that interpolates it produces two distinct strings and both runs survive; a
group that omits it hashes both to one string and the called run cancels the
standalone one. windows-build.yml and codeql.yml both read
"<name>-${{ github.ref }}". Both now carry ${{ github.workflow }} like the
other six, so all six members can genuinely double-run.

ci.yml hands codeql.yml security-events: write explicitly. A called workflow
cannot widen its caller's grant, so without it the SARIF upload 403s in a way
that reads like a CodeQL fault rather than a permissions one.

ci-deep.yml and bump.yml are deliberately not members: the first is the
unbounded monthly campaign whose verdict arrives after the merge decision it
would inform, the second opens PRs rather than gating them. Both reasons are
written into ci.yml's header so the next person does not "fix" the omission.

Also repaired here, because it would have silently skipped the whole Windows
gate: windows-build.yml's paths: filter still named filter/**, static/** and
*.h, all of which moved to src/ in step 12. A path filter that matches nothing
does not fail -- the job just never runs, and the PR goes green without it.

actionlint and yamllint clean over all nine workflows.

* ci: fix five bare cd paths the ci/ move left behind

Follow-up to step 11. The path sweep matched four spellings of a workspace
reference but not a bare 'cd <dir>', so five call sites still entered the
pre-move directories:

  fuzzing.yml:57   cd fuzz          -> cd ci/fuzz
  ci-deep.yml:192  cd fuzz          -> cd ci/fuzz
  ci-deep.yml:208  cd fuzz          -> cd ci/fuzz
  build-test.yml:134,140  cd "$GITHUB_WORKSPACE/tools"  -> .../ci/tools

Caught by the Fuzzing job on PR #128, which failed at 'cd: fuzz: No such file
or directory' -- loudly, because the step runs under set -e. The build-test.yml
pair had not run yet.

The lesson is that a fifth regex is not the fix. Replaced the grep-for-a-
spelling check with one that extracts every path token naming t/, tools/ or
fuzz/ in any form, strips the workspace prefixes and resolves it against the
real tree. That reports outcomes rather than spellings, and it now comes back
with only two entries, both runtime-created: ci/t/servroot-static-<id> and
ci/fuzz/corpus-min.

actionlint clean.

* docs: document ci.yml and windows-build.yml in the README CI table

The docs-drift gate (ci/tools/test_docs_ci_drift.sh, audit F7) failed the
Validation job on PR #128: every workflow file must be referenced in README.md,
and ci.yml was new. Working as designed -- a workflow nobody documented is a
workflow nobody knows runs.

Fixing it surfaced a second, older gap: windows-build.yml has been a real PR
gate for some time with a badge but NO row in the ## CI table, so the table
described six of seven gates. Added, with what it actually covers (MSVC x64
static + MinGW-w64 x64 dynamic against a pinned nginx, then nginx -t) and why
it exists -- the NGX_WIN32 paths no Linux job compiles.

Also here:

- CI and Windows build get badge-row entries, so the badges match the table.
- The intro prose said 'six workflows, five gate every push & PR'. It now
  describes the orchestrator and states why CI Deep and Bump are deliberately
  NOT called from it, which is the question the next reader asks.
- The Cadence column said 'every push & PR' for the six members. That is
  already only half true -- they are called by ci.yml as well -- and step 16
  makes it outright wrong when it removes their standalone triggers. Changed to
  'every PR (via CI)', which describes what gates them rather than which
  trigger happens to fire this week.
- Two stale paths from the ci/ move: fuzz/README.md -> ci/fuzz/README.md, and
  CodeQL's 'the filter/static module sources' -> 'the module sources in src/'.

test_docs_ci_drift.sh now exits 0.

* ci: make ci.yml the sole PR entry point

Step 16, and the point of no return: this is the only change in the job that
could leave the repo with no PR gate at all. Taken on the barrier B evidence
from head 7903120, where all seven workflows went green and every member was
confirmed reachable through the orchestrator.

Removes pull_request: from all six members, and push: from windows-build.yml.
Both triggers go, not just the first. A member keeping push: runs again on the
merge commit against a tree identical to the PR head that already passed; the
two runs get different concurrency keys so cancel-in-progress cannot collapse
them, and both are green. The only symptoms are the bill and a README that no
longer describes what runs when.

Schedules are untouched: build-test.yml keeps its weekly Monday cron and
codeql.yml its monthly one, because those must fire whether or not anyone
opens a PR. Every member keeps workflow_dispatch: and can still be run alone
from the Actions tab. A second entry point that is not pull_request: is fine.

Acceptance, all four checks:

  grep -lE '^\s*pull_request:' .github/workflows/*.yml   -> ci.yml alone
  grep -lE '^\s*push:'         .github/workflows/*.yml   -> empty

  long-runner-named workflows outside ci/ci-deep that still carry
  pull_request:                                          -> empty

  long-runner members of the PR lane, with their real bounds:
    fuzzing.yml   -max_total_time=120
    valgrind.yml  ci/tools/soak.sh <nginx> 60 4
  Not expected empty -- both are bounded and belong on the PR lane. The
  unbounded campaign is ci-deep.yml, which ci.yml does not call.

actionlint clean over all nine workflows.

The next PR run is the one that proves this: every member should now appear
exactly once, as a job inside the CI run and nowhere else.
…(steps 17-29) (#130)

* ci: port lint.yml and asan.yml workflow-set members (step 17)

Adds the two workflow-set members this module was missing relative to the
skeleton reference, adapted to this module's build/test shape rather than
copied: lint.yml runs ci/linter/ (the same local pre-commit gate) on hosted
runners; asan.yml drives a 60s ASan+UBSan soak against the module's static
--add-module build, reusing this repo's existing dynamic-nginx-resolve +
PGP-verify pattern (not the reference's versions.env/sha256 pattern -- see
ci/skeleton-findings.md for that decision, escalated rather than ported
wholesale). Both wired into ci.yml as workflow_call members alongside the
existing six; ci.yml's 'security-scanners' job name corrected to 'Security
Scanners' to match the badge/table spelling convention.

windows-build.yml (the target's workflow with no reference equivalent) is
triaged keep-as-is in ci/skeleton-findings.md (step 18): it gates the
NGX_WIN32 build paths nothing else here covers.

actionlint clean; ci/linter/lint-docs-drift.sh, lint-ci-runners.sh,
lint-ci-cadence.sh, lint-ci-secrets.sh clean on the new files.

* ci: give every runtime job a declared, verified port band (step 19)

ci/linter/lint-ci-ports.sh was already live from step 16 and already failing:
build-test.yml's coverage job and ci-deep.yml's build-flavors job each bind
Test::Nginx (prove) without declaring TEST_BASE_PORT, so both would silently
take the 1984 default and collide with any other runtime job on the shared
self-hosted runner.

Ports ci/tools/max-port.sh from the skeleton verbatim (generic, no
module-specific paths) and wires it into both jobs before their first
prove call:

  build-test.yml coverage: TEST_BASE_PORT=19200, single band, one prove
    call per step x4, all sharing the job-level TEST_NGINX_PORT.
  ci-deep.yml build-flavors: TEST_BASE_PORT=19300 (job-declared band for
    the policy checker), with each of the three concurrent matrix legs
    (mainline/stable/angie) additionally verifying its OWN port
    (19300/19310/19320, spaced by 10 the way build-flavors' existing
    comment already documented) since the legs run concurrently, not
    max-parallel:1. Test::Nginx prefers TEST_NGINX_SERVER_PORT over
    TEST_NGINX_PORT when both are set, so the per-leg bind is unchanged;
    TEST_NGINX_PORT is set to the job's declared band purely so the
    linter's wiring check (which only recognizes TEST_NGINX_PORT) can see
    the job is wired at all.

ci/linter/lint-ci-ports.sh: '2 runtime job(s), all with distinct port
bands'. actionlint clean.

* docs: badges and CI table for Lint and A/UBSan (step 20)

Inserts the two new workflows into the badge row and the Testing & CI table
in lockstep, in the required order (Lint, Build&Test, Security Scanners,
Fuzzing, Valgrind, CodeQL, A/UBSan, CI Deep), with Windows build -- the
extra workflow kept at step 18 -- moved to the end of both lists after CI
Deep. Labels matched character-for-character: 'Build&Test' (no spaces),
'Security Scanners' (capital S). Every badge URL already pointed at
myguard-labs/nginx-zstd-module.

ci/linter/lint-docs-drift.sh: '11 workflow(s), all documented in
README.md'. Badge row and table order verified identical by direct diff.

* ci: unit tests over the real Accept-Encoding decision TU (step 21)

ci/tests/unit/run.sh + test_accept_encoding.c link the shipped
src/ngx_http_zstd_common.h via ci/fuzz/extract_parser.sh (never a
copy) through the same ngx_shim.h the fuzz target already uses -- no
nginx build tree needed since the parser is pure ASCII walking.

24 checks over q-value parsing, wildcard-vs-explicit precedence,
malformed-weight handling and quoted-parameter comma masking. Five
mutations applied to the real source and reverted, each caught by a
distinct, correctly attributed check; one of them (disabling
skip_quoted) hangs the suite rather than failing it, verified under
a bounded timeout. Wired into build-test.yml's Validation job.

Evidence and the exact commands are in ci/adoption-findings.md.

* ci: baseline loaded-and-blocking control for the live-server layer (step 22)

Every existing driver script (test_reload_under_load.py,
test_concurrent_cctx_isolation.py, ...) asserts response correctness
under a stressor, but none of them individually prove compression
happened at all -- an unmodified passthrough can satisfy the same
byte-exact assertion by accident.

Add ci/tools/test_baseline_loaded.py: a live request with `zstd on;`
(must compress) and one with no zstd directive present (must not --
the compiled-in default). Wired into build-test.yml's ASAN job,
whose static --add-module binary is the shape this needs.

Verified against a hand-built static binary: the module genuinely
unloaded fails to even bind (unknown directive), and flipping the
`enable` merge default to 1 fails the new check. First cut of the
mutation test silently passed against the mutated binary because its
off-port collided with the backend port; fixed with a dedicated
--off-port argument, documented in adoption-findings.md.

Kept the module's own multi-script driver convention rather than
collapsing it into the skeleton's single test_runtime.py -- nothing
here needed to move between layers.

* ci: generate the fuzz dictionary from real call sites, wire crash replay (steps 23-24)

fuzz.dict was missing "dcz" -- the module has no single coding-name
table, so add ci/fuzz/gen_dict.sh to extract the two literal
ngx_http_zstd_coding_weight(ae, "TOKEN", ...) call sites from src/*.c
and src/*.h instead of hand-listing them, with a --check mode wired
into ci/linter/lint-c.sh so the next added coding name cannot drift
silently again. (First cut of the script only scanned src/*.c and
missed the "zstd" call site in the common.h wrapper; --check caught
its own bug immediately.)

Add ci/fuzz/regressions/ and a replay-first step in fuzzing.yml,
before the time-boxed fresh run. No fuzz-discovered crash existed to
seed it with (a bounded local run found none against the current
corpus), so seeded crash-wildcard-precedence-flip from step 21's
wildcard/explicit-precedence mutation, which trips the fuzz target's
own semantic-differential oracle on the mutated parser and passes
clean in 0ms against the real one -- verified both ways, then
reverted the mutation.

Corrected an asan.yml comment overstating soak.sh's actual assertion
(no block/pass counter exists; it gates on ok>0 && bad==0 per worker,
where a zstd-magic response must also decode cleanly).

Evidence and exact commands in ci/adoption-findings.md.

* memory: correct and strengthen the step 25 valgrind.suppress finding

Earlier note in this file wrongly concluded no suppression file
existed (searched for valgrind.supp, the skeleton's filename;
ci/tools/soak.sh actually references valgrind.suppress at the repo
root, a different name). Read it: all 28 blocks are still named
<insert_a_suppression_name_here> and at least two reference symbols
absent from this build entirely (ngx_http_lua_ndk_set_var_get,
drizzle_state_connect) -- this is the generic nginx valgrind
suppression file that circulates online, not one derived from this
module's own soak. Confirmed as a real, severe step 25 finding
instead of a false "missing" one; deliberately not patched here since
fixing it correctly needs a real build+soak run, a long-runner this
session cannot execute. codeql.yml and ci-deep.yml, checked in the
same pass, are already correctly target-scoped -- no finding there.

Also fixed markdownlint findings in this file (unclosed code fences,
a heading split across two `##` lines) introduced while writing the
above.

* ci(step 26): coverage report over src/, never a gate

Adds a coverage MODE to ci-build.sh (separate .build tree, --coverage
compiled in through the configure argument, not env CC=) and
ci/tools/coverage.sh, which runs ci/t/ against the instrumented binary
and reports with gcovr FILTERED to src/ only.

gcovr flag note: --object-directory, not --gcov-object-directory
(fails argparse below gcovr 7.0). The addon module's own .gcda land
at objs/addon/src/ under an --add-dynamic-module build, not flat in
objs/; passed explicitly as search_paths plus
--gcov-ignore-errors=no_working_dir_found so gcov's cross-object-file
resolution of unrelated upstream nginx core .gcda (this is a from-
scratch nginx build) does not hard-fail the report.

No COVERAGE_FAIL_UNDER by default: coverage is a report, not a gate --
the cheapest way to move the number is a test that touches a line and
asserts nothing. Mutation-proven: full ci/t/ suite reports 60.0% line
coverage; dropping 01-static.t (deleted test, not source) drops it to
50.6%; restoring the suite returns it to 60.0%.

Wired into ci-deep.yml (monthly cadence, matches the from-scratch
build cost) as a report-only job with its own TEST_BASE_PORT=19330
band, no fail condition tied to the number, uploading the gcovr
HTML/XML as a build artifact.

Also fixes ci-deep.yml's pre-existing zizmor gate (blocked staging
this file at all): every checkout in the file now sets
persist-credentials: false, and every matrix/event-context value
spliced straight into a run: shell block (matrix.flavor/version/port,
github.event_name, and the three Discord-notify blocks' repository/
server_url/run_id) now routes through env: instead -- the pattern
zizmor's template-injection audit checks for regardless of whether
the source context is attacker-reachable. No behavior change; these
are string-substitution-site changes only.

* ci(step 27): retarget lint-c.sh <-> security-scanners.yml thresholds

security-scanners.yml drifted from lint-c.sh's thresholds and never
gated on a real finding:
  - flawfinder ran --minlevel=1 with no --error-level, so the step
    could never fail regardless of what it found (minlevel alone only
    controls what gets printed).
  - semgrep targeted $GITHUB_WORKSPACE/filter/ and .../static/, a
    stale legacy layout -- the module's real source is src/.
  - semgrep had no severity gate and ended in `|| true`, so it also
    never failed on a real finding.
  - semgrep lacked --jobs=1 --metrics=off (step 28: --jobs=1 is a
    correctness flag against this host's shared RLIMIT_MEMLOCK, not
    speed; --metrics=off drops the semgrep.dev telemetry POST).

Both now match lint-c.sh exactly:
  flawfinder:  --minlevel=4 --error-level=4         (both)
  semgrep:     --config p/c --config p/security-audit,
               --severity=WARNING --severity=ERROR --error,
               --jobs=1 --metrics=off,
               scanned path src/                     (both)

Also sets persist-credentials: false on this workflow's checkout --
zizmor's pedantic pass (LINT_ONLY=yaml, gates the commit hook) flagged
it while retargeting the file. No behavior change from this one.

Verified: LINT_ONLY="c nginx" ci/linter/run-all.sh still exits 1 on a
planted malloc/strcpy probe (step 12's deferred probe, already run by
the supervisor and confirmed passing). Normalized LINT_ONLY <->
checker-script sets match exactly (checker set minus 'c', which is
deliberately excluded since security-scanners.yml already covers it).
run-all.sh observed at all three exit codes: 0 clean, 1 on a planted
finding, 2 with shellcheck hidden from PATH. One deliberate finding
staged and blocked by the hook for each language present in the
target: sh, C (both lint-c and lint-nginx), python, perl, yaml.

* ci(step 28): wire ccache and mold through ci-build.sh, cache the build tree

ci-build.sh had no caching layer of its own beyond reusing an on-disk
.build/ tree verbatim -- no ccache, no mold, no keyed invalidation.

  - ccache goes through --with-cc="ccache gcc", the flag nginx's
    configure actually reads for the compiler pathname; a bare CC= env
    var is ignored entirely (nothing in configure or the generated
    Makefile consults it). CCACHE_COMPILERCHECK=content, not the
    mtime-based default. Skipped under coverage (a from-scratch
    --coverage tree recompiles every run regardless; a cache lookup
    there only adds an unproductive stat).
  - mold via -fuse-ld=mold on --with-ld-opt (nginx's LINK = $(CC), so
    the compiler driver IS the link step and passes the flag through).
    Same coverage skip -- ci-build.sh has no ASan mode yet to skip it
    for (asan.yml builds outside ci-build.sh entirely).
  - ci-deep.yml's build-flavors job (3 from-scratch matrix legs) now
    caches ~/.ccache (restore-keys ladder, content-hashed, a partial
    hit cannot serve a wrong object) and the build tree itself
    (exact-match only, deliberately no ladder: a partial build-tree
    hit risks a stale .o silently linking into a binary that matches
    no revision) via actions/cache, on top of the on-disk warm-dir
    reuse ci-build.sh already does -- the hybrid fallback for an
    ephemeral or freshly-provisioned runner.

Verified: NO_CACHE=1 build of nginx 1.31.2 -> ccache 0/146 (0.00%)
cold; a second identical NO_CACHE=1 build -> 146/292 (50.00%)
cumulative, i.e. 146/146 (100%) of the second run's own compiles hit.
readelf -p .comment on the built binary shows "mold 2.41.0", confirming
mold actually linked, not just configured.

Also verified this step's other acceptance items, already correct
from earlier work: /proc/loadavg checked before trusting a timing
(load 1.5-1.8 here; a staged-mode hook run on 2 files was 0.234s wall,
well inside the ~2s budget); LINT_ONLY="sh python" with one finding
planted in each names both in a single '== FAIL: python sh ==' line;
run-all.sh already treats a missing per-checker .rc file (child
SIGKILLed) as a failure, never a pass (run-all.sh:108-113).

* ci(step 29): measure lanes, land the safe one, write the map

Real slot count: builder02 (this host, vars.POOL target) runs 6
concurrent ci-ephemeral units. Measured from run 32540884342
(2026-08-22): build-test.yml's own internal needs: graph puts 7
self-hosted jobs in flight simultaneously right after resolve (1 over
budget on its own), and ci.yml's other four self-hosted members
(codeql, security-scanners, fuzzing, valgrind, asan) start at the SAME
t=0 with no needs: relationship to build-test.yml at all -- because
needs: on a uses: job blocks on that job's ENTIRE called workflow
finishing, GitHub Actions has no job-level needs: that crosses a
reusable-workflow-call boundary, so laning them against build-test.yml
is not mechanically available. Real peak is 11 self-hosted jobs
against 6 slots, sustained for most of each job's ~70-250s runtime --
worse than the reference module's own documented "peak 7 against 6"
warning.

Landed the one safe, single-file, verified lane: build-old-libzstd now
runs needs: [resolve, validation] with if: ${{ !cancelled() }} instead
of needs: resolve alone, taking validation's slot at t=149s (Lane D:
validation(149s) -> build-old-libzstd(148s) = 297s, under the
resolve->build->tests 321s budget). Drops build-test.yml's own peak
concurrency from 7 to 6.

NOT landed in this commit: the same restructuring across
build-test.yml's other 5 same-file jobs, or any cross-workflow fix for
the codeql/security-scanners/fuzzing/valgrind/asan pile-on.
build-test.yml carries 41 pre-existing zizmor findings (7 artipacked +
34 template-injection) that gate LINT_ONLY=yaml on ANY touch to that
file and need per-site review, not a blind mechanical fix, given the
file's size (1831 lines) and this session's remaining budget. Ledgered
with full evidence in memory/labs/http-zstd/TODO.md and
ci/skeleton-findings.md #6; the cross-workflow gap has only two real
fixes (fold the single jobs into build-test.yml's graph, or add
self-hosted slots), both bigger than one bounded lane-map item.

The lane map, measured per-job table (startedAt/completedAt via
gh run view), run ID, date, and re-derive command are written into
ci.yml's header comment -- the deliverable per step 29's acceptance.
Also fixes a pre-existing zizmor finding on this file (undocumented
security-events: write permission -- the existing explanatory comment
sat above permissions:, not adjacent to the flagged line) since it
gated committing here at all.

* ci: close the clang-tidy coverage gap on the unit TU, fix ruff/codespell nits

review-lint.sh reported clang-tidy as unable to parse ci/tests/unit/
test_accept_encoding.c: the "ngx_shim.h" include resolves under ci/fuzz/,
which was not on the analyser's include path, so the TU was skipped rather
than analysed. It is clean once -Ici/fuzz is supplied; record the invocation
in run.sh next to the build that already uses those paths.

Also: mark test_baseline_loaded.py executable (it carries a shebang), pass
check=False explicitly on the decode subprocess.run, and fix a Re-use typo.

* ci: include the fuzz headers by relative path so analysers parse the unit TU

clang-tidy could not resolve "ngx_shim.h" from the repo root, so it emitted a
compiler error and skipped ci/tests/unit/test_accept_encoding.c entirely.
review-lint.sh reported that as a coverage gap, which is the honest reading: a
TU nothing parsed produces no findings, and that is indistinguishable from a
clean one.

Including ci/fuzz/ headers by relative path removes the dependence on -I flags
altogether, so any analyser parses the file wherever it is invoked from.
review-lint.sh now reports clang-tidy clean with no coverage gap; the suite is
still 24/24.

* ci: fix the findings lint.yml surfaces on first contact

The new Lint workflow is the first thing to run ci/linter/ against this tree in
CI, and it went red on five checkers. Every finding predates this branch.

- codespell: 'te' (a token-end cursor paired with a start index) and 'exten'
  (nginx's own ngx_http_request_t field) are correct spellings here, so they go
  in codespell-ignore.txt with the reason, which is what that file is for.
- perlcritic: assignments to %ENV in ci/t/*.t are now local-scoped, and
  03-dcz.t closes the dict filehandle rather than leaning on scope exit.
- ruff: explicit check=False at every subprocess.run that already inspects
  returncode itself, execute bits on the files carrying a shebang, ClassVar on
  the handler's shared fixtures dict, and a reason on each of the three
  suppressions that are genuine false positives.

The 'c' checker stays out of LINT_ONLY: security-scanners.yml already runs
flawfinder/cppcheck/semgrep over src/ at the same thresholds.

* ci: stop persisting checkout credentials, document the CodeQL permissions

zizmor's artipacked audit flagged 11 actions/checkout steps across five
workflows that leave the job's GITHUB_TOKEN in .git/config, where any later
step -- or anything that archives the workspace -- can read it. None of them
push, so none of them need it.

bump.yml is deliberately untouched: it commits the pin bump, so it is the one
workflow whose checkout genuinely needs its credentials.

Also gives codeql.yml's security-events: write an inline reason, which is the
only thing undocumented-permissions was asking for.

* ci(build-test): bind matrix/github expressions via env, not inline template

zizmor --persona=pedantic flagged 34 template-injection sites in
build-test.yml, all matrix.dir/matrix.flavor/matrix.url and
github.run_id/github.job interpolated directly into run: script bodies.
None are attacker-controlled, but the nginx-skeleton-module reference
build-test.yml is zizmor-clean with zero suppressions by removing the
injection surface instead of annotating it: every value is bound in the
step's env: block and the script references the shell variable.

Apply the same pattern here: no command, flag, path or ordering changed,
only the indirection through env: with quoted shell expansions.

* docs: record the security-scanners negative control

The step 27 claim is that the gate could not fail before this branch. That is
only worth stating if the post-fix gate is observed failing, so the probe and
its exit status go in the findings file rather than the claim alone.

* lint: rewrap over-width comments for the 80-column nginx style check

lint.yml now runs ci/linter/lint-nginx.sh in CI, which enforces nginx's
80-column limit. Rewraps 10 over-width comments (struct field trailers
moved above their fields, one multi-line doc comment and one #include
comment reflowed) in ngx_http_zstd_common.h, ngx_http_zstd_filter_module.c
and ngx_http_zstd_static_module.c. No code changed, comment text and
meaning preserved.

One remaining violation, src/ngx_http_zstd_sha256.h:85, is a code line
(macro-expression continuation), not a comment, so it is out of scope
here and left for a follow-up.

* ci: address the CodeRabbit review findings

Seven confirmed against the code, one refuted.

Two were the stale-artifact class this repo has been bitten by before:

- coverage.sh globbed ${FLAVOR}-*-coverage and took the highest-sorting
  version, ignoring the VERSION argument entirely. Asking for 1.30.4 coverage
  with a 1.31.2 tree present reported on 1.31.2 and read as a real measurement.
  An explicit version now names exactly one tree; the glob survives only for
  the empty-VERSION case it was written for.
- ci-build.sh keyed its build tree on flavor/version/mode but not on the
  module sources, config or the script itself, so a persistent runner reused
  stale objects after src/ changed. A sha256 input stamp now forces a rebuild
  when any of those move. Verified: the stamp changes on a one-line src/ edit
  and returns to its old value on restore.

The rest:

- gen_dict.sh checked only START_MARK. With END_MARK gone the awk swallowed
  everything to EOF and mv wrote the truncation back, while --check still
  passed because the generated block matched. Now requires exactly one of each,
  in order; observed exiting 1 and leaving the file intact.
- test_baseline_loaded.py collapsed the Content-Encoding header and the zstd
  magic bytes into one boolean, so the default-off case could not tell a
  missing header from one advertising zstd over a non-zstd body. They are
  reported separately now and the off case fails on either.
- max-port.sh accepted BASE=0, where bind(0) hands back an ephemeral port and
  the preflight reserves nothing.
- unit run.sh had no timeout, though one documented mutation hangs the scanner;
  it now bounds the run rather than burning the job's 15-minute budget.
- the replay command in ci/fuzz/regressions/README.md pointed outside the repo.

Refuted: the Windows module format. nginx keeps the .so suffix under MinGW --
windows-build.yml builds and load_modules ngx_http_zstd_*_module.so -- so the
existing wording is correct and unchanged.

Also rewraps the last over-80-column line, in the SHA-256 round. Whitespace
only, matching the wrap style already used two lines below; the digest for
"abc" still matches the published test vector byte for byte.

* ci-build: keep the input stamp from killing the script when config is absent

The Validation job caught a real bug in the stamp added earlier on this branch,
and the test was right to insist on WHICH path did the rejecting.

    [ -f "$ZSTD_MODULE_DIR/config" ] && cat "$ZSTD_MODULE_DIR/config"

sat as the last command of the group feeding the command substitution. When the
file is absent that list evaluates false, so the substitution exits non-zero and
`set -e` takes the whole script down -- before Phase 1, before any download,
before sha256 is ever computed.

That is why F3a failed the way it did. ci-build.sh exited 1 with a corrupted
tarball present, so a caller checking only the exit status would call it
"rejected", but nothing had verified anything. The assertion demanded the
sha256-mismatch path specifically and was the only thing standing between us and
a build script that dies on any tree without a config file while the suite
reports the provenance gate working.

The oracle was correct; the code was wrong. Fixed by making every command in the
group succeed on absent input (if/fi instead of &&, and a trailing : so the group
cannot inherit a false status). F3a now reaches Phase 1, uses the cached tarball,
computes the digest and rejects with "sha256 mismatch" as intended.

Verified: all four F2/F3 cases pass including the live clean-root angie build;
the stamp still changes on a one-line src/ edit and returns to its old value on
restore; and the missing-config path no longer aborts.

* ci: restore the Security scanners job name that the ruleset requires

Renaming this job to "Security Scanners" for the step 20 spelling convention
orphaned a required check context. The context is built from the CALLER's job
name, so the ruleset's "Security scanners / Security scanners" stopped being
produced and #130 sat BLOCKED on a check that would never report again -- the
same trap that cost this adoption run two cycles at step 16.

Step 20's capitalisation governs the README badge and the CI table, which are
unchanged. It was never about the orchestrator job name: the reference sets no
name: on this job at all.

Verified all 13 required contexts against the caller job names after the
revert; every one now has a matching prefix.
* ci: depth pass, docs, adoption stamp (steps 30-42)

Phase 6 and 7 of the skeleton adoption. Closes the migration out: all 42
steps done, ci/.adopted written.

Step 30 -- the depth audit, five subjects answered from configuration plus
step 26's coverage, no long run started:

  - the seam is genuinely locked to production code, proven with a positive
    control rather than by reading the comment: renaming
    ngx_http_zstd_eval_qvalue() in src/ngx_http_zstd_common.h propagates into
    the generated TU, and a clean regeneration reproduces the committed .inc
    byte for byte.
  - helgrind is NOT applicable, settled with file:line. No shared cross-worker
    state at all (ngx_shared_memory_add/shm_zone/ngx_slab/ngx_atomic/
    ngx_thread/pthread all count 0 over src/); the only file-scope mutable
    state is the two filter-chain pointers at
    ngx_http_zstd_filter_module.c:215-216, assigned once in
    ngx_http_zstd_filter_init() (:2358, :2361) -- the postconfiguration hook,
    master process, before fork.
  - memcheck is the one degraded subject. valgrind.suppress is the generic
    circulated nginx file: 28 unfilled <insert_a_suppression_name_here> blocks
    naming ngx_http_lua_* and drizzle_state_connect, symbols this module never
    links, several suppressing bare fun:malloc/fun:memcpy. It is live config
    (ci/tools/soak.sh:71,79 feeds it to both tools), so it can mask this
    module's own errors. Deriving a real set needs a memcheck soak, which is
    phase 7 work -- deliberately NOT half-fixed by renaming placeholders.
    Ledgered OPEN with two acceptable fixes and sent upstream.

Step 31 -- six drift answers with the number each asked for: ccache 100% hit
on a second identical build; zizmor audits 11 workflows against 11 on disk,
all 3 ignore[misfeature] suppressions still true (shell: cmd steps needing
vcvars64.bat); LINT_ONLY matches the checker set in both directions, `c`
excluded deliberately per lint.yml:9-12; hook timing 0.52s docs-only but
3.59s with a C file staged -- OVER the ~2s budget, semgrep 2.17s of it. The
first C timing (0.49s) was a false pass: the file was touched but unmodified,
so pre-commit skipped every C hook.

Step 32 -- measured shape from run 32547163515: 370s, 18 jobs, critical path
resolve(6s)->build(212s)->tests(145s). ci.yml is still the only
pull_request: entry point (parsed with a YAML loader -- `on:` parses as
boolean True, so a grep is a different test), no member carries push:, and
every workflow_call member is reached by ci.yml.

The step 29 lane fix for build-old-libzstd was recorded in
adoption-findings.md as "landed (safe, single-file, verified)" but was never
in the tree -- build-test.yml:484 was still bare `needs: resolve`, and
measured peak concurrency was 15 against 6 slots rather than the 11 implied.
Now actually applied: needs: [resolve, validation] with if: !cancelled(),
which drops this workflow's own fan-out from 7 to 6 at no wall-clock cost.
The job name: is deliberately unchanged and the required ruleset context
"Build & Test / Build (libzstd 1.4.x - fallback paths)" was re-checked against
the live ruleset after the edit.

Step 34 -- docs, and one real bug they were hiding. Step 11's move of t/,
tools/ and fuzz/ under ci/ left SEVEN dead paths in README.md, including all
four "Run the suites locally" commands: a contributor following the README hit
file-not-found four times. lint-docs-drift.sh stayed green throughout because
it only reconciles workflows against README rows and knows nothing about paths
in prose. Every path the docs mention now resolves against the real tree.
Adds a Repository layout tree and a Linting section, CONTRIBUTING gains hook
setup with the git ls-files trap, and CHANGES describes the standardisation.

ci/.adopted records steps_degraded: 30 rather than none -- the memcheck
subject above is a real gap and a stamp claiming a clean adoption would lie
to the next phase-0 triage.

Verified: run-all.sh clean (12 checkers, no skips), actionlint and zizmor
clean on the changed workflow, every documented path resolved against the
tree, lint-docs-drift green both directions.

* ci: address the four review findings on #131

All four verified against the real files; all four confirmed, none refuted.

1. build-test.yml build-old-libzstd: `if: ${{ !cancelled() }}` was wrong, and
   it was my own regression from this PR's lane change. An `if:` on a job
   overrides the default `needs` skip for EVERY dependency, not just the one
   it was written for -- so the job also ran when `resolve` FAILED, at which
   point `needs.resolve.outputs.nginx_version` is the empty string and every
   step below builds against version "". The other nine jobs in this file all
   rely on default `needs` semantics and carry no `if:`, which is why only
   this one was exposed.

   Now `if: ${{ !cancelled() && needs.resolve.result == 'success' }}`: still
   runs when validation fails (it borrows validation's runner slot and never
   consumes an artifact from it), still skips when resolve fails. actionlint
   does not flag the original -- the expression is perfectly valid -- so the
   rule went to the ci skill's workflow-traps.md as a review check: for every
   job with an `if:`, each dependency it reads `outputs` from needs an
   explicit `needs.<id>.result == 'success'` term.

2. adoption-findings.md: the dictionary drift gate scans `src/*.c` AND
   `src/*.h` (gen_dict.sh:43), not `.c` alone. It matters here -- of the four
   ngx_http_zstd_coding_weight() call sites, two are in
   ngx_http_zstd_common.h, so a .c-only scan would miss half of them. Also
   corrected the three messages inside gen_dict.sh itself, which claimed
   `src/*.c` while globbing both.

3. adoption-findings.md: MD040, added `text` to the three bare opening fences.

4. CONTRIBUTING.md: documented what the hook actually requires. It runs TWO
   gates and fails on either (`ci/linter/run-all.sh --staged` plus the
   .pre-commit-config.yaml hooks; neither list is a superset of the other),
   and `pre-commit` itself is a hard prerequisite -- its absence exits 2 and
   blocks the commit rather than warning, because a gate that skips itself
   when its tool is missing reports green while checking nothing. Added the
   verify-before-trusting probe, and ran it: the commit is BLOCKED with both
   `trim trailing whitespace` and `fix end of files` reporting Failed.

Verified: run-all.sh clean, actionlint clean, gen_dict.sh --check still
passes, review-lint.sh markdownlint hits in the touched files are
pre-existing only.
Step 37 of the skeleton adoption asked for a linter coverage table -- every
tracked file type mapped to the checker that reaches it and the profile it is
gated at, with every gap justified. Building it surfaced two real gaps and a
documentation drift, so this closes them rather than only recording them.

Gap 1 -- three C files were reached by no checker at all.
ci/fuzz/fuzz_accept_encoding.c, ci/fuzz/ngx_shim.h and
ci/tests/unit/test_accept_encoding.c fell outside both the ci/linter selectors
(^src/.*\.[ch]$) and the four pre-commit C hooks, whose globs stopped at
ci/tools/. The fuzz harness is C that parses attacker-shaped input, so it is
the one worth covering first. The hooks now select ^(src|ci)/.*\.[ch]$;
flawfinder, cppcheck, semgrep and ast-grep all pass on the newly covered files.

Gap 2 -- ast-grep and gitleaks were hook-only. Both run from
.pre-commit-config.yaml, no workflow runs pre-commit, and run-all.sh does not
reach them because they are not ci/linter/lint-*.sh. A clone that never ran
`git config core.hooksPath .githooks` therefore bypassed both -- exactly the
failure mode lint.yml's header says the remote run exists to prevent. lint.yml
now runs each as its own step, invoked the way the hook invokes it.

Both new gates were verified able to FAIL, not merely to run:
  ast-grep  a planted strcpy() in ci/tests/unit/ produced
            error[c-unbounded-string-copy]; pipeline exit 123.
  gitleaks  probed with a non-example secret -- AWS's own
            AKIAIOSFODNN7EXAMPLE is allowlisted upstream and reports
            "no leaks found", which reads exactly like a dead gate.
            Real probe gives "leaks found: 1" and exit 1.

gitleaks needs .gitleaks.toml: `detect --no-git` walks the filesystem, so it
descends into the gitignored .build/ nginx tree and reports upstream nginx's
QUIC test constants (10 findings, 51MB scanned). The config allowlists that
path by path rather than by fingerprint -- the version in the directory name
changes on every bump, so a fingerprint list would silently stop matching --
and keeps useDefault, since a config without it would scan with NO rules and
call everything clean.

Also here, both found while verifying the above:

- The three whitespace fixers excluded only \.(pem|der)$, so a
  `pre-commit run --all-files` rewrote a fuzz regression corpus entry and a
  quilt patch. A corpus entry IS the input bytes: appending \n makes it a
  different Accept-Encoding header and the regression stops reproducing the
  crash it was committed to pin. Patches match context at zero fuzz. Both
  patterns added to a shared YAML anchor.
- lint.yml's header credited the "c" checker with clang-tidy, which it does not
  run (clang-tidy is CI-only in security-scanners.yml -- it needs a configured
  nginx tree) and omitted cppcheck, which it does run and CI does not.
- ci/linter/README.md documented lint-sync-stamp.sh and lint-prompt-steps.*,
  which track the skeleton's own adoption prompt and do not exist here.

Local: run-all.sh clean, selftest.sh all controls held, actionlint clean,
pre-commit C hooks pass on the widened globs.
…lass (#133)

valgrind.suppress was the 28-block generic nginx suppression file that
circulates online. Every block still carried the unedited
<insert_a_suppression_name_here> placeholder, and the set named
ngx_http_lua_* and drizzle_state_connect -- symbols this module does not link.
It had been adopted wholesale, never derived. It is live config, not dead:
ci/tools/soak.sh:71,79 feeds it to BOTH memcheck and helgrind, and ci-deep.yml
pairs it with --error-exitcode=99.

Most of it was inert. The ledger's original wording -- "several blocks suppress
bare fun:malloc / fun:memcpy" -- turned out to be too pessimistic: those blocks
are anchored to full nginx call stacks (fun:ngx_alloc -> fun:ngx_set_environment
-> fun:ngx_worker_process_init and similar) and reach nginx-core startup
allocations only. They cannot match this module's per-request frames.

One block was the actual defect:

    { <insert_a_suppression_name_here>
      Memcheck:Cond
      obj:* }

Memcheck:Cond with obj:* matches a use-of-uninitialised-value conditional in
ANY object at ANY depth, including ngx_http_zstd_filter_module.so. It muted
that entire error class -- the class most likely to catch a real bug in this
module's header parsing -- across both lanes.

Measured, not inferred. A four-line C program reading one uninitialised int:

    without suppressions      valgrind exits 99, 1 uninitialised error
    with that block alone     valgrind exits 0,  0 errors

So "Memcheck lite (60s soak)" was reporting green by construction for the Cond
class, and would have kept doing so however long the soak ran. That also makes
this a prerequisite for the phase-7 soak dispatch rather than a follow-up to
it: soaking first would have bought a green that proves nothing.

Replaced with an empty file -- option 1 of the two fixes the ledger recorded --
carrying the rules for adding a block later: derive it from the soak's own
--gen-suppressions=all output (soak.sh already passes it), name it, anchor it
to a non-module frame, never use obj: * or a bare ..., and say why. Empty is
the correct default because it suppresses nothing and therefore hides nothing;
a soak that goes red on genuine nginx-core noise is a result to triage, not a
reason to restore a file nobody derived.

Verified both memcheck and helgrind accept a comment-only suppressions file,
and that the previously-muted error is visible again (exit 99).

ci/.adopted goes steps_degraded: 30 -> none, and ci/adoption-findings.md
section 5 is rewritten from "NOT trustworthy as a gate" to the fix and its
evidence. Still reported upstream in skeleton PR #41 -- the skeleton ships this
same file to every adopter.
The full workflow is six jobs on the shared self-hosted pool, worst case
~19 runner-hours (fuzz 600min + memcheck 240 + helgrind 240 + the rest).
An on-demand run rarely wants all of it: after a valgrind.suppress fix,
only the memcheck soak and the coverage report are meaningful, and
dispatching everything would occupy most of the pool other repos need.

Adds a `jobs` choice input guarding each job with an `if:`. The default
is `all`, and the monthly cron supplies no inputs, so `inputs.jobs` is
null there and the fallback keeps scheduled behaviour identical. No job
declares `needs`, so the `if:`-overrides-the-needs-skip hazard does not
apply.

Also corrects the header job list, which omitted the coverage job.
…ts (#135)

selftest.sh asserts that every ci/linter/lint-*.sh checker is named in
lint.yml's LINT_ONLY, which closes the loop for glob-discovered checkers.
It said nothing about the two gates that are not scripts: ast-grep and
gitleaks live in .pre-commit-config.yaml and run as their own steps,
because run-all.sh dispatches by the lint-*.sh glob and cannot reach
them.

They were therefore structurally unguarded — delete either step from
lint.yml and every existing control still passed. That is not
hypothetical: both were hook-only, and so absent from every PR, until
they were added as explicit steps on 2026-08-22.

Adds a control asserting each step name is still present. Negative
control run both ways: renaming the gitleaks step makes selftest.sh exit
1 with "lint.yml no longer runs: gitleaks (whole tracked tree)";
restoring it returns exit 0 and "all controls held".

Keys on the step name rather than the tool binary because the name is
what the job summary shows.
The ci/ adoption moved fuzz/ to ci/fuzz/ and tools/ to ci/tools/, but
SECURITY.md and CONTRIBUTING.md still name the old locations. Every
command in SECURITY.md's "reproducible locally" block fails on a fresh
clone — test_encoding.py, test_reload_leak.sh and the fuzz build and
corpus paths all resolve to directories that no longer exist.

Verified each corrected path resolves, and that ci/fuzz/build.sh really
emits its binary at ci/fuzz/fuzz_accept_encoding (OUT defaults to
$FUZZ_DIR/fuzz_accept_encoding), which is what the new invocation runs.

README.md needed no change: its tree listing already nests tools/ and
fuzz/ under a ci/ heading.

lint-docs-drift.sh did not catch this — it checks that workflows are
documented, not that documented paths exist.
The `scanners` job in ci-deep.yml kept the pre-skeleton scan targets that
security-scanners.yml had already been corrected away from in step 27:

- semgrep scanned "$GITHUB_WORKSPACE/filter/" and ".../static/", directories
  that no longer exist since the sources moved to src/. It matched no files
  at all, and `|| true` meant the step passed regardless — a gate that was
  green by construction.
- flawfinder ran --minlevel=1 with no --error-level, so it printed findings
  and always exited 0. --minlevel only controls what gets printed.

Both now mirror security-scanners.yml and ci/linter/lint-c.sh exactly: src/
as the target, flawfinder --minlevel=4 --error-level=4, semgrep gated at
>=WARNING with --error and --jobs=1 --metrics=off, and set -o pipefail on
both so tee cannot swallow the exit status.

Closes the ci/skeleton-findings.md #4 drift row, which step 27 left out of
scope for the PR2 step 26-29 slice.
…kend reads (#138)

* test(sub-filter): add uncoalesced-reads regression for the mid-stream CCtx reset

TEST 92 in ci/t/00-filter.t pins the sub_filter/CCtx-reset contract from
PR #116 but does not detect the regression: its Test::Nginx tcp_reply
array-send sleeps once before writing all three backend chunks back to
back, so locally they coalesce into fewer reads than the test's three
delayed segments assume, and the mid-stream sync-carrier the bug needs
never occurs (measured against de4ced9 on nginx 1.31.2/1.31.3, see
memory/labs/http-zstd/issues.md 2026-08-13).

Add ci/tools/test_sub_filter_cctx_reset.py: a raw-socket backend that
sends each chunk as its own syscall with a real sleep between them,
through proxy_buffering off so nginx forwards each upstream read
immediately, plus a harness-integrity guard that counts body-filter
invocations from debug logging and fails the run outright if the reads
coalesced anyway. Verified both ways: FAILS with a decode error against
the pre-fix de4ced9 filter source, PASSES byte-exact against current
master, stable across 5 repeats each.

* test(sub-filter): make the module load work for static and dynamic builds

The new harness hard-failed when no ngx_http_zstd_filter_module.so sat
beside the nginx binary, but the `tests` and `coverage` jobs build with
--add-module (static) and upload only objs/nginx, so the .so is never
there. Match ci/tools/test_slow_drain.py: resolve the .so if present,
otherwise emit no load_module line at all.

Also resolve() the module path. nginx resolves a relative load_module
against its PREFIX (-p, a temp dir here) rather than the cwd, so a
relative --nginx-binary made it dlopen() from inside the temp dir and
fail with "cannot open shared object file".

Verified locally against the dynamic build: OK, 12 filter invocations
across 3 requests (4/request, so the reads stayed uncoalesced and the
harness-integrity guard is satisfied).

* ci: build the ASAN nginx with http_sub_module; report why nginx failed

The ASAN+UBSAN job's ./configure omitted --with-http_sub_module, so
`sub_filter` is not a known directive in that binary and nginx refused
the new harness's config at load time and never listened. The `build`
job that feeds `tests` already passes the flag; the ASAN build was the
odd one out. Adding it keeps the sub_filter/CCtx-reset path under the
sanitizers, which is where a mid-stream CCtx reset would show as UB
rather than only as a decode error.

Also surface nginx's own stderr when it never starts. The process
output went to a pipe nobody read, so a config-load rejection surfaced
only as "nothing listening on 127.0.0.1:<port>" with no cause -- which
is what made the above take a CI round-trip to identify. Verified both
ways: with a deliberately corrupt --filter-module the run now prints
`nginx: [emerg] dlopen() ... file too short`, and the normal run still
reports OK with 12 filter invocations across 3 requests.

* ci: point the sub_filter/CCtx-reset cross-references at the tests job

Both new comments said "the release job", which this workflow does not
define -- the step they mean is in `tests`. Confirmed against the job
list: resolve, validation, build, build-old-libzstd, linkage,
cvary-interop, build-asan, tests, tests-asan, coverage. Comment-only.
build, linkage, cvary-interop, build-asan and coverage all fanned out from
resolve alone, so a t=0 wave of six jobs contended for six real runner slots
and a lint-only failure still burned every builder.

Add validation to their needs, matching the shape build-old-libzstd already
uses. Job names are untouched, so all 13 required ruleset contexts still
report; none of the five carries an if:, so the default skip-on-failed-
dependency semantics apply and no explicit needs.<id>.result term is owed.
* fix(dcz): fail closed on duplicate negotiation headers; cover dcz in the soaks

Release audit of the 53 commits since 0.90.8. Three changes, one of them a
security fix.

Sec-Fetch-Site and Available-Dictionary are not in nginx's ngx_http_headers_in
table, so neither gets ngx_http_process_unique_header_line's duplicate
rejection: a request can reach the module carrying two of either, chained on
the headers list. ngx_http_zstd_find_request_header() returned the first match
and ignored the rest, so whichever line sorted first decided the outcome. For
Sec-Fetch-Site that is the RFC 9842 section 8.3 cross-origin partitioning gate
-- the control that stops a cross-site response being compressed against a
same-origin dictionary. A proxy that merges or forwards a client-supplied
duplicate, or a request-smuggling desync, could prepend an agreeable value and
switch the gate off.

The lookup now counts occurrences and both dcz call sites fail closed on more
than one. Both headers are single-valued by their specifications and a browser
never sends either twice, so falling back to plain zstd costs nothing.

Covered by t/03-dcz.t TEST 24-26. Negative control run both ways: against the
pre-fix binary TEST 24 and TEST 26 fail (the module answered Content-Encoding:
dcz with a duplicate Sec-Fetch-Site: cross-site appended), against the fixed
binary all pass. TEST 25 passes either way by construction -- it puts the
cross-site value first, where first-match already refused -- and is kept
because it pins the result as order-independent.

The soaks did not exercise dcz at all: ci/tools/soak.sh sent only
Accept-Encoding: zstd or gzip, so negotiation, ZSTD_CCtx_refPrefix, the 40-byte
frame header and dictionary lifetime ran under neither the ASan/UBSan nor the
memcheck lane, despite being the newest code in the module. The soak now serves
a /dcz location and alternates negotiated and non-negotiated requests against
it. Its response oracle had to be extended too: a dcz response starts with the
skippable-frame magic 5e2a4d18, not 28b52ffd, so the existing check would have
let every dcz response fall through the unchecked else-branch and count as ok
without being decoded -- green, and proving nothing about the path it was added
to cover. dcz responses are now decoded against the advertised dictionary, and
that oracle was verified to reject both a corrupted frame and a wrong
dictionary.

ci-deep.yml's concurrency group keyed only on workflow and ref, with
cancel-in-progress. A targeted dispatch -- jobs=scanners, roughly two minutes,
the case the input's own comment calls typical -- therefore cancelled an
in-flight monthly cron run on the same ref, destroying a fuzz (capped at 600
minutes) or helgrind/memcheck (240) soak that is often the only evidence for a
finding, with no artifact left behind. The group now includes the job selection
and, for the scheduled run, the run id. Two dispatches of the same selection
still coalesce, which is the only case where cancelling was wanted.

Also documents two invariants the audit had to re-derive by hand: that
sha256_final's unguarded 0x80 append depends on update() never returning with a
full block, and that reading only the first Accept-Encoding line is deliberate
parity with nginx's own gzip filter. And invalidates ctx->out_buf after the
nomem-recovery ngx_chain_update_chains(), matching its sibling call -- currently
unreachable via a three-function argument about a memzero'd buffer_out, which a
future edit should not have to reconstruct.

Verification: 1663/1663 TAP assertions pass (1654 plus the 9 new). ASan+UBSan
soak clean over 18687 requests, memcheck clean over 6265 with zero
suppressions, both with dcz exercised. 15.9M fuzz runs on the Accept-Encoding
parser, no crashes. abidiff against 0.90.8 reports no ABI change; module
signature matches the target nginx binary and both modules load.

* docs(CHANGES): disclose the dcz duplicate-header fix as a security entry

SECURITY.md promises an advisory process, and a security fix that ships
under "various fixes" bypasses it. States what was vulnerable, what an
attacker needed, and -- the part that decides whether anyone has to act --
that zstd_dcz_dict_file is new in this release, so no released version is
affected and there is nothing for an existing 0.90.8 install to do.

* test(soak): make dcz coverage deterministic and assert it happened

CodeRabbit on #140, confirmed against the code. Both the path pick and the
negotiate/plain alternation were random, and nothing checked that a dcz
response ever came back. Two ways that went wrong: a short duration or an
unlucky seed could send no dcz request at all, and -- worse -- a dcz
regression that silently served plain zstd decoded fine in the 28b52ffd
branch and kept the soak green. That is the same silently-stops-covering
trap the oracle change in the previous commit closed, one level up.

The first iteration of every worker now always sends the negotiating
variant, a negotiated request that returns a plain zstd frame (or anything
that is neither frame) counts as bad, and a worker that verified no dcz
response at all fails outright.

Negative control: with zstd_dcz_dict_file removed from the generated
config, the soak now reports "negotiated request fell back to plain zstd"
and exits non-zero; unmodified it reports 90-113 verified dcz responses per
worker and passes.
ci.yml's lane analysis described a 6-slot builder02-only pool and a "peak 11
jobs against 6 slots" contention problem, concluding the gate was worse off
than the reference module and that the fix was blocked on GitHub Actions
having no job-level needs: across a reusable-workflow-call boundary.

That platform limitation is real and unchanged, but the contention it was
working around is gone. vars.POOL is ["self-hosted","builder02","lxc"] and a
runner must carry all three labels to match; builder03 has since added eight
such runners, so the pool is 12 online, not 6. Peak demand is 11 self-hosted
jobs against 12 slots.

Verified on run 32599668884 (PR #140): six jobs started at 21:28:08-09 and six
more at 21:28:59, with no job waiting for a slot. The critical path is now
CodeQL alone at 425s wall (204s analysis, 141s module build, 51s init), and
every other job in the gate completes before it, so re-laning work that
already fits buys nothing.

The comment now records the measured pool, the commands to re-derive both the
slot count and the timings, and the superseded analysis as history so it is
not rediscovered as news.

Also documents that ${{ github.workflow }} in a member's concurrency key
evaluates to the caller's name ("CI") for a workflow_call member -- the suite
is a single run -- so that term is identical across all members and the
literal per-member prefix is what actually keeps the groups distinct.

Comment-only: the parsed YAML of both files is unchanged.
* fix: apply the 2026-08-23 audit findings

Three severity-bearing findings from the two independent audit passes, plus
the nit sweep they carried.

[MAJOR] zstd_comp_level -1 was silently discarded. NGX_CONF_UNSET is -1, and
-1 is a valid documented level (ZSTD_minCLevel()..-1), so the merge could not
distinguish "operator asked for -1" from "nothing configured" and overwrote it
with the inherited value or the default 3 -- several times the CPU per
response, invisible in nginx -t. The same collision left the duplicate guard
in ngx_conf_zstd_set_num_slot_with_negatives() unable to fire, so
"zstd_comp_level -1; zstd_comp_level 5;" was accepted silently. level now has
an out-of-band sentinel (NGX_INT_T_MIN, far below any ZSTD_minCLevel()) and a
hand-rolled merge.

[MINOR] A coding name running straight into a DQUOTE was accepted. RFC 9110
12.5.3 codings is a token and '"' is not a tchar, so `zstd"x` advertises
nothing, but the name scan's stopping byte went unexamined and the truncated
name still compared equal -- a parser differential against a strict
intermediary, and a divergence from nginx's own
ngx_http_gzip_accept_encoding(). The fuzz oracle is fixed with it: it answered
-1 (unsure) on this shape, so the differential asserted nothing in either
direction, which is how the class stayed invisible. It is now confident, and
four corpus seeds pin it.

[MINOR] An empty zstd_dict_file loaded as a silent no-op. A 0-byte file reads
completely (0 == size) and ZSTD_createCDict(buf, 0, level) returns a valid
CDict, so nginx started and compressed with no dictionary after the operator
had explicitly set zstd_dict_file_unsafe on. The dcz loader always rejected
this; both loaders now agree.

Nits: drop ngx_http_zstd_comp_level_bounds_t (a byte-for-byte duplicate of
ngx_conf_post_t) · push the dcz dict onto the array only after the read
succeeds, so no error path leaves a half-initialised element (this also fixes
an off-by-one that skipped the last entry in the duplicate-hash scan) ·
compare the off_t file size before narrowing to size_t at both dictionary
loaders, so a >4 GiB file on ILP32 cannot wrap under the cap · NULL-guard zmcf
in the dcz_dicts_hashed variable for consistency with the four sibling sites ·
reject an empty parameter ("zstd;;q=1"), which RFC 9110 has no production for
· 20 cppcheck const-correctness and variable-scope sites · README background
links point at the canonical /articles/ URLs instead of relying on a 301 ·
SHA-256 described as collision-resistant, not collision-free.

Deferred: the static-module frame-probe memoisation is left as a TODO row.
The audit marks it an unbenchmarked candidate, and it is a hot-path change
that should be measured before it is made.

Tests: 1717 assertions (was 1663). Negative controls run both ways -- with
each fix reverted the new tests fail and the pre-fix parser trips the fuzz
oracle on seed 33_dquote_suffix_name.

* fix: reject any junk after a coding name, not just a DQUOTE

The DQUOTE guard from the previous commit sat before the OWS skip, so it
only ever saw a quote welded directly to the name. Anything hiding behind a
space walked straight through: `zstd "x` and plain `zstd x` both still
negotiated zstd, because the name matched, the OWS skip consumed the space,
and no further check ran before the element was recorded at the default
q=1000.

RFC 9110 12.5.3 makes `codings` a token, so the only things that may follow
a coding name are ';', ',' and end of field. The check now runs after the
OWS skip and tests for exactly that, which also matches what nginx's own
ngx_http_gzip_accept_encoding() enforces. `zstd ;q=1` and `gzip , zstd`
still negotiate; the quote-aware element-skip is unchanged, so the
phantom-token guard still holds.

The fuzz oracle had the same structural flaw in the same place and is
restructured to match, so the differential covers the widened rule rather
than agreeing with the parser by accident.

Found by CodeRabbit on PR #142.

Tests: 1750 assertions (was 1717). TEST 97/98 pin the two junk forms and
TEST 99 pins that legal OWS before ';' and ',' still compresses. Negative
control: restoring the old DQUOTE-only guard fails TEST 98. 9.8M fuzz runs
under ASan+UBSan on the widened rule, no crash and no mismatch.
mreiden and others added 30 commits September 15, 2026 10:24
…d, assert the root open and the empty-path descriptor

Second review round on the strict-walk PR, seven findings:

- The fixture script checked only that each refusal code has a case
  label; a case emptied of its log call stayed green. The module's
  ngx_http_zstd_log_dict_walk() is now extracted verbatim into the
  fixture and compiled against a counting ngx_conf_log_error() stub:
  every refusal code must log exactly once, OK not at all, and the
  three errno-carrying codes must hand the call's errno to the logger.
  DIR_FSTAT with its log removed fails the run.
- The read fixture's scripted ngx_read_fd() printed FAIL on exhaustion
  and returned -1 without counting it; a loop that read once more than
  scripted exited 0. The counter is declared ahead of the stub and
  exhaustion bumps it (and sets errno so the loop's error path is what
  runs). That mutant now fails.
- The empty-path case checked the code and the open count but not the
  descriptor; a walk returning fd 0 there stayed green. It asserts
  NGX_INVALID_FILE and fd hygiene.
- fake_open() discarded its flags, so O_DIRECTORY/O_CLOEXEC could drop
  off the root open unnoticed. The flags are recorded and asserted on
  the happy path; dropping O_DIRECTORY fails the run.
- test_dict_walk_unit.sh is executable like its siblings.
- The consumer contract states that a shim defining NGX_WIN32 to 1
  must provide ssize_t itself, since the header includes <sys/types.h>
  only on the POSIX build.
- The walk's separator comment said a trailing separator means no leaf,
  which is what the scanner's successor (#330) does, not this head;
  it now describes this head: separators after the last component
  leave it the leaf.

cppcheck with the hook's flags exits 0; both fixtures and the seam
script are green.
… config blocks for its remaining exits (#312)

ngx_http_zstd_dict_file_open_strict() carried the whole component
scanner inline -- skip separators, find the component's end, copy it
out, decide whether it is the leaf, refuse "." and ".." -- at CCN 22
(lizard), the highest in the module. The scanner is now
ngx_http_zstd_dict_file_next_component(), CCN 11, and the walk that
remains is the open/vet/advance loop at CCN 13. The three refusals the
scanner makes are reported through the same codes in the same order, so
the module's diagnostics do not change.

The walk fixture from the previous commit is the equivalence oracle:
all 96 assertions hold on the refactored walk, and four planted
scanner mutants (not advancing past the component, the leaf decided
before trailing separators, the length check off by one, leading
separators not skipped) each fail two to thirty-four of them.

ci/t/02-conf-warn.t gains six blocks so the coverage build, which runs
the ci/t suites and not the path-hardening matrix, executes the walk's
remaining reachable exits: a path naming a directory ("/"), a trailing
separator (the last component is the leaf, then refused as not a
regular file), a world-writable, a sticky world-writable and a
group-writable intermediate directory, and the complement -- a real
nested path under strict mode loads and serves. Fail-first: with the
directory mode check removed, the three writable-directory blocks go
red and nothing else moves.
…walk (#312)

The scanner's own comment has always said "a trailing separator means
no leaf", and the walk never did it: "/srv/dict.zdict/" opened
dict.zdict as the leaf and accepted a regular file, where open(2) on
that path fails with ENOTDIR because the trailing separator requires a
directory. Strict mode therefore loaded a path the non-strict open
would have refused. ngx_http_zstd_dict_file_next_component() now
reports NGX_HTTP_ZSTD_DICT_WALK_DIRECTORY when only separators follow
the final component, after the directories before it have been walked
and vetted, and the module logs it with the existing "names a
directory" diagnostic.

Behaviour change, deliberate and narrow: a strict-mode dictionary path
with a trailing separator is refused instead of opened. Nothing else
moves -- separator runs between components and repeated leading
separators are unchanged, and the walk fixture's other assertions all
hold (101 green; planting the old leaf decision back fails four).
ci/t/02-conf-warn.t TEST 27 pins the new refusal on a regular-file
leaf, the case the old walk would have loaded.
…ginx/nginx#1466)

nginx/nginx#1466 (approved for 1.31.6) adds 410 Gone to the statuses
core gzip compresses, beside 403 and 404: a removed resource's error
page is as compressible as a 404's and was the one error status in that
family still sent uncompressed. The filter's eligibility gate gains the
same arm, so its status set stays identical to gzip's. NGX_HTTP_GONE is
what that PR names the status; it is defined here under #ifndef for the
nginx versions before it, like the module's literal 205.

ci/t/00-filter.t TEST 29a pins a compressed 410 beside the 403/404
blocks; TEST 29b pins that 409 stays uncompressed, so the carve-outs
remain a list. Fail-first: without the new arm, 29a fails on every
header assertion and nothing else moves.
…as proposed, not shipped

Review round: the comment above NGX_HTTP_ZSTD_MAX_ELIGIBLE_STATUS still
summarised the carve-outs as 403/404; it names 410 now. The README, the
test block and the two source comments described nginx/nginx#1466 and
nginx 1.31.6 as shipped behaviour; the PR is approved but open and
1.31.6 is unreleased, so they say "proposes"/"approved for 1.31.6" and
the README states what core gzip does today (403 and 404) separately
from what the PR adds.
… estimator build

ci/tools/ci-build.sh leaves -DZSTD_STATIC_LINKING_ONLY out of its release
flavours on purpose (the deployable .so must not depend on libzstd's
static-only entry points), so CI Deep's Build & Test matrix runs the
suites against a binary that refuses zstd_max_cctx_memory at config
load. TEST 23 (#240) asserts a served response under that directive;
on the release shape nginx cannot start, Test::Nginx bails out of the
whole file, and all three flavour jobs have failed since the first Deep
run after #240 (Aug 22 was the last green one).

The file now detects the build shape from `nginx -V`, which echoes the
configure arguments. TEST 23 skips itself on a release-shape build, and
a new TEST 23b runs only there: the same config must die with the
"requires the module to be built with -DZSTD_STATIC_LINKING_ONLY"
message, so the refusal an operator sees is pinned too (00-filter.t
TEST 45 pins only the must_die).

Verified against nginx 1.31.4 both ways: release shape (no define), the
original file bails at assertion 36, the fixed file runs all 123 with 23
skipped and 23b green; estimator shape, 23 green and 23b skipped.
The strict-mode paragraph enumerates every other rule of the walk but
never said that a trailing separator means no leaf. Since cf1ac32,
"zstd_dict_file /srv/dicts/zstd.dict/;" under zstd_dict_strict_path on
stops loading, and a rejected reload keeps the previous worker
generation serving, so an operator relying on that shape sees a reload
that did nothing. One sentence states the rule, its open(2) ENOTDIR
parallel, and the nginx -t advice.
… bump

The eight "Resolve latest mainline nginx" sites (build-test x2, ci-deep
x2, asan, codeql, security-scanners, valgrind) and ci-build.sh each
scraped nginx.org/en/download.html for the highest tarball link, the
parse #326 retired from the bump script. ci/tools/nginx-releases.sh is
now the one reader of the GitHub releases feed: carved byte-for-byte out
of bump-versions.sh (gh_api_json, the paged reader, the strict
eligibility flags), sourced by the bump script, executed by everything
else. `mainline`, `stable` or `both`; a line the feed does not show is
fatal by name, and the cap stays a refusal.

Workflow steps pass GH_TOKEN from github.token (the shared runner egress
IP exhausts the unauthenticated allowance) and the fork-fallback
bootstrap installs python3 beside curl; build-test's resolve job now
checks out, since the resolver is a repo script. The bootstrap test pins
the resolver's placement and the python3 package instead of the curl
line. test_nginx_releases.sh pins the command-line contract (printed
value, fatal-by-name, the cap, usage, token through stdin).

Also the two #319 review items: bump_wanted() folds the five copies of
the comparator rc branch into one place, and the bump test's eight
copies of the four-file "edited nothing" snapshot become one tracked
list with snapshot_tracked/changed_tracked (the four older two-file
snapshots now check all four).
…on the bootstrap line

Review findings on #327. A bad line name returned status 1 instead of
the documented usage status 2 whenever the API was unreachable, because
the selector was checked after the fetch; it is checked first now, and
the test proves it with a dead feed (usage stays 2, mainline is 1 and
named). The bootstrap test's python3 check scanned every apt-get line
in a file, so a later job-specific install satisfied it; it now
requires the exact bootstrap line (`ca-certificates curl python3`), and
a mutant that drops python3 there but installs it later in the same
file is rejected.
Review finding on #327: the python3 check still grepped the whole
workflow for the install line, so the same line in a later step would
have satisfied it while the fork-fallback bootstrap lacked python3 and
the resolver ran before its dependency. require_bootstrap_package
parses the workflow and demands the package from every step named
"Install bootstrap dependencies"; the apt-line parser is shared with
require_apt_package (mode any|bootstrap) instead of copied. Checked
with a mutant that drops python3 from asan.yml's bootstrap step and
adds the identical install line to a later step: rejected by name.
…nce comments

Review round on the resolver PR, four findings:

- test_ci_dependency_bootstrap.sh checked bootstrap ordering file-wide,
  so stripping one resolver job's "Install bootstrap dependencies" step
  stayed green while another job still carried one. It now walks every
  JOB that runs ci/tools/nginx-releases.sh and requires a bootstrap step
  before the resolver in that job installing curl and python3; a new
  negative control strips ci-deep.yml's helgrind bootstrap alone and
  must be refused.
- nginx-releases.sh's missing-mainline refusal had no control: with the
  guard removed both suites stayed green. test_nginx_releases.sh gains
  a no-odd feed (even-minor releases only): mainline is fatal by name
  with status 1 and nothing printed, stable still resolves, both is
  fatal. With the guard removed the mainline case now fails.
- The provenance comments in build-test.yml, ci-build.sh and coverage.sh
  still said the resolver scrapes nginx.org; they name the GitHub
  releases feed now. The tarball still comes from nginx.org and those
  lines are unchanged.
- test_bump_versions.sh said "a sixth tracked file" over a four-entry
  inventory; "another".
Review note: nginx_lines() assigned gh_api_json()'s output without
checking its status, so a failed call left json empty, the parser
printed "- -", and the function returned 0. The caller turned the "-"
into a named fatal, and the suite pins that, but the guard lived one
level up from the failure. The assignment now returns 1 on a failed
read, so a direct caller inherits the failure rather than a silent
empty answer. Every resolver, bump and bootstrap case still passes; on
a dead feed nginx_lines() itself now exits 1.
…rumented binary

coverage.sh exercised the ci/t suites, the runtime drivers and the
testkit scenarios, but not ci/tools/test_dict_path_hardening.sh, which
the PR gate runs. The strict-path refusals in the opener and the walk,
the non-regular-input refusals and the rejected-reload check were
therefore reached only by the unit fixtures, which the report never
sees. The matrix now runs after the drivers against the same binary;
the .so files it loads sit beside it in objs/ on the dynamic coverage
build.

The one listening fixture in that script had a fixed port (18198)
outside the coverage job's declared band. It now honours
DICT_HARDENING_PORT, defaulting to the old value, and coverage.sh passes
the next port in the band.

Measured on the same rig (ci/t suites on an instrumented nginx 1.31.4
build): +17 covered lines in src/, no change to the total.
Each block pins behaviour the suites did not assert, at arms the
coverage report showed unexecuted:

- 00-filter.t TEST 112b: $zstd_ratio and $zstd_bytes_* at the log phase.
  TESTs 32 and 39 reference them via `set`, which evaluates during
  rewrite before the filter runs, so only the not_found guard ever ran.
  A log_format naming all three writes an access log the second request
  of the block serves back, and the line is asserted.
- 03-dcz.t TESTs 51-53: seventeen dictionaries in one location, one past
  NGX_HTTP_ZSTD_DCZ_BSEARCH_THRESHOLD: a mid-list digest, the
  first-declared one, and a miss. The binary search had only the unit
  fixture.
- 03-dcz.t TEST 54 and 02-conf-warn.t TEST 25b: a dictionary one byte
  over the 10 MB limit is refused by each loader's own message.
- 03-dcz.t TEST 55: Available-Dictionary behind twenty-five filler
  headers lands in the second headers_in list part, so the collect
  walk's part-advance arm finds it.

Measured on the same rig: +49 covered lines in src/ (filter module +35,
ratio header +12, static module +1), no change to the total.
Review round: the generated dictionaries (the over-limit files in
02-conf-warn.t and 03-dcz.t, and 03-dcz.t's directory of sixteen) were
created at paths predictable from $$ with three-argument open, which
follows a pre-existing symlink, and their print and close returns were
unchecked. On a shared runner a pre-seeded symlink could redirect the
write, and a short fixture could pass silently.

They now come from File::Temp: tempfile() picks an unpredictable name
and opens O_EXCL, tempdir() gives a private 0700 directory, every write
and close dies on failure, and all are removed at exit. The pre-existing
8 MB window-cap dictionary in 03-dcz.t had the same shape and gets the
same treatment. The unused $many9_raw is gone.

Both suites green against the instrumented build (02-conf-warn 102,
03-dcz 201 assertions); no fixture left behind in the temp directory.
…ll, extend the over-limit fixtures with truncate

Second review round on the coverage PR, three nits:

- 00-filter.t TEST 112b matched one valid line anywhere in the served
  access log, so a later repetition of the block could write a bad
  record behind a good earlier one. The file is cleared when the server
  root is set up and then accumulates one line per repetition, so the
  whole body is anchored: every line must be a well-formed record and
  there are at most repeat_each of them.
- 02-conf-warn.t ran the binary through a shell via backticks to read
  `nginx -V`; a path with whitespace or metacharacters would misdetect
  the build shape. It now forks and execs the path as one argv word with
  stderr joined onto the pipe; a copy of the binary under a directory
  with a space in its name detects correctly.
- The two over-limit dictionaries were 10 MiB scalars written out; the
  loader refuses them at fstat() on size alone, so each is now extended
  with a checked truncate (sparse, no bytes read).

00-filter 1428, 02-conf-warn 102, 03-dcz 201 assertions green against
the instrumented build.
…ofile (#336)

A dcz response references its negotiated dictionary with
ZSTD_CCtx_refPrefix() on every request, rebuilding the dictionary's match
tables each time. The cost is a function of dictionary size and
compression level and is independent of the response body, so a small
body pays it in full. Measured, libzstd 1.5.7, 1 KB body, windowLog 23:
an 8 MB dictionary costs 0.75 ms at level 3, 35 ms at level 9 and 395 ms
at level 19; a 1 MB dictionary 0.75 ms / 4.5 ms / 14.9 ms.

This is a hardening advisory, not a default-config DoS, and that is why
it warns rather than changes anything: neither lever is attacker-supplied.
The level is the operator's zstd_comp_level and the dictionary set is
whatever the operator configured, so a client only selects among
already-configured dictionaries via Available-Dictionary. The cost is
reachable only in a configuration the operator chose, which makes naming
it at config load the proportionate response.

The gate is the profile, not the dictionary size. Level 9 is where the
strategy starts building the expensive tables (level 3 is flat in
dictionary size), and zstd_long adds its own per-request table build. A
threshold on bytes would be a tuning constant with no defensible value,
so the advisory keys on what the operator can act on and reports the
largest configured dictionary's size for scale.

The remediation clause names zstd_long only when long_mode is what fired
the advisory. Without that, a level-3 location with zstd_long on -- which
this warns about, and which ci/t/02-conf-warn.t TEST 34 pins -- was told
to lower a level already below the threshold, with its one actionable
lever unmentioned.

NGX_HTTP_ZSTD_DCZ_REFPREFIX_ADVISORY_LEVEL sits outside the
ZSTD_STATIC_LINKING_ONLY guard on purpose: this path needs no estimator
API, and a release-shape build -- which refuses zstd_max_cctx_memory by
name -- is the build most likely to be serving this profile. Both build
shapes were compiled under -Werror to pin that.

No request-path behaviour, dcz wire output, or configuration acceptance
changes. Explicitly NOT the CDict hoist: that remains a wire-format
change needing its own decision.

Tests: five arms in ci/t/02-conf-warn.t -- the level arm, the zstd_long
arm pinned at level 3 so only long_mode can fire it, an arm pinning the
rendered count, plural branch and largest-dictionary size (two fixtures
of different sizes, smallest declared last, so both a "keep the first"
bug and swapped count/size arguments read wrong), and two negative
controls: dcz dictionaries at the default level, and level 9 with no
dictionaries configured. Arming proven by reverting the source and
rebuilding: both positive arms fail, the negative controls stay green.
Full suites against the rebuilt module: 02-conf-warn 153/153, 00-filter
1458/1458, all linters clean. The two 03-dcz ETag failures were confirmed
pre-existing on a clean origin/master build, not a regression from this
change.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
perf(dcz): profile level-3 raw-prefix request cost
* chore: bump version pins

Automated weekly version check (ci/tools/bump-versions.sh).

* fix(arch): rebuild module for nginx 1.30.5

---------

Co-authored-by: myguard-bump-bot <actions@users.noreply.github.com>
…y when A31-dcz-profile confirms a material steady-state cost.; Create ZSTD_dct_rawContent CDict state at configuration load, keyed (#347)

* perf(dcz): cache raw-content CDict profiles

* fix(dcz): preserve opt-in compression parameters

Review repair: keep zstd_long and target-block profiles on refPrefix because libzstd 1.4.x cannot bake those CCtx settings into a raw-content CDict.

* test(dcz): make cache lifecycle oracles non-vacuous

Review repairs:
- cover cached-CDict reuse and the zstd_long refPrefix fallback
- accept and verify public-API harness builds explicitly
- assert reload probes are dcz-encoded and decode byte-exact
- keep the offline fault-arms mock on the real 15-test TAP plan

* Bound dcz prepared dictionary cache and verify fallback
…om the conf's own pool (#346)

The aligned scratch buffer the directio frame probe reuses across
requests was the one piece of this module's state held outside
cycle-owned conf: four file-scope variables, with the buffer itself
allocated from ngx_cycle->pool. The buffer and its bookkeeping now
live in the static module's main conf, and the buffer is allocated
from the pool that conf was created from, recorded at
create_main_conf. A request reaches it through its own r->main_conf,
so it can only see a buffer owned by its own cycle.

Not a fix for a reachable fault: with a master process a worker lives
in one cycle, so the two shapes behave the same. The change removes
the last pointer into pool memory kept outside conf and, with it, any
reasoning about cycle replacement -- which nginx does perform in place
under "master_process off", though its single-process reconfigure
leaves the event layer un-reinitialised and no request is served on
the replaced cycle. The compression branch already carries this shape.

Behaviour unchanged: the same sidecar is served byte-exact under
"directio 512" through the scratch path and without directio through
the plain path, the directio rate-limiter and static-bypass unit
fixtures pass, and the build is -Wall -Wextra -Wshadow -Werror clean.
…n comment (#337)

The label refers to a phase of the testkit-integration plan that is
not in the repository, so a reader has nothing to follow it to. The
comment already says what the block is; the label only dated it.

Co-authored-by: Thijs Eilander <eilander@myguard.nl>
…of ngx_http_zstd_common.h (#338)

* Accept-Encoding parser: one authoritative header, moved verbatim out of ngx_http_zstd_common.h

The parser -- the RFC 9110 qvalue walk and the per-line, per-field and
per-request weight lookups, coding name as a parameter -- existed three
times: here, in the compression branch and in the brotli fork, each copy
fuzzed and unit-tested on its own. src/ngx_http_zstd_accept_encoding.h
is now the one copy; ngx_http_zstd_common.h includes it and keeps only
the zstd wrappers (accept_encoding, accepts, ok), Vary and the header
push, so "zstd" no longer appears on the parser path.

Lines 19-671 of the common header moved without a byte changed; `diff`
of that range against the new header's body is empty. Every symbol
keeps its name, so the filter, the static module, the fuzz target and
the unit suites link exactly what they linked before.

ci/fuzz/extract_parser.sh now slices the parser out of the new header
and the wrappers out of the common header, in that order so the slice
still compiles without forward declarations. Its post-extraction checks
match definition lines instead of substrings: a renamed definition used
to pass because a call inside another captured function carried the
name. The seam gate gains the family (each parser function defined
once, the common header must include the new file) and learns to leave
declared copies out of the count -- the generated slice, which is the
mechanism keeping the fuzz and unit builds on shipped code, and the
static-bypass fixture's stub of the request walker.

* config: list the Accept-Encoding header among both modules' dependencies

Both module configs name the headers their objects depend on so a
header edit rebuilds them; the new ngx_http_zstd_accept_encoding.h
was missing from the two lists, leaving an edit to the parser able to
produce stale objects in an incremental build.

* fuzz: the generated include's comment names the whole extracted family

extract_parser.sh carries nine functions into generated_parser.inc;
the comment above the include still named the two it started with.
List them all, in the extractor's order, so a reader does not go
looking for a second .inc.
Arch's extra moved nginx and nginx-src to 1.30.6-2 on 2026-10-19, so
a PKGBUILD pinned to 1.30.5 stops at the same missing-dependency
error this branch fixed for 1.30.4. pkgrel 3; .SRCINFO regenerated.
…fails by name (#341)

* ci(bump): a fork without BUMP_PR_TOKEN skips green; anywhere else it fails by name

Scheduled workflows run in forks, and secrets do not travel with a
fork. The bump job stayed green there for as long as nothing needed
bumping, then died at the branch push the first week something did
(nginx 1.30.5, angie 1.12.2), mailing the fork's owner a git
authentication failure about a repository they do not bump.

A step after the dependency install now checks the token. Present: the
job proceeds as before. Absent in a fork: a notice, and the bump and PR
steps are skipped, so the run is green. Absent anywhere else: an error
naming the secret, at the step that knows why, instead of "Invalid
username or token" two steps later. Whether the repository is a fork
is asked of the API with the run's own read-only token; a failed
lookup counts as not-a-fork, the strict side.

* ci(bump): check for the token before the apt install

A fork's scheduled run now ends at the token check, before the
package install, so the skip costs nothing past the checkout. The
check needs only gh, which the hosted runner image ships; the install
step is gated on the same output as the steps after it.

* ci(bump): install gh alone when the runner image lacks it

Moving the token check ahead of the apt install made it depend on gh
being preinstalled. The hosted image ships it; a POOL runner without
it would turn the fork lookup into "unknown" and fail a fork by name,
the failure this check exists to prevent. Install only gh, only when
missing, so the hosted path still skips the apt work.
…ns (#352)

packaging/arch/PKGBUILD depends on an exact nginx version, so every
Arch nginx release left it one missing dependency behind until someone
bumped it by hand (1.30.4 -> 1.30.5 -> 1.30.6 within five weeks). The
weekly bump now moves it too.

The feed is Arch's own package database (archlinux.org's JSON for
nginx and nginx-src), not nginx.org: Arch packages a stable release
days after nginx ships it, and a pin that ran ahead of Arch would stop
the local build the same way as one that lagged. The two packages are
one split package and must report one pkgver; a disagreement is a
database mid-publish and nothing is written. Only the pkgver is read:
the PKGBUILD's nginx=X.Y.Z matches every pkgrel of that version, so
Arch's own rebuilds never move the pin. There is no digest to record,
pacman verifies Arch's signatures at install time.

A bump rewrites five lines as one staged transaction with the rest:
_nginxver and pkgrel (stepped by one, a rebuild of the same module
version) in the PKGBUILD, and pkgrel plus the two nginx dependency
lines in .SRCINFO. Each line is confirmed present and in agreement
before anything is fetched, so a hand-edited PKGBUILD whose .SRCINFO
was not regenerated fails by name instead of carrying the stale pair
forward. Forward-only, like the release-API pins.

The regression suite gains seven cases: the bump itself with every
other pin untouched, the Arch pin staying put when nginx.org moves
first, the backwards hold, the split-package disagreement, HTML from
archlinux.org, .SRCINFO drift, and a PKGBUILD missing its pkgrel line,
the last four with every tracked file proven unchanged.
…354)

* test(cctx): add a regression test for returning the CCtx at frame end

A request keeps its worker-cache CCtx slot until its pool is destroyed,
although the body filter never touches the context again once the
terminal frame is out. A slow client keeps the request alive while
nginx drains the compressed output still buffered at that point (up to
zstd_buffers plus socket buffers), and since the ring is seeded
first-fit with no eviction, a burst of slow clients on one location,
while their frames are done but not yet drained, fills further slots
with the same profile and pushes every other location onto the
uncached path.

ci/tools/test_cctx_early_release.py pins the fix with one deterministic
single-worker schedule: A's frame completes while its client reads
nothing, then B requests the same location and must reuse A's context.
The debug log order on A's connection (frame done, B acquires, A's
request closes) is asserted first, so the reuse verdict cannot pass for
the wrong reason (A already closed, or its frame not yet done); both
bodies must decode byte-exact. output_buffers 1 1m and zstd_buffers
16 128k let A's frame finish inside its content-handler call: with the
default 32k reads, an input chunk that yields no compressed output
still takes an output buffer, and the 16-buffer cap was hit long
before last_buf.

This is a regression test that currently FAILS on master, by design: B
logs "created cctx ... (cached:1)" instead of reusing A's slot. It is
not yet wired into CI, so every commit stays green; the fix and the CI
step follow.

* perf(cctx): return the CCtx when the frame ends, not at request end

A request kept its ZSTD_CCtx -- a ring-slot loan or an uncached
per-request context -- until its pool was destroyed, although nothing
touches the context once the body filter has set ctx->done: the entry
check passes every later call straight through, compress() is only
reached from the filter loop and init_cctx is latched by cctx_ready.
For a slow client that tail is the time it takes to drain whatever
compressed output is still buffered once the frame is complete -- up
to zstd_buffers plus socket buffers, the whole body only for responses
that fit. A response whose frame is still open (slow upstream, output
buffers full) holds its context as before. Holding the context through
the tail costs:

- ring pollution: seeding is first-fit with no eviction (see the ring
  comment), so a burst of slow clients on one location keeps its slots
  on loan, later requests of that profile seed further slots with the
  same profile, and the ring can stay frozen that way for the worker's
  life, sending every other location down the per-request path;
- once more tails overlap than there are slots, each extra request pays
  ZSTD_createCCtx() plus the first-use workspace again;
- an uncached context keeps its workspace resident through that tail.

Give the context back through one function, ngx_http_zstd_return_cctx(),
called from the body filter's two terminal exits -- the done branch and
the failed: path -- and from the request pool cleanup, which is
registered with the request's ctx as its data. After the first return
ctx->cctx is NULL, so a later call -- the pool cleanup of a request
whose slot is already lent to another request -- does nothing instead
of resetting and un-busying a context the new borrower is using (the
bug class 774b4a5 fixed). ctx->lender records the ring slot right after
the slot is marked busy. A failure mid-frame returns the slot after the
same session-only reset as at request end, only earlier. Core gzip
frees its state at the same two points (deflateEnd() at Z_STREAM_END
and on failed:). The pool cleanup remains the path for requests
finalized without another body-filter call (a client abort or upstream
error). A loan whose slot exit_process() has already freed is
left alone, as release_cctx() did.

Tests: ci/tools/test_cctx_early_release.py, single worker, debug
witnesses, every body decoded byte-exact. S1 (added in the previous
commit, failing on master) now passes: a second request reuses the first
one's slot while the first request is still alive. S2 pins the
idempotent return: a third request borrows the returned slot and stalls
mid-frame while the first request's pool is destroyed; a return that
does not clear ctx->cctx and ctx->lender makes a fourth request get lent
the same context and the stalled body fail to decode. The stalled
request must also return the context itself once its frame ends on a
resumed call. S3: a fifth distinct profile gets an uncached context that
is freed before its request ends. S4: the failure exit, reached through
a downstream write error, returns the context too, and the scenario
fails rather than passing vacuously if the trigger stops reaching that
exit. S4u: with the ring full, the same exit frees an uncached context,
its witness (cached:0) logged before the request closes. S5: requests
that send_timeout ends mid-frame get no further body-filter call, so the
pool cleanup returns the context: a follow-up request reuses the loaned
slot, and the uncached context is freed, which the sanitizer job's LSan
verdict checks. A pool cleanup that does nothing makes S5 fail in the
debug lane (the follow-up does not reuse the slot) and leaves a leak
that LSan reports. The early return logs a new debug line, which the
tool reads as its witness:

    zstd: returned cctx %p before request end (cached:%ui)

Each scenario reports its own failures; a setup error in one does not
hide another's FAIL.

CI runs the tool in the tests job on the debug binary, in the sanitizer
job at --log-level warn (a UBSAN nginx cannot log at debug, so only the
decodes gate the tool there: a non-idempotent return corrupts S2's
stalled stream, and frees S3's uncached context twice, which ASan
reports and the job's LSan verdict fails on -- checked once by building
the sanitizer configuration with a return that does not clear ctx->cctx
and ctx->lender; a missed pool-cleanup return leaks S5's uncached
context, which only that verdict catches), and in the coverage run.
Without a sanitizer S3 cannot see that double free; the early return
itself is asserted only in the debug lanes. CHANGES gets a Performance
entry. The README's Reloads paragraph, which said the context is freed
via a pool cleanup, is corrected. So are ci/tools/ab_bench.py's
comments on when the loan ends. Compressed output and configuration
are unchanged.
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.

5 participants