Repository navigation
the one PR - #53
Open
eilandert wants to merge 443 commits into
Open
the one PR #53eilandert wants to merge 443 commits into
eilandert wants to merge 443 commits into
Conversation
|
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:
For more information about GitHub Code Scanning, check out the documentation. |
…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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
https://deb.myguard.nl/2026/05/zstd-nginx-module-what-it-does-bugs-fixed/
New Directives & Features
Added optimisations:
Fixed bugs:
Added CI pipeline:
And more: