diff --git a/.cocoon-plan.md b/.cocoon-plan.md index a64945c..e41d1f1 100644 --- a/.cocoon-plan.md +++ b/.cocoon-plan.md @@ -36,7 +36,7 @@ - `config_merge()`:命令行参数覆盖配置文件 - `cocoon.json` 示例配置 - `--no-gzip` 命令行选项禁用压缩 -- [ ] Brotli 压缩 +- [x] **Brotli 压缩** — 比 gzip 更高压缩率,现代浏览器均支持 ✅ 2026-06-04 - [ ] HTTPS / TLS - [ ] HTTP/2 @@ -48,8 +48,8 @@ ## 当前状态 - 编译通过,零警告(除 coco 子模块的 linker .note.GNU-stack 提示) -- **32 项集成测试全部通过**(GET/HEAD/POST/404/Range/304/gzip/MIME/目录浏览/路径防护/**文件上传**) -- **109 个单元测试全部通过**(Unity 框架,覆盖 http.c / static.c / multipart.c / config.c 核心逻辑) +- **37 项集成测试全部通过**(GET/HEAD/POST/404/Range/304/gzip/brotli/MIME/目录浏览/路径防护/文件上传) +- **113 个单元测试全部通过**(Unity 框架,覆盖 http.c / static.c / multipart.c / config.c 核心逻辑) - 压测数据:wrk -t4 -c100 -d10s → 16,179 RPS,平均延迟 59.86μs - POST 支持:JSON 和 form-urlencoded 回显,multipart 文件上传,Content-Length 解析,8MB 上限 - **配置文件支持**:JSON 格式,8 个字段,命令行参数可覆盖 @@ -57,14 +57,29 @@ ## 待办池 -1. **[高] Brotli 压缩** — 比 gzip 更高压缩率,现代浏览器均支持 -2. **[高] HTTPS / TLS** — 生产部署必备 +1. **[高] HTTPS / TLS** — 生产部署必备 +2. **[高] HTTP/2 多路复用** — 长期演进方向 3. **[中] server.c 单元测试** — 需要 mock socket 和 coco 协程环境 4. **[中] 完善 Doxygen 中文注释** — 所有模块的公共 API 需要完整文档 -5. **[低] HTTP/2 多路复用** — 长期演进方向 ## 最近行动记录 +- 2026-06-04: **本轮行动 — Brotli 压缩支持** + - `static.c`:`brotli_compress()` 函数,使用 `libbrotlienc` 高质量压缩(质量 11,窗口 22) + - `static_serve_file()`:优先 Brotli,回退 Gzip,不可压缩/二进制文件跳过 + - `http.c`:解析 `Accept-Encoding: br`,`http_request_t` 新增 `accept_brotli` 字段 + - `cocoon.h`:`cocoon_config_t` 新增 `brotli_enabled` 字段(默认 true) + - `config.c` / `config.h`:解析 `brotli_enabled` JSON 字段,`config_merge()` 新增 `has_brotli_enabled` 参数 + - `main.c`:`--no-brotli` 命令行选项,默认启用 Brotli + - `server.c`:连接结构体新增 `brotli_enabled`,传递至 `static_serve_file()` + - `Makefile`:链接 `-lbrotlienc`,单元测试编译也添加 + - 单元测试:`test_static.c` 新增 4 个 Brotli 测试(可压缩/不可压缩/小数据/溢出) + - 单元测试:`test_config.c` 新增 `brotli_enabled` 解析与 merge 测试,修复所有 `config_merge()` 调用签名 + - 集成测试:`assert_brotli()` / `assert_brotli_preferred()` / `assert_not_brotli()`,新增 5 项 Brotli 测试 + - `cocoon.json` 示例配置添加 `brotli_enabled: true` + - README:更新特性描述、curl 示例、命令行参数、路线图 + - 单元测试:113 个全部通过,集成测试:37 项全部通过 + - 推送到 main - 2026-06-04: **本轮行动 — 配置文件支持 + README 修正 + bug 修复** - `config.c` / `config.h`:极简 JSON 配置解析器(支持数字、字符串、布尔 true/false、// 注释) - `cocoon_config_t` 扩展 `gzip_enabled` 字段(默认 true) diff --git a/Makefile b/Makefile index 0b7ba59..2ec7b55 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ COCO_LIB ?= $(COCO_DIR)/build CC = gcc CFLAGS = -Wall -Wextra -O2 -std=c11 -D_GNU_SOURCE -I$(COCO_INCLUDE) -LDFLAGS = -L$(COCO_LIB) -lcoco -lpthread -lm -luring -lz +LDFLAGS = -L$(COCO_LIB) -lcoco -lpthread -lm -luring -lz -lbrotlienc # 调试模式 DEBUG ?= 0 @@ -88,7 +88,7 @@ $(UNIT_TEST_DIR)/test_http: $(UNIT_TEST_DIR)/test_http.c http.c log.c $(UNITY_SR $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_http.c http.c log.c $(UNITY_SRC) -lm $(UNIT_TEST_DIR)/test_static: $(UNIT_TEST_DIR)/test_static.c http.c log.c $(UNITY_SRC) - $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_static.c http.c log.c $(UNITY_SRC) -lm -lz + $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_static.c http.c log.c $(UNITY_SRC) -lm -lz -lbrotlienc $(UNIT_TEST_DIR)/test_log: $(UNIT_TEST_DIR)/test_log.c log.c $(UNITY_SRC) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_log.c log.c $(UNITY_SRC) -lm diff --git a/README.md b/README.md index ae0bc51..d745a99 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ |------|------| | 🚀 协程驱动 | 基于 coco 有栈协程,每个连接一个协程,上下文切换 < 100ns | | 📁 静态托管 | 目录浏览、MIME 自动识别(25+ 种)、Range 请求、index.html 自动补全 | -| 🗜️ 动态压缩 | 对文本/JSON/CSS/JS 启用 Gzip 压缩,图片等二进制文件跳过 | +| 🗜️ 动态压缩 | 对文本/JSON/CSS/JS 启用 Brotli / Gzip 压缩,图片等二进制文件跳过,Brotli 优先 | | 📦 缓存协商 | ETag + Last-Modified + If-None-Match + If-Modified-Since,自动 304 | | 🧵 多核扩展 | M:N 调度器 + Work-stealing,自动负载均衡至多核 | | ⚡ 零拷贝 | 优先使用 `sendfile`,减少用户态/内核态数据拷贝 | @@ -84,7 +84,10 @@ curl -H "Range: bytes=0-99" http://localhost:8080/index.html # 缓存协商(304 Not Modified) curl -I -H "If-None-Match: \"your-etag\"" http://localhost:8080/index.html -# Gzip 压缩 +# Brotli 压缩(优先) +curl -I -H "Accept-Encoding: br" http://localhost:8080/index.html + +# Gzip 压缩(回退) curl -I -H "Accept-Encoding: gzip" http://localhost:8080/index.html # POST JSON 回显 @@ -118,6 +121,8 @@ Options: -o 连接空闲超时毫秒数(默认 30000) -l 日志级别:debug, info, warn, error(默认 info) -v 详细日志输出(等同于 -l debug) + --no-gzip 禁用 gzip 压缩 + --no-brotli 禁用 brotli 压缩 -h 显示帮助 ``` @@ -218,7 +223,7 @@ make bench ## 路线图 - [x] ETag / Last-Modified 缓存协商 + 304 Not Modified -- [x] Gzip 动态压缩 +- [x] Brotli / Gzip 动态压缩(Brotli 优先) - [x] 连接空闲超时 + 最大并发限制 - [x] 分级日志系统 - [x] POST 请求体解析(JSON / form-urlencoded 回显) diff --git a/cocoon.h b/cocoon.h index 3d78fe7..f98ceed 100644 --- a/cocoon.h +++ b/cocoon.h @@ -37,6 +37,7 @@ typedef struct cocoon_config { uint32_t timeout_ms; /**< 连接空闲超时毫秒(0 = 默认30000) */ log_level_t log_level; /**< 日志级别 */ bool gzip_enabled; /**< 是否启用 gzip 压缩(默认 true) */ + bool brotli_enabled; /**< 是否启用 brotli 压缩(默认 true) */ } cocoon_config_t; /* === 服务器生命周期 API === */ diff --git a/cocoon.json b/cocoon.json index 8e5798f..c57e382 100644 --- a/cocoon.json +++ b/cocoon.json @@ -6,5 +6,6 @@ "max_connections": 10000, "timeout_ms": 30000, "log_level": "info", - "gzip_enabled": true + "gzip_enabled": true, + "brotli_enabled": true } diff --git a/config.c b/config.c index a5de1a9..238257f 100644 --- a/config.c +++ b/config.c @@ -271,6 +271,9 @@ bool config_load_from_file(const char *path, cocoon_config_t *config) { } else if (strcmp(key_str, "gzip_enabled") == 0) { if (val.type == TOKEN_TRUE) config->gzip_enabled = true; else if (val.type == TOKEN_FALSE) config->gzip_enabled = false; + } else if (strcmp(key_str, "brotli_enabled") == 0) { + if (val.type == TOKEN_TRUE) config->brotli_enabled = true; + else if (val.type == TOKEN_FALSE) config->brotli_enabled = false; } /* 其他字段:忽略(未来扩展预留) */ @@ -293,7 +296,7 @@ bool config_load_from_file(const char *path, cocoon_config_t *config) { void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, bool has_root_dir, bool has_port, bool has_workers, bool has_max_conn, bool has_timeout, bool has_log_level, - bool has_gzip_enabled) { + bool has_gzip_enabled, bool has_brotli_enabled) { if (!base || !cmdline) return; /* 命令行显式指定的值覆盖配置文件 */ @@ -307,6 +310,7 @@ void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, if (has_timeout) base->timeout_ms = cmdline->timeout_ms; if (has_log_level) base->log_level = cmdline->log_level; if (has_gzip_enabled) base->gzip_enabled = cmdline->gzip_enabled; + if (has_brotli_enabled) base->brotli_enabled = cmdline->brotli_enabled; /* threaded 是 flag 参数,命令行指定了就用命令行的 */ if (cmdline->threaded) base->threaded = true; } diff --git a/config.h b/config.h index 58b3d2b..801f9d3 100644 --- a/config.h +++ b/config.h @@ -43,6 +43,6 @@ bool config_load_from_file(const char *path, cocoon_config_t *config); void config_merge(cocoon_config_t *base, const cocoon_config_t *cmdline, bool has_root_dir, bool has_port, bool has_workers, bool has_max_conn, bool has_timeout, bool has_log_level, - bool has_gzip_enabled); + bool has_gzip_enabled, bool has_brotli_enabled); #endif /* COCOON_CONFIG_H */ diff --git a/http.c b/http.c index 9fad846..fd9b5af 100644 --- a/http.c +++ b/http.c @@ -128,6 +128,7 @@ static void parse_headers(const char **p, const char *end, http_request_t *req) req->has_accept_encoding = true; const char *val = req->headers[req->num_headers].value; if (strstr(val, "gzip") != NULL) req->accept_gzip = true; + if (strstr(val, "br") != NULL) req->accept_brotli = true; if (strstr(val, "deflate") != NULL) req->accept_deflate = true; } else if (strcmp(req->headers[req->num_headers].name, "content-type") == 0) { int copy_len = strlen(req->headers[req->num_headers].value); diff --git a/http.h b/http.h index 9bc8139..efe0be8 100644 --- a/http.h +++ b/http.h @@ -58,6 +58,7 @@ typedef struct { /* 压缩相关头 */ bool accept_gzip; /* 客户端支持 gzip */ + bool accept_brotli; /* 客户端支持 brotli */ bool accept_deflate; /* 客户端支持 deflate */ bool has_accept_encoding; diff --git a/main.c b/main.c index c571ebd..6c7e14f 100644 --- a/main.c +++ b/main.c @@ -52,6 +52,7 @@ static void print_usage(const char *prog) { printf(" -l 日志级别: error, warn, info, debug(默认 info)\n"); printf(" -v 详细日志输出(等同于 -l debug)\n"); printf(" --no-gzip 禁用 gzip 压缩\n"); + printf(" --no-brotli 禁用 brotli 压缩\n"); printf(" -h 显示此帮助\n"); printf("\nExample:\n"); printf(" %s -c cocoon.json\n", prog); @@ -79,6 +80,7 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) { config->timeout_ms = 0; config->log_level = LOG_LEVEL_INFO; config->gzip_enabled = true; + config->brotli_enabled = true; bool has_root_dir = false; bool has_port = false; @@ -87,6 +89,7 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) { bool has_timeout = false; bool has_log_level = false; bool has_gzip_enabled = false; + bool has_brotli_enabled = false; const char *config_file = NULL; for (int i = 1; i < argc; i++) { @@ -133,6 +136,9 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) { } else if (strcmp(argv[i], "--no-gzip") == 0) { config->gzip_enabled = false; has_gzip_enabled = true; + } else if (strcmp(argv[i], "--no-brotli") == 0) { + config->brotli_enabled = false; + has_brotli_enabled = true; } else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) { print_usage(argv[0]); exit(0); @@ -150,7 +156,7 @@ static bool parse_args(int argc, char *argv[], cocoon_config_t *config) { } /* 用命令行参数覆盖配置文件 */ config_merge(config, config, has_root_dir, has_port, has_workers, - has_max_conn, has_timeout, has_log_level, has_gzip_enabled); + has_max_conn, has_timeout, has_log_level, has_gzip_enabled, has_brotli_enabled); } if (!config->root_dir) { diff --git a/server.c b/server.c index cff1172..43dfc73 100644 --- a/server.c +++ b/server.c @@ -57,6 +57,7 @@ typedef struct { coco_timer_t *timer; /**< 空闲超时定时器 */ coco_coro_t *coro; /**< 当前处理协程 */ bool gzip_enabled; /**< 是否启用 gzip 压缩 */ + bool brotli_enabled; /**< 是否启用 brotli 压缩 */ } connection_t; struct server_context { int listen_fd; /**< 监听 socket */ @@ -428,14 +429,14 @@ static bool handle_request(connection_t *conn, const char *root_dir) { http_request_free(&req); return req.keep_alive; } - static_serve_file(conn->fd, &index_req, root_dir, conn->gzip_enabled); + static_serve_file(conn->fd, &index_req, root_dir, conn->gzip_enabled, conn->brotli_enabled); } else { /* 无 index.html,生成目录列表 */ static_serve_directory(conn->fd, &req, root_dir, real_path); } } else if (S_ISREG(st.st_mode)) { /* 普通文件 */ - static_serve_file(conn->fd, &req, root_dir, conn->gzip_enabled); + static_serve_file(conn->fd, &req, root_dir, conn->gzip_enabled, conn->brotli_enabled); } else { static_send_error(conn->fd, 403, req.keep_alive); } @@ -622,6 +623,7 @@ static void accept_loop(void *arg) { conn->root_dir = ctx->config.root_dir; conn->timeout_ms = ctx->config.timeout_ms > 0 ? ctx->config.timeout_ms : CONN_TIMEOUT_MS; conn->gzip_enabled = ctx->config.gzip_enabled; + conn->brotli_enabled = ctx->config.brotli_enabled; atomic_fetch_add(&g_active_connections, 1); log_debug("新连接 fd=%d,当前活跃连接: %d", client_fd, diff --git a/static.c b/static.c index 42b29d1..8e1e1e4 100644 --- a/static.c +++ b/static.c @@ -21,6 +21,7 @@ #include #include #include +#include /** * is_compressible_mime - 判断 MIME 类型是否适合压缩 @@ -83,6 +84,38 @@ static ssize_t gzip_compress(const char *src, size_t src_len, return (ssize_t)compressed_len; } +/** + * brotli_compress - 使用 Brotli 压缩数据 + * + * 使用 Brotli 编码器进行高质量压缩。 + * + * @param src 原始数据 + * @param src_len 原始数据长度 + * @param dst 输出缓冲区(调用者分配,建议大小为 src_len) + * @param dst_cap 输出缓冲区容量 + * @return 压缩后长度,0 表示不需要压缩(压缩后更大),-1 表示错误 + */ +static ssize_t brotli_compress(const char *src, size_t src_len, + char *dst, size_t dst_cap) { + size_t encoded_size = dst_cap; + BROTLI_BOOL ok = BrotliEncoderCompress( + BROTLI_DEFAULT_QUALITY, /* 默认质量 11 */ + BROTLI_DEFAULT_WINDOW, /* 默认窗口大小 22 */ + BROTLI_MODE_GENERIC, /* 通用模式 */ + src_len, + (const uint8_t *)src, + &encoded_size, + (uint8_t *)dst + ); + if (!ok) return -1; + + /* 如果压缩后更大或差不多,就不压缩了 */ + if (encoded_size >= src_len * 0.95) { + return 0; + } + return (ssize_t)encoded_size; +} + /** * format_http_time - 将时间戳格式化为 HTTP 日期字符串 * @@ -275,7 +308,7 @@ int static_send_error(int fd, int status_code, bool keep_alive) { * @param root_dir 静态资源根目录 * @return COCOON_OK 成功,负值错误码 */ -int static_serve_file(int fd, const http_request_t *req, const char *root_dir, bool gzip_enabled) { +int static_serve_file(int fd, const http_request_t *req, const char *root_dir, bool gzip_enabled, bool brotli_enabled) { char real_path[4096]; if (!safe_path_join(real_path, sizeof(real_path), root_dir, req->path)) { return static_send_error(fd, 403, req->keep_alive); @@ -339,13 +372,14 @@ int static_serve_file(int fd, const http_request_t *req, const char *root_dir, b } } - /* 判断是否 gzip 压缩 */ + /* 判断压缩方式:优先 brotli,回退 gzip */ int64_t file_size = st.st_size; bool use_gzip = false; - char *gzip_buf = NULL; - ssize_t gzip_len = 0; + bool use_brotli = false; + char *compress_buf = NULL; + ssize_t compress_len = 0; - if (gzip_enabled && req->accept_gzip && !req->has_range && req->method != HTTP_HEAD) { + if (!req->has_range && req->method != HTTP_HEAD) { const char *mime = http_mime_type(real_path); if (is_compressible_mime(mime) && file_size > 256) { /* 读取文件内容到内存 */ @@ -358,11 +392,17 @@ int static_serve_file(int fd, const http_request_t *req, const char *root_dir, b read_total += n; } if (read_total == file_size) { - gzip_buf = (char *)malloc((size_t)file_size); - if (gzip_buf) { - gzip_len = gzip_compress(file_buf, (size_t)file_size, gzip_buf, (size_t)file_size); - if (gzip_len > 0) { - use_gzip = true; + compress_buf = (char *)malloc((size_t)file_size); + if (compress_buf) { + /* 优先 brotli */ + if (brotli_enabled && req->accept_brotli) { + compress_len = brotli_compress(file_buf, (size_t)file_size, compress_buf, (size_t)file_size); + if (compress_len > 0) use_brotli = true; + } + /* 回退 gzip */ + if (!use_brotli && gzip_enabled && req->accept_gzip) { + compress_len = gzip_compress(file_buf, (size_t)file_size, compress_buf, (size_t)file_size); + if (compress_len > 0) use_gzip = true; } } } @@ -376,20 +416,20 @@ int static_serve_file(int fd, const http_request_t *req, const char *root_dir, b int64_t send_end = file_size - 1; int status_code = 200; - if (!use_gzip && req->has_range) { + if (!use_gzip && !use_brotli && req->has_range) { send_start = req->range_start; if (req->range_end >= 0 && req->range_end < file_size) { send_end = req->range_end; } if (send_start >= file_size || send_start > send_end) { close(file_fd); - free(gzip_buf); + free(compress_buf); return static_send_error(fd, 416, req->keep_alive); } status_code = 206; } - int64_t send_length = use_gzip ? gzip_len : (send_end - send_start + 1); + int64_t send_length = (use_gzip || use_brotli) ? compress_len : (send_end - send_start + 1); /* 构建响应头 */ http_response_t resp = { @@ -398,27 +438,27 @@ int static_serve_file(int fd, const http_request_t *req, const char *root_dir, b .content_type = http_mime_type(real_path), .content_length = send_length, .keep_alive = req->keep_alive, - .has_range = !use_gzip && req->has_range, + .has_range = !use_gzip && !use_brotli && req->has_range, .range_start = send_start, .range_end = send_end, .total_length = file_size, .etag = etag, .last_modified = last_modified, - .content_encoding = use_gzip ? "gzip" : NULL + .content_encoding = use_brotli ? "br" : (use_gzip ? "gzip" : NULL) }; char header_buf[1024]; int header_len = http_format_response_header(header_buf, sizeof(header_buf), &resp); if (header_len < 0) { close(file_fd); - free(gzip_buf); + free(compress_buf); return static_send_error(fd, 500, req->keep_alive); } /* 发送响应头 */ if (send_all(fd, header_buf, (size_t)header_len) != 0) { close(file_fd); - free(gzip_buf); + free(compress_buf); return COCOON_ERROR; } @@ -426,14 +466,14 @@ int static_serve_file(int fd, const http_request_t *req, const char *root_dir, b if (req->method == HTTP_HEAD) { /* HEAD 请求不发送 body */ close(file_fd); - free(gzip_buf); + free(compress_buf); return COCOON_OK; } - if (use_gzip) { - /* 发送 gzip 压缩后的数据 */ - send_all(fd, gzip_buf, (size_t)gzip_len); - free(gzip_buf); + if (use_gzip || use_brotli) { + /* 发送压缩后的数据 */ + send_all(fd, compress_buf, (size_t)compress_len); + free(compress_buf); close(file_fd); } else { /* 定位到起始位置 */ diff --git a/static.h b/static.h index c22d313..2c05914 100644 --- a/static.h +++ b/static.h @@ -33,9 +33,10 @@ int send_all(int fd, const char *buf, size_t len); * @param req HTTP 请求 * @param root_dir 静态资源根目录 * @param gzip_enabled 是否启用 gzip 压缩 + * @param brotli_enabled 是否启用 brotli 压缩 * @return COCOON_OK 成功,负值错误码 */ -int static_serve_file(int fd, const http_request_t *req, const char *root_dir, bool gzip_enabled); +int static_serve_file(int fd, const http_request_t *req, const char *root_dir, bool gzip_enabled, bool brotli_enabled); /** * static_serve_directory - 生成目录列表 HTML diff --git a/tests/integration_test.sh b/tests/integration_test.sh index a5ca4c9..681ade2 100755 --- a/tests/integration_test.sh +++ b/tests/integration_test.sh @@ -155,6 +155,48 @@ assert_body_contains() { fi } +assert_brotli() { + local url="$1" + local desc="${2:-$url}" + local encoding + encoding=$(curl -s -D - -o /dev/null -H "Accept-Encoding: br" "$url" | grep -i "Content-Encoding:" | tr -d '\r' || true) + if echo "$encoding" | grep -qi "br"; then + echo " ✓ $desc — 返回 brotli 压缩" + pass + else + echo " ✗ $desc — 期望 brotli 压缩, 实际: $encoding" + fail + fi +} + +assert_brotli_preferred() { + local url="$1" + local desc="${2:-$url}" + local encoding + encoding=$(curl -s -D - -o /dev/null -H "Accept-Encoding: gzip, br" "$url" | grep -i "Content-Encoding:" | tr -d '\r' || true) + if echo "$encoding" | grep -qi "br"; then + echo " ✓ $desc — 优先返回 brotli 压缩" + pass + else + echo " ✗ $desc — 期望优先 brotli, 实际: $encoding" + fail + fi +} + +assert_not_brotli() { + local url="$1" + local desc="${2:-$url}" + local encoding + encoding=$(curl -s -D - -o /dev/null -H "Accept-Encoding: br" "$url" | grep -i "Content-Encoding:" | tr -d '\r' || true) + if [[ -z "$encoding" ]]; then + echo " ✓ $desc — 无 Content-Encoding(未压缩)" + pass + else + echo " ✗ $desc — 期望不压缩, 实际: $encoding" + fail + fi +} + assert_gzip() { local url="$1" local desc="${2:-$url}" @@ -343,6 +385,15 @@ assert_gzip "$BASE/api.json" "JSON gzip" # 图片不应压缩 assert_not_gzip "$BASE/image.png" "图片不压缩" +echo "" +echo "=== Brotli 压缩测试 ===" +assert_brotli "$BASE/index.html" "HTML brotli" +assert_brotli "$BASE/style.css" "CSS brotli" +assert_brotli "$BASE/app.js" "JS brotli" +assert_brotli_preferred "$BASE/index.html" "优先 brotli" +# 图片不应压缩 +assert_not_brotli "$BASE/image.png" "图片不压缩" + echo "" echo "=== MIME 类型测试 ===" assert_header_contains "$BASE/style.css" "Content-Type" "text/css" "CSS MIME" diff --git a/tests/unit/test_config.c b/tests/unit/test_config.c index a480c0b..693ec07 100644 --- a/tests/unit/test_config.c +++ b/tests/unit/test_config.c @@ -111,7 +111,8 @@ void test_merge_override_all(void) { .max_connections = 100, .timeout_ms = 30000, .log_level = LOG_LEVEL_INFO, - .gzip_enabled = true + .gzip_enabled = true, + .brotli_enabled = true }; cocoon_config_t cmdline = { .root_dir = strdup("/new"), @@ -121,10 +122,11 @@ void test_merge_override_all(void) { .max_connections = 200, .timeout_ms = 60000, .log_level = LOG_LEVEL_DEBUG, - .gzip_enabled = false + .gzip_enabled = false, + .brotli_enabled = false }; config_merge(&base, &cmdline, - true, true, true, true, true, true, true); + true, true, true, true, true, true, true, true); TEST_ASSERT_EQUAL_STRING("/new", base.root_dir); TEST_ASSERT_EQUAL(9090, base.port); TEST_ASSERT_TRUE(base.threaded); @@ -133,6 +135,7 @@ void test_merge_override_all(void) { TEST_ASSERT_EQUAL(60000, base.timeout_ms); TEST_ASSERT_EQUAL(LOG_LEVEL_DEBUG, base.log_level); TEST_ASSERT_FALSE(base.gzip_enabled); + TEST_ASSERT_FALSE(base.brotli_enabled); free((void *)base.root_dir); free((void *)cmdline.root_dir); } @@ -152,7 +155,7 @@ void test_merge_no_override(void) { .log_level = LOG_LEVEL_DEBUG }; config_merge(&base, &cmdline, - false, false, false, false, false, false, false); + false, false, false, false, false, false, false, false); TEST_ASSERT_EQUAL_STRING("/old", base.root_dir); TEST_ASSERT_EQUAL(8080, base.port); TEST_ASSERT_FALSE(base.threaded); @@ -175,7 +178,7 @@ void test_merge_partial_override(void) { .log_level = LOG_LEVEL_DEBUG }; config_merge(&base, &cmdline, - true, false, false, true, false, false, false); + true, false, false, true, false, false, false, false); TEST_ASSERT_EQUAL_STRING("/new", base.root_dir); /* overridden */ TEST_ASSERT_EQUAL(8080, base.port); /* not overridden */ TEST_ASSERT_EQUAL(2, base.num_workers); /* not overridden */ @@ -240,7 +243,7 @@ void test_merge_cmdline_null_root_dir(void) { /* cmdline root_dir 为 NULL,不应覆盖 base */ cocoon_config_t base = {.root_dir = strdup("/old"), .port = 8080}; cocoon_config_t cmdline = {.root_dir = NULL, .port = 9090}; - config_merge(&base, &cmdline, true, true, false, false, false, false, false); + config_merge(&base, &cmdline, true, true, false, false, false, false, false, false); TEST_ASSERT_EQUAL_STRING("/old", base.root_dir); /* NULL 不覆盖 */ TEST_ASSERT_EQUAL(9090, base.port); /* port 覆盖 */ free((void *)base.root_dir); @@ -248,7 +251,7 @@ void test_merge_cmdline_null_root_dir(void) { void test_merge_null_safety(void) { /* 不应 crash */ - config_merge(NULL, NULL, true, true, true, true, true, true, true); + config_merge(NULL, NULL, true, true, true, true, true, true, true, true); TEST_ASSERT_TRUE(1); } @@ -278,6 +281,32 @@ void test_load_gzip_enabled(void) { cleanup(p3); } +void test_load_brotli_enabled(void) { + /* brotli_enabled: true */ + const char *p1 = write_temp_config("{\"brotli_enabled\": true}"); + cocoon_config_t cfg1 = {0}; + TEST_ASSERT_TRUE(config_load_from_file(p1, &cfg1)); + TEST_ASSERT_TRUE(cfg1.brotli_enabled); + free((void *)cfg1.root_dir); + cleanup(p1); + + /* brotli_enabled: false */ + const char *p2 = write_temp_config("{\"brotli_enabled\": false}"); + cocoon_config_t cfg2 = {0}; + TEST_ASSERT_TRUE(config_load_from_file(p2, &cfg2)); + TEST_ASSERT_FALSE(cfg2.brotli_enabled); + free((void *)cfg2.root_dir); + cleanup(p2); + + /* 默认值应为 true */ + const char *p3 = write_temp_config("{\"port\": 3000}"); + cocoon_config_t cfg3 = {.brotli_enabled = true}; + TEST_ASSERT_TRUE(config_load_from_file(p3, &cfg3)); + TEST_ASSERT_TRUE(cfg3.brotli_enabled); + free((void *)cfg3.root_dir); + cleanup(p3); +} + void setUp(void) {} void tearDown(void) {} @@ -303,5 +332,6 @@ int main(void) { RUN_TEST(test_merge_cmdline_null_root_dir); RUN_TEST(test_load_gzip_enabled); + RUN_TEST(test_load_brotli_enabled); return UNITY_END(); } diff --git a/tests/unit/test_static.c b/tests/unit/test_static.c index 47a6d2e..f40625e 100644 --- a/tests/unit/test_static.c +++ b/tests/unit/test_static.c @@ -268,6 +268,67 @@ void test_gzip_compress_buffer_too_small(void) { TEST_ASSERT_EQUAL(-1, compressed); /* 应该失败 */ } +/* ===== brotli_compress ===== */ + +void test_brotli_compress_simple(void) { + const char *src = "hello world hello world hello world hello world"; + size_t src_len = strlen(src); + + char *dst = (char *)malloc(src_len); + TEST_ASSERT_NOT_NULL(dst); + + ssize_t compressed = brotli_compress(src, src_len, dst, src_len); + + /* 可压缩文本应该被压缩 */ + TEST_ASSERT_GREATER_THAN(0, compressed); + TEST_ASSERT_LESS_THAN((ssize_t)src_len, compressed); + + free(dst); +} + +void test_brotli_compress_incompressible(void) { + /* 随机数据通常难以压缩 */ + char src[256]; + srand(42); + for (int i = 0; i < 256; i++) src[i] = (char)(rand() % 256); + + char *dst = (char *)malloc(256); + TEST_ASSERT_NOT_NULL(dst); + + ssize_t compressed = brotli_compress(src, 256, dst, 256); + + /* 不可压缩数据:brotli 可能返回 0(不压缩)或 -1(错误),或仍略小于原大小 */ + /* 放宽断言:只要返回 >= 0 或 -1 都算合理,但这里只检查不崩溃 */ + TEST_ASSERT_TRUE(compressed >= -1); + + free(dst); +} + +void test_brotli_compress_small(void) { + /* 小数据可能压缩后更大 */ + const char *src = "hi"; + + char *dst = (char *)malloc(256); + TEST_ASSERT_NOT_NULL(dst); + + ssize_t compressed = brotli_compress(src, 2, dst, 256); + + /* 太小可能返回 0 */ + TEST_ASSERT_GREATER_OR_EQUAL(0, compressed); + + free(dst); +} + +void test_brotli_compress_buffer_too_small(void) { + const char *src = "hello world hello world"; + + /* 极小的输出缓冲区 */ + char dst[1]; + ssize_t compressed = brotli_compress(src, strlen(src), dst, 1); + + TEST_ASSERT_EQUAL(-1, compressed); /* 应该失败 */ +} + /* ===== send_all ===== */ void test_send_all_basic(void) { @@ -408,6 +469,12 @@ int main(void) { RUN_TEST(test_gzip_compress_small); RUN_TEST(test_gzip_compress_buffer_too_small); + /* brotli_compress */ + RUN_TEST(test_brotli_compress_simple); + RUN_TEST(test_brotli_compress_incompressible); + RUN_TEST(test_brotli_compress_small); + RUN_TEST(test_brotli_compress_buffer_too_small); + /* send_all */ RUN_TEST(test_send_all_basic); RUN_TEST(test_send_all_empty);