Don't compress a response whose handler already set Content-Encoding (#2575)

* Don't compress a response whose handler already set Content-Encoding

* Don't compress a pre-encoded response served from a file

The guard that stands down when a response already names a content coding
covered the responses that settle their coding in `apply_ranges()`, but a
file-backed one settles it in `static_file_encoding()`, which asked the
content-type overload and so never saw the field. With static file
compression enabled, a mount point naming the coding for a tree of
build-time compressed assets, and a handler setting the field on a
`set_file_content()` response, both had their stored bytes compressed a
second time and a second `Content-Encoding` field line appended.

A file-backed response has not been given a content type by the time its
coding is decided, which is the only reason it could not go through
`encoding_type()`. It takes the type as an argument now, so both paths share
the one guard instead of carrying a copy each.

`Response::content_encoding_` becomes `content_coding_`, after what it
holds. It names the coding chosen for the body, which is what its own
comment already called it, while the old name read as the value of the
`Content-Encoding` field whose presence is exactly what forces the coding to
`None`.

README gains the behaviour, including the part that stays with the handler:
`Vary` is added only to a coding the server chose, so a handler that picks a
representation from `Accept-Encoding` has to add the field itself.

---------

Co-authored-by: yhirose <yuji.hirose.bug@gmail.com>
This commit is contained in:
Jhen-Jie Hong
2026-08-31 20:44:12 -04:00
committed by GitHub
co-authored by yhirose
parent 9d6a7ee2c1
commit 7d53a31d23
3 changed files with 209 additions and 21 deletions
+151
View File
@@ -8910,12 +8910,24 @@ protected:
}
int start(const std::function<void(Server &)> &configure = nullptr) {
// Serves the same tree as a directory of build-time compressed assets
// would be served: the mount point names the coding the files are already
// stored in. Registered before "/" so it is the one that matches.
svr_.set_mount_point("/pre-encoded", "./www",
{{"Content-Encoding", "gzip"}});
svr_.set_mount_point("/", "./www");
svr_.Get("/file_content", [](const Request & /*req*/, Response &res) {
res.set_file_content("./www/dir/index.html", "text/html");
});
svr_.Get("/pre-encoded-file-content",
[](const Request & /*req*/, Response &res) {
res.set_header("Content-Encoding", "gzip");
res.set_file_content("./www/dir/index.html", "text/html");
});
svr_.Get("/streamed", [](const Request & /*req*/, Response &res) {
res.set_content_provider(
6, "text/plain",
@@ -9021,6 +9033,47 @@ TEST_F(StaticFileCompressionTest, FileContent) {
EXPECT_EQ(104U, res->body.size());
}
// A handler that serves an already-encoded file names the coding itself. The
// file-backed path decided its coding from Accept-Encoding and the content
// type alone, so it compressed the stored bytes a second time and appended a
// second Content-Encoding field line.
TEST_F(StaticFileCompressionTest, PreEncodedFileContentIsNotCompressedAgain) {
auto port = start(enable_without_floor);
Client cli(HOST, port);
cli.set_decompress(false);
auto res = cli.Get("/pre-encoded-file-content",
Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1U, res->get_header_value_count("Content-Encoding"));
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
// Vary belongs to a coding the server chose, and it chose none here.
EXPECT_FALSE(res->has_header("Vary"));
// The file travels exactly as it is stored on disk.
EXPECT_EQ(104U, res->body.size());
}
// The 1MB file clears the default floor, so this one runs the configuration a
// deployment would actually have.
TEST_F(StaticFileCompressionTest, PreEncodedMountPointIsNotCompressedAgain) {
auto port = start(enable);
Client cli(HOST, port);
cli.set_decompress(false);
auto res =
cli.Get("/pre-encoded/dir/1MB.txt", Headers{{"Accept-Encoding", "gzip"}});
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1U, res->get_header_value_count("Content-Encoding"));
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_FALSE(res->has_header("Vary"));
EXPECT_EQ(1048576U, res->body.size());
EXPECT_EQ("1048576", res->get_header_value("Content-Length"));
}
TEST_F(StaticFileCompressionTest, Head) {
auto port = start(enable);
@@ -12616,6 +12669,104 @@ TEST(ContentEncodingTest, KnownEncodingWithoutSupportIsReported) {
}
}
#ifdef CPPHTTPLIB_ZLIB_SUPPORT
// A handler serving pre-compressed content (e.g. build-time gzipped static
// assets) sets Content-Encoding itself. The server used to pick a coding from
// Accept-Encoding and the content type alone, gzipping the already-gzipped
// body a second time and appending a second Content-Encoding field line, so
// clients that decode one coding per listed value handed back raw gzip bytes.
TEST(ContentEncodingTest, PreEncodedResponseIsNotCompressedAgain) {
const std::string gzipped(GZIPPED_HELLO_WORLD, sizeof(GZIPPED_HELLO_WORLD));
Server svr;
// text/plain is a compressible type, so only the pre-set Content-Encoding
// keeps the server from applying a coding of its own.
svr.Get("/pre-gzipped", [&](const Request & /*req*/, Response &res) {
res.set_content(gzipped, "text/plain");
res.set_header("Content-Encoding", "gzip");
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
Headers headers = {{"Accept-Encoding", "gzip"}};
auto res = cli.Get("/pre-gzipped", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1U, res->get_header_value_count("Content-Encoding"));
EXPECT_EQ("Hello World!", res->body);
}
// A chunked provider settles its coding where the headers are written and
// reuses it at write time. Both ends of that have to hold: a handler's own
// Content-Encoding suppresses the coding, and a response without one is still
// compressed as it always was.
TEST(ContentEncodingTest, PreEncodedChunkedResponseIsNotCompressedAgain) {
const std::string gzipped(GZIPPED_HELLO_WORLD, sizeof(GZIPPED_HELLO_WORLD));
const std::string plain = "Hello World! Hello World! Hello World!";
Server svr;
svr.Get("/pre-gzipped", [&](const Request & /*req*/, Response &res) {
res.set_header("Content-Encoding", "gzip");
res.set_chunked_content_provider(
"text/plain", [gzipped](size_t /*offset*/, DataSink &sink) {
sink.write(gzipped.data(), gzipped.size());
sink.done();
return true;
});
});
svr.Get("/negotiated", [&](const Request & /*req*/, Response &res) {
res.set_chunked_content_provider(
"text/plain", [plain](size_t /*offset*/, DataSink &sink) {
sink.write(plain.data(), plain.size());
sink.done();
return true;
});
});
auto port = svr.bind_to_any_port(HOST);
thread t = thread([&]() { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
Client cli(HOST, port);
Headers headers = {{"Accept-Encoding", "gzip"}};
{
auto res = cli.Get("/pre-gzipped", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ(1U, res->get_header_value_count("Content-Encoding"));
EXPECT_EQ("Hello World!", res->body);
}
{
auto res = cli.Get("/negotiated", headers);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status);
EXPECT_EQ("gzip", res->get_header_value("Content-Encoding"));
EXPECT_EQ(plain, res->body);
}
}
#endif
// RFC 9110 Section 5.3: a Content-Encoding split over several field lines is
// the same message as the comma-joined one, so both have to be read the same
// way. Reading only the first line made "gzip" followed by "gzip" look like a