From 513e733ee4f0585a38520ff924d7c59d49e68652 Mon Sep 17 00:00:00 2001 From: kjdev Date: Thu, 13 Aug 2026 09:43:16 +0900 Subject: [PATCH 1/3] feat: support negation tokens in output_compression_exclude_types A token prefixed with `!` negates a match against the built-in MIME exclusion list, allowing individual entries to be re-enabled for compression. Within the same list, when multiple tokens match the same MIME type, the last one listed wins, positive or negative (gitignore-style precedence). The built-in list is only consulted as a fallback when the user-configured list produces no definitive match. --- brotli.c | 49 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/brotli.c b/brotli.c index 5279acc..2229001 100644 --- a/brotli.c +++ b/brotli.c @@ -382,7 +382,10 @@ static int php_brotli_output_encoding(void) return BROTLI_G(compression_coding); } -static int php_brotli_output_mimetype_excluded(const char *exclude) +/* returns 1 if a positive token matches, -1 if a negative (!) token + * matches, 0 if no token matches; when multiple tokens in the same + * list match, the last one listed wins (gitignore-style precedence) */ +static int php_brotli_output_mimetype_match(const char *list) { #if defined(COMPILE_DL_BROTLI) && defined(ZTS) ZEND_TSRMLS_CACHE_UPDATE(); @@ -390,8 +393,9 @@ static int php_brotli_output_mimetype_excluded(const char *exclude) const char *mimetype = SG(sapi_headers).mimetype; const char *p, *end; size_t mimetype_len; + int last_match = 0; - if (!mimetype || !*mimetype || !exclude || !*exclude) { + if (!mimetype || !*mimetype || !list || !*list) { return 0; } @@ -400,10 +404,11 @@ static int php_brotli_output_mimetype_excluded(const char *exclude) end++; } mimetype_len = end - mimetype; - p = exclude; + p = list; while (*p) { size_t token_len; + zend_bool negated = 0; while (*p == ',' || *p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') { p++; @@ -416,26 +421,50 @@ static int php_brotli_output_mimetype_excluded(const char *exclude) token_len = end - p; + if (token_len > 0 && *p == '!') { + negated = 1; + p++; + token_len--; + } + if (token_len > 0) { + zend_bool matched = 0; + if (token_len >= 2 && p[token_len - 2] == '/' && p[token_len - 1] == '*') { size_t prefix_len = token_len - 1; if (mimetype_len >= prefix_len && !strncasecmp(mimetype, p, prefix_len)) { - return 1; + matched = 1; } } - if (mimetype_len == token_len && + if (!matched && mimetype_len == token_len && !strncasecmp(mimetype, p, token_len)) { - return 1; + matched = 1; + } + + if (matched) { + last_match = negated ? -1 : 1; } } p = end; } - return 0; + return last_match; +} + +static int php_brotli_output_mimetype_excluded(void) +{ + int result = php_brotli_output_mimetype_match( + BROTLI_G(output_compression_exclude_types)); + + if (result != 0) { + return result > 0; + } + + return php_brotli_output_mimetype_match(BROTLI_MIMETYPE_EXCLUDE) > 0; } static zend_string *php_brotli_output_handler_load_dict(php_brotli_context *ctx) @@ -559,11 +588,7 @@ static int php_brotli_output_handler(void **handler_context, php_brotli_context *ctx = *(php_brotli_context **)handler_context; if ((output_context->op & PHP_OUTPUT_HANDLER_START) - && ( - php_brotli_output_mimetype_excluded(BROTLI_MIMETYPE_EXCLUDE) - || - php_brotli_output_mimetype_excluded(BROTLI_G(output_compression_exclude_types)) - )) { + && php_brotli_output_mimetype_excluded()) { return FAILURE; } From b62524204670f9e070609b47313ecd267ef70dfa Mon Sep 17 00:00:00 2001 From: kjdev Date: Thu, 13 Aug 2026 09:43:22 +0900 Subject: [PATCH 2/3] test: add tests for exclude_types negation tokens Cover the default built-in exclusion, overriding it with a negation token, and the last-matching-token-wins precedence when a positive and a negative token in the same list both match, in either order. --- tests/ob_exclude_005.phpt | 20 ++++++++++++++++++++ tests/ob_exclude_006.phpt | 24 ++++++++++++++++++++++++ tests/ob_exclude_007.phpt | 24 ++++++++++++++++++++++++ tests/ob_exclude_008.phpt | 21 +++++++++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 tests/ob_exclude_005.phpt create mode 100644 tests/ob_exclude_006.phpt create mode 100644 tests/ob_exclude_007.phpt create mode 100644 tests/ob_exclude_008.phpt diff --git a/tests/ob_exclude_005.phpt b/tests/ob_exclude_005.phpt new file mode 100644 index 0000000..66e9176 --- /dev/null +++ b/tests/ob_exclude_005.phpt @@ -0,0 +1,20 @@ +--TEST-- +brotli.output_compression_exclude_types built-in exclusion applies by default +--SKIPIF-- + +--GET-- +a=b +--INI-- +brotli.output_compression=1 +--ENV-- +HTTP_ACCEPT_ENCODING=br +--FILE-- + +--EXPECT-- +hi diff --git a/tests/ob_exclude_006.phpt b/tests/ob_exclude_006.phpt new file mode 100644 index 0000000..b011317 --- /dev/null +++ b/tests/ob_exclude_006.phpt @@ -0,0 +1,24 @@ +--TEST-- +brotli.output_compression_exclude_types negation token overrides built-in exclusion +--SKIPIF-- + +--GET-- +a=b +--INI-- +brotli.output_compression=1 +brotli.output_compression_exclude_types="!image/png" +--ENV-- +HTTP_ACCEPT_ENCODING=br +--FILE-- + +--EXPECT_EXTERNAL-- +files/ob_hi.br +--EXPECTHEADERS-- +Content-Encoding: br +Vary: Accept-Encoding diff --git a/tests/ob_exclude_007.phpt b/tests/ob_exclude_007.phpt new file mode 100644 index 0000000..7fe5dfb --- /dev/null +++ b/tests/ob_exclude_007.phpt @@ -0,0 +1,24 @@ +--TEST-- +brotli.output_compression_exclude_types later negation token wins over earlier positive match +--SKIPIF-- + +--GET-- +a=b +--INI-- +brotli.output_compression=1 +brotli.output_compression_exclude_types="image/*,!image/png" +--ENV-- +HTTP_ACCEPT_ENCODING=br +--FILE-- + +--EXPECT_EXTERNAL-- +files/ob_hi.br +--EXPECTHEADERS-- +Content-Encoding: br +Vary: Accept-Encoding diff --git a/tests/ob_exclude_008.phpt b/tests/ob_exclude_008.phpt new file mode 100644 index 0000000..76818a1 --- /dev/null +++ b/tests/ob_exclude_008.phpt @@ -0,0 +1,21 @@ +--TEST-- +brotli.output_compression_exclude_types later positive token wins over earlier negation +--SKIPIF-- + +--GET-- +a=b +--INI-- +brotli.output_compression=1 +brotli.output_compression_exclude_types="!image/png,image/*" +--ENV-- +HTTP_ACCEPT_ENCODING=br +--FILE-- + +--EXPECT-- +hi From 2ca9526f68deb509e1fd7d4ce289096689da42af Mon Sep 17 00:00:00 2001 From: kjdev Date: Thu, 13 Aug 2026 09:43:34 +0900 Subject: [PATCH 3/3] docs: document negation tokens for exclude_types Explain the `!` token for re-enabling individual entries from the built-in exclusion list, and the last-match-wins precedence rule (gitignore-style: later tokens in the same list override earlier ones, whether positive or negative). --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 6980371..6d140b4 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,33 @@ brotli.output\_compression\_dict | "" | PHP\_INI\_ALL This is useful for already-compressed binary formats where additional Brotli compression usually provides little benefit. + A token prefixed with `!` negates a match: it makes the extension + compress that MIME type even if it is in the built-in exclusion list. + Negation also supports exact matches and `type/*` wildcards. Since + `!` is a special character in php.ini, values that use it must be + quoted. + + ```ini + brotli.output_compression_exclude_types="!image/png" + ``` + + Within the same list, if a MIME type matches multiple tokens, the + last matching token wins, regardless of whether it is positive or + negative (the same precedence rule `.gitignore` uses). This allows + patterns such as: + + ```ini + brotli.output_compression_exclude_types="image/*,!image/png" + ``` + + which excludes all `image/*` types except `image/png`, since the + negation is listed after the wildcard. + + Negation only applies within this setting: if it doesn't produce a + definitive match for the current MIME type, the built-in list is + still consulted. The built-in list's contents can be seen in the + phpinfo() output. + * brotli.output\_compression\_dict _string_ Specifies the path to the compressed dictionary file to be