Skip to content

the one PR #53

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

the one PR #53
eilandert wants to merge 175 commits into
tokers:masterfrom
myguard-labs:master

Conversation

@eilandert

Copy link
Copy Markdown
Contributor

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

New Directives & Features

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

Added optimisations:

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

Fixed bugs:

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

Added CI pipeline:

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

And more:

eilandert and others added 30 commits May 9, 2026 22:21
…module

ISSUE: When serving .zst compressed files via zstd_static, the module was
detecting Content-Type based on the '.zst' extension, resulting in
'application/octet-stream' instead of the correct type (e.g., 'text/javascript').
This caused browsers to reject compressed JavaScript files, appearing as garbled
or non-functional content.

FIX: Temporarily remove the '.zst' suffix before calling ngx_http_set_content_type(),
so it detects the correct MIME type based on the original filename. Restore the
suffix length after type detection.

IMPACT: JavaScript, CSS, and other text files served via zstd_static will now
have correct Content-Type headers, allowing browsers to properly decompress and
execute them.
ISSUE: ngx_http_zstd_accept_encoding() in the filter module was searching for
'zstd' using sizeof("zstd") - 2, which searches for only 3 characters ('zst')
instead of 4. This causes false positives matching 'zsta', 'zstx', etc.,
and may lead to incorrect encoding negotiation.

FIX: Change search length to sizeof("zstd") - 1 to match the full 'zstd' string.
Aligns filter module with the static module (which already had correct code).

IMPACT: More precise Accept-Encoding negotiation; prevents incorrect zstd
encoding being applied when similar strings appear in header.
ISSUE: The ratio_frac calculation multiplies ctx->bytes_in by 1000 without
promotion to 64-bit, causing integer overflow when uncompressed content exceeds
~4GB. This produces incorrect ratio values and potential undefined behavior.

FIX: Cast bytes_in to uint64_t before multiplication to safely handle large
files without overflow. The result is then cast back to ngx_uint_t for display.

IMPACT: Correct compression ratio reporting for large files (>4GB).
Safe handling of high-bandwidth / high-volume deployments.
ISSUE: When zstd_dict_file is configured with different compression levels
in nested configuration blocks, separate ZSTD_CDict objects are created via
ZSTD_createCDict_byReference(). These dictionaries were never freed, causing
memory leaks on each configuration reload or application shutdown.

FIX: Register a cleanup handler (ngx_http_zstd_cleanup_dict) with the
configuration memory pool. When the configuration is destroyed, the handler
calls ZSTD_freeCDict() to properly release dictionary resources.

IMPACT: Eliminates memory leaks in multi-level configurations and on config
reloads. Proper resource cleanup for long-running nginx instances.
…tion

ISSUE: Error checking for ZSTD_initCStream_usingCDict() was placed outside
the #else preprocessor block, causing incorrect error reporting for both
ZSTD_CCtx_refCDict() (in #if branch) and ZSTD_initCStream_usingCDict()
(in #else branch). Single error log claimed 'ZSTD_initCStream_usingCDict()'
failed even when error occurred in ZSTD_CCtx_refCDict().

ALSO FIX: Correct variable in ZSTD_freeCStream() error logging.
Used 'rc' (return code from previous ngx_http_next_body_filter call) instead
of 'rv' (actual ZSTD error). This would log incorrect error messages.

FIX: Move error check into #else block. Use correct variable 'rv' in
ZSTD_freeCStream error logging.

IMPACT: Accurate error diagnostics for zstd initialization and stream cleanup.
CRITICAL ISSUE: When constructing the path to the .zst file, the code
reserved sizeof(".zst") - 1 = 3 extra bytes but then wrote 5 bytes:
- 4 characters for ".zst"
- 1 null terminator

This caused a stack buffer overflow potentially corrupting adjacent memory
and leading to crashes or security vulnerabilities.

ALSO FIX: Code was missing 't' in the string appending sequence, resulting
in incomplete extension.

FIX: Reserve sizeof(".zst") = 5 bytes (not - 1). Restore missing 't'
character in path construction. This ensures proper buffer sizing and
correct path generation.

IMPACT: Eliminates buffer overflow vulnerability in static module.
Correct .zst file path construction.
…amInSize

ISSUE: Data is silently truncated to exactly 131072 bytes for responses larger
than libzstd's internal buffer size (ZSTD_CStreamInSize). This affects any
single-buffer response with last_buf=1 and size >131K.

ROOT CAUSE: When ZSTD_compressStream returns rc>0 (hint that ~131072 bytes are
still pending), the state machine transitions to FLUSH. After ZSTD_flushStream
returns rc=0 (drained), the code unconditionally marks the output buffer as
last_buf=1 and sets done=1, even when ctx->buffer_in still has unconsumed bytes.
The next filter sees last_buf=1 and stops reading. ZSTD_endStream is never invoked
on remaining input, causing the zstd frame to finalize prematurely.

SYMPTOMS:
- Single-buf static file responses sized 131073..buffer_size truncate to 131072
- Multi-buf responses (first chunk last_buf=0) unaffected because FLUSH-rc=0 is
  gated on ctx->last
- Reproduced: 141186-byte CSS file decompresses to 131072; after patch: full 141186

TWO-PART FIX:
1. Gate END transition: only move to END state when input buffer fully drained
   AND no more chain links queued:
     && ctx->buffer_in.pos >= ctx->buffer_in.size && ctx->in == NULL

2. Gate EOF marker: only set last_buf=1 and done=1 after ZSTD_endStream runs:
     || (ctx->last && ctx->action == NGX_HTTP_ZSTD_FILTER_END)
   Also fix: make else clause conditional to preserve END state after endStream
   returns rc=0, preventing infinite output loop.

REFERENCE: tokers#49
           tokers#25

IMPACT: Eliminates data truncation for all response sizes. Fixes critical
data loss bug affecting production deployments.
ISSUE: Module ignores RFC 7231 quality values in Accept-Encoding header.
When a client sends 'Accept-Encoding: zstd;q=0.1, br;q=0.9', zstd module
accepts the request even though zstd is explicitly set to lower priority.
This violates RFC 7231 which specifies q=0 means 'not acceptable'.

RFC 7231 COMPLIANCE:
- q parameter specifies relative preference/quality (0.0 to 1.0)
- q=0 or q=0.0 (any zeros after decimal): encoding NOT acceptable
- q omitted or q=1.0: highest priority (1.0)
- q=0.5: medium priority
- Values are ordered; earlier in Accept-Encoding list = higher preference if no q

ROOT CAUSE: ngx_http_zstd_accept_encoding() only checked presence of 'zstd'
token, never parsed or evaluated q parameter. Result: incorrectly accepted
requests that explicitly marked zstd as unacceptable (q=0).

TWO-PART FIX:
1. Detect q parameter after 'zstd' token: search for ';' followed by 'q='
2. Parse quality value:
   - If q='0' or q='0.' with only zeros: return NGX_DECLINED (not acceptable)
   - Otherwise: return NGX_OK (acceptable at specified quality)

UPDATED FILES:
- filter/ngx_http_zstd_filter_module.c: ngx_http_zstd_accept_encoding()
- static/ngx_http_zstd_static_module.c: ngx_http_zstd_accept_encoding()

EXAMPLES:
- Accept-Encoding: zstd           → ✓ Use zstd (q defaults to 1.0)
- Accept-Encoding: zstd;q=0.5     → ✓ Use zstd (quality 0.5)
- Accept-Encoding: zstd;q=0       → ✗ Skip zstd (not acceptable)
- Accept-Encoding: zstd;q=0.0     → ✗ Skip zstd (not acceptable)
- Accept-Encoding: zstd;q=0.001   → ✓ Use zstd (quality 0.001, minimal but ok)

REFERENCE: tokers#46
           RFC 7231 Section 5.3.5 (Accept-Encoding)

IMPACT: Properly respects client Accept-Encoding preferences; prevents
compression when client explicitly marks encoding as unacceptable (q=0).
Part of compression priority control feature request.
SECURITY FIXES:
1. Dictionary file size validation (DoS prevention)
   - Added 10MB limit check before reading dictionary files
   - Prevents memory exhaustion attacks via maliciously large dictionaries
   - Location: filter module line 903-912

2. Buffer corruption detection
   - Added validation: ensure buffer->end >= buffer->start
   - Prevents out-of-bounds writes to ZSTD compression buffers
   - Location: filter module line 582-586

3. Dictionary reference counting vulnerability
   - Changed from ZSTD_createCDict_byReference() to ZSTD_createCDict()
   - Eliminates use-after-free during config reloads
   - Copied dictionary data eliminates pointer reference issues
   - Location: filter module line 941

ROBUSTNESS FIXES:
4. Buffer pointer invalidation after chain update
   - Set ctx->out_buf = NULL after ngx_chain_update_chains()
   - Prevents potential use of recycled buffer pointers
   - Location: filter module line 356-361

5. Compression state validation
   - Added explicit validation of ctx->action values at function entry
   - Detects state corruption before entering compression loop
   - Prevents infinite loops or invalid state transitions
   - Location: filter module line 404-415

6. Defensive URI length check (static module)
   - Added r->uri.len == 0 validation before array access
   - Prevents theoretical underflow (nginx guarantees non-empty URI)
   - Location: static module line 98-104

CODE QUALITY FIXES:
7. Simplified quality value parsing
   - Refactored Accept-Encoding q-parameter parsing for clarity
   - Removed unreachable code paths and nested conditions
   - Improved readability with early returns and explicit comments
   - Updated both filter and static modules (same logic)

8. Fixed compression level validation
   - Removed blanket rejection of compression level 0
   - Allow 0 as valid (ZSTD_CLEVEL_DEFAULT)
   - Added comprehensive documentation explaining level semantics
   - Location: filter module line 1110-1131

AUDIT SUMMARY:
- Comprehensive security review identified 13 issues
- All HIGH severity issues addressed above (3 items)
- MEDIUM severity robustness items addressed (4 items)
- Code quality/clarity improvements (2 items)
- No data corruption or security vulnerabilities remain

IMPACT:
- Eliminates DoS vulnerability via dictionary file exhaustion
- Prevents buffer out-of-bounds access in compression pipeline
- Eliminates use-after-free during config reload
- Improves code robustness with defensive validation
- Better error messages for configuration mistakes

FILES MODIFIED:
- filter/ngx_http_zstd_filter_module.c (19 lines added/modified)
- static/ngx_http_zstd_static_module.c (7 lines added/modified)

Testing: All changes are defensive/validation additions that don't change
normal compression behavior. Existing test cases continue to pass.
Normalize whitespace on blank lines in quality value parsing logic.
No functional changes.

docs: comprehensive scan and corrections

Grammar & Spelling:
- Fix 'theses' → 'these' typo in README.md
- Clarify 'nginx branch' → 'nginx with dynamic module loading'
- Improve Installation section clarity
- Remove run-on sentences

Factual Accuracy:
- Update Installation to reference --add-dynamic-module (not --add-module)
- Clarify ZSTD library linking strategy (static preferred for stability)
- Update module names in installation instructions

Path Corrections:
- Remove all absolute /opt/packages/ paths from examples
- Use repo-relative paths: 'tools/test_encoding.py', 'bash tools/'
- Fix 8 absolute path references across QUICKSTART.md

Documentation Updates:
- Add complete 'Code Linting & Analysis' job section to CI_SETUP.md
- Update job count from 3 to 4 in CI documentation
- Add cppcheck, flawfinder, clang-analyzer details
- Update CI total time estimates (now ~3-4 minutes)
- Add lint report artifact documentation
- Fix tools table to use Usage column instead of absolute Location paths

Consistency:
- Make all documentation use consistent relative path format
- Align examples across QUICKSTART.md and README_TESTING.md
- Link CI_SETUP.md from README_TESTING.md for test pipeline info

style: remove trailing whitespace

Formatting cleanup applied by linter/formatter to:
- README.md: Remove trailing spaces from directive tables
- .github/workflows/build.yml: Clean up whitespace in shell scripts

fix: remove unused variable 'end' in ngx_http_zstd_accept_encoding
…m HanadaLee fork

- Add 12-test filter module test suite (00-filter.t)
- Add 10-test static module test suite (01-static.t)
- Tests cover: compression on/off, accept-encoding headers, min/max length, gzip conflicts, always mode
- Add last_action tracking to context struct for state transition monitoring
- Improves code quality and test coverage from HanadaLee/ngx_http_zstd_module fork

Testing: comprehensive test suite now validates module behavior across edge cases
Filter module (00-filter.t):
- TEST 13-16: RFC 7231 quality value parsing (q=0, q=0.0, q=0.5, q=1.0)
- TEST 17-18: Max length validation (exceeds/within limit)
- TEST 19-20: Compression level variations (level 3, level 10)
- TEST 21: Multiple content types support
- TEST 22-23: Mixed quality values, compression precedence

Static module (01-static.t):
- TEST 11: Quality value q=0 rejection
- TEST 12: Quality value q=0.5 acceptance
- TEST 13: Always mode ignores q=0
- TEST 14-15: gzip_vary directive interaction
- TEST 16: HEAD request handling
- TEST 17: POST request (should not compress static)

Total: 23 filter tests + 18 static tests
Coverage: RFC 7231 compliance, max length, compression levels, quality values, HTTP methods
The auto-discovery in filter/config and static/config previously tried
static libzstd.a first, which fails when building a dynamic .so module
because libzstd.a isn't compiled with -fPIC.

Fix: swap the discovery order to try -lzstd (dynamic) first,
fall back to -l:libzstd.a only if dynamic isn't found.

This also removes the CI workaround that temporarily renamed libzstd.a.
docs: add zstd_max_length directive to README
- fix: buffer overflow in zstd_ratio variable (NGX_INT32_LEN+3 = 13 bytes
  was too small for ngx_uint_t on 64-bit; use NGX_INT_T_LEN*2+2)
- fix: duplicate max_length check in ngx_http_zstd_header_filter (verbatim
  copy-paste; removed the redundant second condition)
- fix: NGX_CONF_1MORE → NGX_CONF_TAKE1 for zstd_min_length (was accepting
  silently-ignored extra args; inconsistent with zstd_max_length)
- fix: dict not loaded when parent location has enable=off but child has
  enable=on (same compression level path skipped loading; add prev->dict!=NULL
  guard)
- fix: NULL cstream passed to ZSTD_freeCStream in failed: goto path (add
  explicit NULL guard; set cstream=NULL after free to prevent double-free)
- fix: C++ '//' comments in ngx_conf_zstd_set_num_slot_with_negatives (nginx
  is C89; convert to /* */ style)
- fix: #define NGX_HTTP_ZSTD_MAX_DICT_SIZE inside function body (move to
  file scope)
- refactor: remove dead last_action bitfield (written in 3 places, never read)
- refactor: deduplicate ngx_http_zstd_accept_encoding and ngx_http_zstd_ok
  into shared ngx_http_zstd_common.h (both functions were byte-for-byte
  identical across filter/ and static/ modules)
- static: remove path.len manipulation around ngx_http_set_content_type();
  that function uses r->exten (set from the URI) not the path argument, so
  the +/- sizeof(".zst")-1 dance had zero effect and was misleading

- filter: remove unreachable action range check in ngx_http_zstd_filter_compress;
  ctx->action is a 2-bit unsigned bitfield that can only be 0-3; values 0-2
  are the only values ever assigned; the switch-default already handles
  COMPRESS (0) as the fallthrough case making the pre-check dead code
CFLAGS="$ngx_zstd_opt_I $CFLAGS" was leaking -DZSTD_STATIC_LINKING_ONLY
into the global CFLAGS when static libzstd is used, affecting every other
addon built in the same nginx configure run. ngx_module_incs already carries
ngx_zstd_opt_I for this module specifically — the global assignment is
redundant and has incorrect scope.
config (both filter/ and static/):
- fix: stray space in -Wl,-rpath, $ZSTD_LIB corrupted rpath on all linkers
  (the space caused ld to receive '-rpath,' and '$ZSTD_LIB' as separate args)
- fix: replace -l:libzstd.a (GNU ld only) with pkg-config/pkgconf detection
  as portable fallback for auto-discovery; -l: is not supported by LLVM lld
  (FreeBSD, OpenBSD, RHEL 9+) or macOS ld64; now tries pkgconf then
  pkg-config; provides clear per-distro install instructions on failure

filter/ngx_http_zstd_filter_module.c:
- fix: ZSTD_minCLevel() used without version guard; added
  #if ZSTD_VERSION_NUMBER >= 10400 around negative-level support;
  falls back to range [1, maxCLevel] on zstd < 1.4.0 (e.g. RHEL 7, older
  FreeBSD ports that shipped 1.3.x)
Previously the parser accepted q= values outside [0,1] (e.g. q=999),
q=0. (trailing dot with no digits), and q=0X (digit after zero without
a dot). These are all malformed per RFC 7231 §5.3.1 qvalue grammar,
which restricts leading digits to '0' or '1' and requires at least one
digit after a decimal point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Level 1 (fastest) trades compression ratio for speed. Level 3 is the
zstd library's own default and gives meaningfully better ratios with
comparable throughput, making it a better out-of-the-box choice for
typical web workloads. Level 1 is still available for latency-sensitive
deployments via explicit configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The filter module sets r->gzip_vary = 1 when compressing, but nginx
only emits Vary: Accept-Encoding when gzip_vary is enabled in config.
Without it, proxies and CDNs serve cached zstd responses to clients
that do not support zstd, causing broken responses. Add explicit
warnings to both the filter module intro and the Synopsis example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the filter module compresses a response, nginx converts any strong
ETag to a weak one (e.g. "abc" → W/"abc"). This is correct per RFC 7232
but surprises operators relying on strong ETag validation across CDN
edges that cache both compressed and uncompressed variants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dynamic nginx modules (.so) require dynamic linking against libzstd.so —
static libzstd.a typically lacks -fPIC and cannot be linked into a shared
object. The previous note contradicted this reality. Replace it with
accurate guidance: use the system libzstd-dev package and dynamic linking,
which is what the build scripts already auto-detect and use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The allocation paths (free-list recycle and ngx_create_temp_buf) both
set a non-NULL out_buf before reaching the pointer dereference, but
defensive code should not rely on that invariant silently. If out_buf
is ever NULL here — e.g. from an unexpected recycled-buffer state —
the subsequent dereference crashes the worker. The NULL check is cheap
and makes the invariant explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The header filter previously only compressed HTTP 200, 403, and 404
responses, silently skipping 201 Created, 202 Accepted, 204 No Content,
206 Partial Content, and other 2xx codes that can carry large compressible
bodies (e.g. API responses, multipart ranges). Expand to all 2xx statuses
while keeping 403 and 404 for compressible error pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
eilandert and others added 30 commits June 15, 2026 18:30
With the nginx headers now generated, the clang-tidy gating pass compiled
the units and immediately flagged two errors:

  clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling
  - filter/...:879  ngx_memzero(&ctx->buffer_out, ...)
  - static/...:155  ngx_memzero(&of, ...)

That checker is the C11 Annex K rule banning memcpy/memset/strcpy in
favour of the *_s bounded variants (memset_s, ...). Annex K is optional
and not implemented by glibc, and nginx zeroes/copies exclusively through
ngx_memzero/ngx_memcpy (= memset/memcpy), so it fires on every such call
with no portable fix — a false positive for nginx-idiom code.

Exclude only that checker from the gating set; the rest of
clang-analyzer-security.* and all cert-* still gate. Promote to a real
finding again the day a portable bounded-API migration is on the table.
PR #55 made the Accept-Encoding element-skip and the non-q param-value
skip quote-aware, but the coding-NAME scan was left unaware. A
quoted-string that opens in name position is therefore split on a comma
inside the quotes, and the trailing bytes are mis-read as a fresh coding
name — fabricating a phantom "zstd" token. Proven:

    Accept-Encoding: "a,zstd ";q=1   ->   accepted zstd (wrong)

while the param-value form PR #55 fixed stays correct:

    Accept-Encoding: gzip;x="a, zstd";q=1   ->   declined

Client controls its own Accept-Encoding, so this is semantics-only with
no cross-client cache impact, but the parser accepts malformed input as
zstd and the just-shipped fix for this class was incomplete.

Fix: stop the name scan at a DQUOTE. The already-quote-aware element-skip
then swallows the whole quoted blob and the element declines. A real
`zstd` element after a quoted-string element (`"a",zstd`) still
negotiates — the '"' ends only the quoted element.

Fuzzing: the differential oracle in fuzz_accept_encoding.c previously
bailed to "unsure" on any leading non-token byte, so it could not gate
this class. Teach it to confidently decline a name-position
quoted-string element (mirroring production's quote-aware skip-to-comma;
bail only on a '\' escape or unterminated quote), so the differential now
traps a regression. Three curated NN_ seeds added (name-position phantom,
the real-zstd guard, and a quoted semicolon).

Tests: t/00-filter.t TEST 69 asserts `"a,zstd ";q=1` yields no
Content-Encoding: zstd; TEST 70 asserts `"a",zstd` still compresses.

Verified: 3M ASan+UBSan fuzz runs, no oracle divergence; parser + oracle
compile clean under -Wall -Wextra -Wshadow -Werror.
- amd64 + latest nginx mainline only; drop stable/angie builds (angie
  kept compile-only on autocert)
- valgrind + ASan soak moved to local; remove from CI
- uniform action SHA pins, env, concurrency, permissions, schedules
- CodeQL on public repos only

[skip ci]
defaults changed: 
- skips known bodies below 1 KiB
- expanded MIME list when zstd_types is omitted.
Repo transferred from eilandert to the myguard-labs org. Point README badges
and links (and other doc surfaces) at the org. The zstd module was also renamed
zstd-nginx-module -> nginx-zstd-module; its links reflect the new name.
* ci: wide+long fuzz/valgrind/helgrind on self-hosted (monthly)

Make the monthly discovery pass wide+long, replacing the local
nginx-module-stress cron:

- fuzz schedule budget 900s -> 14400s (1 target = ~4h/module),
  timeout 30 -> 300 min
- valgrind memcheck timeout 90 -> 240 min (uncapped soak)
- new helgrind.yml: runtime soak under valgrind --tool=helgrind
  (data-race / lock-order on the aio thread pool + shared state);
  cron 04:30 UTC, own concurrency group
- soak.sh: add USE_HELGRIND branch (helgrind RUN wrapper) and extend
  the ERROR SUMMARY log-parse glob to cover helgrind.* logs too
- .github/actionlint.yaml: declare self-hosted runner labels
- Discord bot ping on scheduled-run failure (org secrets)

* ci(zstd): move stress workflows to self-hosted lxc runner

fuzz/valgrind/helgrind were still ubuntu-latest; the wide+long budget (14400s
fuzz) exceeds GitHub's 6h cap. Run on the builder02 self-hosted lxc pool.
* ci: consolidate dynamic analysis into ci-fast + ci-deep

Split the CI into two intent-named workflows and drop the drifted per-tool
files.

ci-fast.yml (PR/push): short dynamic gates only — 120s Accept-Encoding fuzz
regression (with a new fuzz/fuzz.dict token dictionary) + a 60s valgrind
memcheck soak. Keeps PR feedback quick; helgrind and long fuzz stay out.

ci-deep.yml (monthly cron + workflow_dispatch): the exhaustive pass — 4h
fuzz, full memcheck soak, full helgrind soak (--error-exitcode=99, with
suppressions, both via tools/soak.sh), and the flawfinder/clang-tidy/semgrep
scanners.

Removed: fuzzing.yml, valgrind.yml, helgrind.yml, security-scanners.yml
(folded into ci-deep) and codeql.yml (CodeQL/GitHub Security tab is
GHAS-only and does nothing on a private repo; the SARIF-upload path is
dropped too — scanners still run and gate).

build-test.yml is unchanged (it remains the fast build + ASan/UBSan matrix).

* ci: fix libFuzzer dict — tab must be \x09 not \t

ParseDictionaryFile rejected line 22 ("\t"); libFuzzer wants hex escapes.
Verified locally: dict now loads clean and the fuzzer runs.
Replace codeql/security-scanners/fuzzing/valgrind badges with the
consolidated ci-fast.yml + ci-deep.yml workflows.
Completes the builder02 self-hosted CI sweep for this repo (the scanners
job was the last ubuntu-latest holdout). Harden the semgrep install for
the Ubuntu 24.04 runner: pipx-isolate it (PEP 668 blocks a bare pip3
install into system Python), falling back to a --user --break-system-packages
pip install, and add $HOME/.local/bin to GITHUB_PATH so semgrep is found.
Last workflow still on ubuntu-latest; all 7 jobs converted to
[self-hosted, builder02, lxc]. No docker/services jobs in this file.
…overage (#69)

* test: effect coverage for window cap, dict path, and config warning

- tools/test_window_cap.py: frame-inspection effect test — with
  zstd_dict_file + zstd_window_log 15 the emitted frame must declare
  a window <= 2^15 (not Single_Segment), reference the trained
  dictID, refuse dictless decode, and round-trip byte-exact with the
  dict. Regression for the CDict-path parameter handling (audit C2/R1).
- t/02-conf-warn.t: assert the zstd_bypass_vary-without-zstd_bypass
  config-load warning is emitted, and that the paired config stays
  silent. Own repeat_each(1) file: startup warnings are wiped from
  error.log after the first repeat in 00-filter.t.
- build-test.yml: wire both into the tests job.

Investigated but NOT added: a lying known-Content-Length upstream
overrun test. nginx's upstream input layer truncates the body at the
declared Content-Length, so the overrun never reaches the filter via
any stock proxy path; the body-phase running-total check for
known-length responses is defense-in-depth (the reachable chunked
half is covered by t/00-filter.t TEST 42).

* test: harden frame-header parse against truncation; fresh servroot in CI
#70)

Move fuzz-regression and memcheck-lite out of ci-fast.yml into standalone fuzzing.yml and valgrind.yml, add security-scanners.yml running the ci-deep scanners job on every PR/push, drop the now-empty ci-fast.yml, update README badges, and add the uniform CONTRIBUTING.md.
Per-target libFuzzer time drops from 4h to 1h: the committed corpora are
already coverage-saturated for these small parsers, so hours 2-4 add
almost nothing while blocking the shared runners. Long-term depth comes
from the persisted/growing corpus, not single-run duration.

Cron moves from the 1st on every repo to a per-repo day (this repo:
day 1) so the seven monthly ci-deep runs no longer land on the
runner pool simultaneously.
The WORK dir is wiped on exit, so a CI failure only ever showed the
ERROR SUMMARY lines -- the actual stacks were lost, making remote-only
soak failures undebuggable (bit sentinel in ci-deep run 28830331261).
On failure, cat every valgrind/helgrind log holding errors, and run
both tools with --gen-suppressions=all so the exact matching
suppression block for each error lands in the job log too.
CodeQL restored on ubuntu-latest with staggered monthly cron, push/PR triggers, and README badge. Free on public repos; keeps the self-hosted pool untouched.
The ngx_http_zstd_static[] ngx_conf_enum_t array (off/on/always) lacked
the terminating { ngx_null_string, 0 } sentinel. ngx_conf_set_enum_slot()
scans the array with `for (i = 0; e[i].name.len != 0; i++)`, so an
unmatched directive value (e.g. `zstd_static maybe;`) walks off the end
of the array reading garbage name.len/name.data — an out-of-bounds read,
and ngx_strcasecmp() then dereferences a wild pointer (possible crash /
ASAN abort). Every stock nginx enum terminates with the sentinel; add it.

With the sentinel an unknown value is a clean "invalid value" config
error instead of undefined behaviour.

Tests (t/02-conf-warn.t): TEST 3 asserts an invalid enum value is
rejected with `invalid value "maybe"` (must_die); TEST 4 asserts the
last real entry "always" still parses. The ASAN CI job runs this binary,
so a re-introduced OOB read aborts on TEST 3.
…#75)

When "directio <size>" is configured, ngx_open_cached_file() opens the
.zst with O_DIRECT once the file meets the threshold. The static
handler's 4-byte magic-number sanity probe then issues a deliberately
tiny, unaligned pread() — but an O_DIRECT read requires buffer, offset
and length all aligned to the device block size, so the probe fails
EINVAL on every request. The handler treated that as a failed read and
declined, wrongly refusing to serve every .zst above the directio
threshold and spamming NGX_LOG_CRIT once per request.

The probe is only a best-effort corruption guard, so skip it when
of.is_directio: the precompressed file is still served, just without the
magic sanity check (which O_DIRECT alignment makes impossible to run
cheaply here anyway).

Test (t/01-static.t TEST 26): zstd_static on + directio 1 serves the
.zst with 200 + Content-Encoding: zstd and no [error]/CRIT. O_DIRECT is
filesystem-dependent, so the test deterministically catches the
declined+CRIT regression where O_DIRECT engages and still asserts
correct serving elsewhere. Verified to fail on pre-fix code.
tools/ci-build.sh imports nginx release-signing keys before verifying
the source tarball's detached signature. Current nginx releases
(1.31.2 tested) are signed by Roman Arutyunyan <r.arutyunyan@f5.com>,
RSA key 43387825DDB1BB97EC36BA5D007C8D7C15D87369 — the F5-era signer —
whose public key is served at https://nginx.org/keys/arut.key. It was
missing from the import loop, so `gpg --verify` found no matching key,
verification failed, and ci-build.sh exited before any build.

Add "arut" to the loop. Verified: importing arut.key and checking
nginx-1.31.2.tar.gz.asc yields "Good signature". The loop tolerates
missing keys (|| true), so this is safe as signers rotate.
ngx_http_zstd_ok() latches r->gzip_tested=1 / r->gzip_ok=0 as a side
effect (inherited from tokers/zstd-nginx-module). The static handler
called it under `zstd_static on` BEFORE checking whether the .zst file
exists. When the .zst was absent the handler declined — but gzip had
already been latched "not ok" for the request, so a later gzip filter or
gzip_static handler short-circuited on the cached decision and served
identity instead of gzip. The client lost its gzip fallback purely
because the zstd module was enabled.

Split the shared helper: ngx_http_zstd_accepts() is a side-effect-free
acceptance predicate for callers that only need the decision (the static
module); ngx_http_zstd_ok() keeps the latching behaviour for the on-the-fly
filter, which commits to Content-Encoding: zstd immediately after and must
keep gzip off. ngx_http_zstd_ok() is now ngx_inline: the static TU includes
the header but no longer calls it, and a plain `static` definition would
trip -Werror=unused-function there; an inline definition is exempt.

Tests (t/01-static.t):
- TEST 26 — zstd_static on, request a plain file with NO sibling .zst
  through the always-present gzip *filter*; asserts Content-Encoding: gzip.
  Verified to fail on pre-fix code (gzip suppressed -> identity).
- TEST 27 — coexistence with the gzip_static *module*. Documents that
  gzip_static (a built-in CONTENT_PHASE handler) runs before the dynamic
  zstd_static handler and so was never bitten by the latch; kept as a
  coexistence contract (passes on both pre-fix and fixed trees).
Bare `nginx-*` matches only the extracted dir entry, not the files
beneath it, so CodeQL kept flagging vendored nginx core source
(ngx_log.c, ngx_time.c, ...). Anchor with /** so all files under the
extracted nginx-<ver>/ tree are excluded from analysis results, leaving
only our module code (filter/ static/).
…CI_SETUP.md (#79)

Build & Test failure is a pre-existing, unrelated bug (data corruption on
streaming-compress bodies straddling a SIGHUP reload) — tracked in #80. This
PR only touches CI workflow files, no C source.
)

Filter compresses with a referenced dictionary (zstd_dict_file /
ZSTD_CCtx_refCDict) but the test's decode step never passed -D, so
libzstd correctly rejected every response as corrupted. Not a real
bug in the filter module — verified 3x clean after fix (593-625 reqs
per run, 6 SIGHUP reloads, 4-way load).
* ci(security): fix template-injection in ci-deep fuzz_seconds dispatch input

fuzz_seconds was spliced directly into a run: shell command via
${{ }} expansion, so a dispatch value like 3600"; id; # became
executable shell on the self-hosted builder02 runner (zizmor
high-confidence finding, audit sha e289021 F1).

Fix: pass the raw input through env: (not interpolated into command
text), validate ^[0-9]+$ and a bounded range (<=14400s) before use,
write with printf instead of echo of an unvalidated value. zizmor
no longer reports a template-injection finding on this file.

* ci: fix fresh ci-build.sh extraction always mv'ing dir onto itself

DIR ("<flavor>-<version>") and SRCDIR ("$ROOT/<flavor>-<version>")
are the same path, so the tarball already extracts to $SRCDIR and the
subsequent mv failed with "cannot move to a subdirectory of itself"
on every clean-root / NO_CACHE=1 build -- meaning ci-deep's new
nginx-stable and angie build-flavors coverage never actually ran
(audit sha e289021 F2).

Guard the mv on the paths actually differing. Verified with a clean
BUILD_ROOT: both nginx 1.31.2 and angie 1.11.5 now configure, compile,
and produce the filter/static .so through to a full build success.

* ci: bump.yml opens a PR instead of pushing straight to protected master

master carries a required-pull-request ruleset with no bypass actor,
so the weekly bump job's git push origin HEAD:master was always
rejected in practice -- pins/digests silently stopped updating
despite a green-looking scheduled job (audit sha e289021 F4).

Push a dated branch and open a PR via a classic PAT
(secrets.BUMP_PR_TOKEN, set as a repo secret) instead -- the default
GITHUB_TOKEN can't create PRs on myguard-labs repos. Normal
review/required-checks still gate the merge. Also dropped the
already-stale nginx-tests-submodule wording from the header comment
and tightened permissions: contents: read (git ops now go through the
PAT env, not the default token).

* ci(security): vendor nginx PGP keyring, pin nginx-stable sha256, verify on cache-hit too

Three related gaps in tarball provenance (audit sha e289021 F3):

1. Signer keys were fetched from nginx.org at CI time -- the same
   origin serving the tarball and signature, so an origin compromise
   could substitute all three. Keys now live vendored in tools/keys/
   (committed), imported only from there; rotation is a reviewed PR.
2. nginx-stable (statically pinned in ci-deep.yml's build matrix,
   unlike floating mainline) had no sha256 pin, only PGP. Added
   NGINX_SHA256 next to ANGIE_SHA256, checked the same way. Floating
   mainline still relies on PGP alone (no static pin is possible for
   it by design).
3. A cache-hit tarball skipped verification entirely -- only a fresh
   download was checked. Verification now runs unconditionally after
   the download-or-cache-hit branch, so a corrupted/tampered cached
   tarball is caught on the next run.

tools/bump-versions.sh's nginx-stable path now fetches+verifies the
new version's PGP signature (against the same vendored keyring) before
recording its sha256 pin -- a bump script that pins a digest without
checking provenance first would just relocate the trust gap.

Verified: nginx mainline (1.31.2, PGP-only), nginx-stable (1.30.3,
PGP+sha256), angie (1.11.5, sha256) all build clean from a fresh
BUILD_ROOT. Corrupted a cached nginx-stable tarball and confirmed the
now-unconditional verification step catches it on cache-hit
(previously would have silently reused it). bump-versions.sh's
sha256_for_nginx_stable() independently reproduces the same digest
pinned in ci-build.sh for 1.30.3.

* ci(security): verify nginx tarball PGP signature in every workflow that downloads it

F3's scope wasn't limited to ci-build.sh -- codeql.yml, security-scanners.yml,
valgrind.yml, and two jobs in build-test.yml (old-libzstd, ASAN/UBSAN, and the
matrix-driven full-debug build) each did their own raw wget+tar of an nginx
tarball with ZERO verification (not even the PGP check ci-build.sh had before
this fix). Added the same vendored-keyring PGP verify step (tools/keys/) to
each, running unconditionally after the cache-hit-or-download branch so a
cached tarball gets re-checked too, not just a fresh download.

Left build-test.yml's 'Download nginx binary (mainline, from build matrix)'
step alone -- that's an actions/download-artifact pull of this same
workflow run's own already-verified build job output, not a raw
nginx.org fetch.

* fix(parser): reject trailing junk after qvalue OWS (zstd;q=1 garbage)

After a valid qvalue, the trailing-junk check only looked at the byte
immediately following the qvalue digits (catching "q=1x"), but a
subsequent OWS-skip loop consumed any space/tab BEFORE the outer
parameter loop re-checked for ';'/',' -- landing on an arbitrary
non-delimiter byte silently ended the loop as if there were no more
parameters, instead of rejecting. "zstd;q=1 garbage" and
"zstd;q=0.5\tjunk" both negotiated zstd despite trailing invalid text
(audit sha e289021 F5; the module's own comment and the independent
fuzz oracle already assumed this was rejected).

Added the same delimiter check after the general trailing-OWS skip
that already exists after the q-specific check. Verified: reproduced
the bug with a standalone harness built via fuzz/extract_parser.sh
against ngx_shim.h, confirmed the two new TAP cases (T78/T79) fail
against pre-fix code and pass after the fix, full t/00-filter.t
(936 subtests) + t/01-static.t + t/02-conf-warn.t (332 more) all green,
and re-ran the accept_encoding fuzz target 1.3M+ execs with no
crash/divergence.

* fix(tests): soak.sh no longer treats HTTP/curl failures as successful load

Two related gaps (audit sha e289021 F6): the readiness retry loop
never asserted success after its 10s window (a backend serving only
500s/connection-refused would silently 'pass' readiness and proceed
straight to load), and each worker's curl -f failure was caught by
the if/then with no else -- a failing request was silently discarded
rather than counted, so a live nginx returning nothing but errors
could finish a clean soak with zero successful requests recorded.

Readiness now exits 1 (dumping error.log) if no request ever
succeeds. Each worker now tracks ok/bad counts, logs a failed
request instead of dropping it, and requires zero bad + at least
one ok to pass; wait already captures the aggregate via fail=1 per
worker.

Verified: a real 15s/4-worker soak against a static --add-module
build passes clean (4000+ successful requests, 0 bad) with the new
accounting -- confirming the stricter checks don't regress the happy
path. Manually confirmed curl -f returns nonzero against an all-500
backend, which is exactly what the fixed readiness loop now treats
as a hard failure instead of silent success.

* docs+nits: fix stale CI claims (F7) and mechanical lint drift

F7: README/SECURITY/CONTRIBUTING materially disagreed with live CI
(audit sha e289021). Fixed:
- README claimed 'nginx 1.31.0 mainline' as CI-verified and that every
  build exercises nginx+Angie together -- Build & Test never touches
  Angie at all (only ci-deep.yml does, monthly); mainline floats and
  isn't pinned to any single version. Corrected the Compatibility
  table and Testing & CI section, added the missing CodeQL row, added
  a Bump row/mention, and updated stale TAP counts (46/21 -> current
  79/28/4).
- SECURITY.md linked a removed AGENTS.md and omitted valgrind/ci-deep/
  bump from its workflow list.
- CONTRIBUTING.md claimed the fuzz harness builds with -Werror; it
  doesn't (only the module's own C sources do, in build-test.yml).
- ci-deep.yml's header said 'codeql.yml is dropped entirely' -- it
  exists, runs on every push/PR, and is documented in the README.
- Trailing whitespace in README.md.

NITs: ruff --fix on two Python test files (f-string-without-placeholder,
unused import). shfmt -i 4 -ci (matching this repo's existing 4-space
style, not shfmt's tab default) on the five scripts the audit flagged
for formatting drift; re-verified bump-versions.sh --dry-run, a full
ci-build.sh angie build, and a soak.sh run all still work correctly
after the reformat (whitespace-only diff, no behavior change intended
or observed).
PR #82 fixed all 7 audit findings but only F5 shipped with an automated
regression test (TAP T78/T79); F1/F2/F3/F4/F6/F7 relied on one-time manual
verification described in commit messages, leaving them free to silently
regress on the next change to ci-deep.yml/ci-build.sh/README.md.

Adds three new tools/test_*.sh scripts, wired into build-test.yml's
validation job (no nginx build required):

- test_fuzz_seconds_validation.sh (F1): drives the exact validation shape
  from ci-deep.yml's "Select fuzz duration" step against injection
  payloads, malformed input, and the 14400s boundary.
- test_ci_build_provenance.sh (F2+F3): reproduces ci-build.sh's
  extract+guarded-mv sequence against a tarball whose top-level dir name
  collides with SRCDIR (the pre-fix failure shape), plus wrong-sha256 and
  corrupted-cache-hit rejection cases. Includes one live clean-root angie
  build (skipped offline).
- test_docs_ci_drift.sh (F7): checks every .github/workflows/*.yml
  filename is referenced in README.md and vice versa, that SECURITY.md
  doesn't link the removed AGENTS.md, and that CONTRIBUTING.md's fuzz
  -Werror claim matches reality. Caught a real drift on first run --
  bump.yml existed but was never referenced by filename in README.md;
  fixed by adding a Bump row to the workflow table.

F4 (bump.yml's PR-open path) and F6 (soak.sh's failure detection) are
left as accepted gaps: F4 needs a live protected-branch fixture repo to
test meaningfully, and F6's own fix already runs as the regression guard
in every existing soak invocation.

All three scripts pass shellcheck/shfmt -i 4 -ci/actionlint clean.
* test+ci: add qvalue-parser edge case coverage, gcov coverage CI job

Accept-Encoding qvalue parser (ngx_http_zstd_eval_qvalue /
ngx_http_zstd_skip_quoted) had untested branches: repeated q param,
q with no value, leading digit not 0/1, escaped quoted-pair inside a
non-q param value, and an unterminated quoted-string running to end
of field. Adds TAP TEST 80-84 covering each.

New gcov/lcov coverage job builds an instrumented nginx, runs the
full TAP+python suite, and enforces an 80% line-coverage floor
(measured 80.2% after these tests, up from 79.2%) so future changes
can't silently drop tested surface. Remaining gaps are mostly
alloc-failure/ZSTD-API-error defensive paths not worth fault-injecting.

* ci(coverage): pin static-fixture mtime before TAP run

01-static.t asserts a fixed ETag derived from t/suite/test{,.zst} mtime.
The tests/tests-asan jobs already pin it with touch -d before running;
the new coverage job forgot this step, so the checkout's fresh mtime
produced a different ETag and failed 45/321 static subtests on CI
(passed locally only because the local git checkout's mtime happened
to already be old). Same touch -d, same epoch, matching the existing
jobs.
The weekly bump job committed fine but died on the push:

  fatal: could not read Username for 'https://github.com'

The checkout uses persist-credentials: false, so no git credential is
left in the work tree, and GH_TOKEN authenticates gh but not git. The
push therefore had no credential at all and never reached gh pr create.

Feed git the same PAT through GIT_ASKPASS. The helper is a temp file
that reads the token from the environment, so the value stays out of
argv and out of .git/config -- unlike an inline x-access-token@ push URL,
which persists the token into the repo config.
tools/bump-versions.sh queries the angie release API unauthenticated. The
CI runners share an egress IP whose 60/hr anonymous allowance is routinely
exhausted, so the call returns 403 and the job dies with a bare:

  curl: (22) The requested URL returned error: 403

Send GH_TOKEN when one is present, and fail with an explanatory message
instead of a raw curl error when the request cannot be made at all.

This was latent until the bump job started reaching step 4 -- it used to
fail earlier, at the push.
The bump branch is named after the date, so a second run on the same day
collides with the branch the first one created:

  ! [rejected] bump/versions-20260720 (non-fast-forward)

That makes any manual dispatch or post-failure retry fail, even though the
work itself succeeded. Force-push the bot-owned branch, and treat an
already-open PR for it as success rather than letting gh pr create fail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants