feat(http2): 添加 HTTP/2 目录浏览支持
- 新增 http2_serve_directory() 函数,生成 HTML 目录列表页面 - 目录无 index.html 时自动返回目录浏览(与 HTTP/1.1 行为一致) - 支持 HTML 转义、文件大小格式化、修改时间显示、上级目录链接 - 支持 HEAD 请求(不发送 body) - 新增 2 项集成测试:HTTPS h2 目录浏览 + h2c 目录浏览 - 54 项集成测试全部通过,35 个单元测试全部通过
This commit is contained in:
parent
a93e9f32f5
commit
606c7b6b4e
@ -34,8 +34,9 @@
|
|||||||
- [x] **配置文件支持** — JSON 配置替代纯命令行 ✅ 2026-06-04
|
- [x] **配置文件支持** — JSON 配置替代纯命令行 ✅ 2026-06-04
|
||||||
- [x] **Brotli 压缩** — 比 gzip 更高压缩率 ✅ 2026-06-04
|
- [x] **Brotli 压缩** — 比 gzip 更高压缩率 ✅ 2026-06-04
|
||||||
- [x] **HTTPS / TLS** — OpenSSL Memory BIO 集成,支持命令行与配置文件启用 ✅ 2026-06-04
|
- [x] **HTTPS / TLS** — OpenSSL Memory BIO 集成,支持命令行与配置文件启用 ✅ 2026-06-04
|
||||||
- [x] **HTTP/2** — nghttp2 完整实现(TLS ALPN 协商 + 静态文件服务 + 缓存)✅ 2026-06-04
|
- [x] **HTTP/2** — nghttp2 完整实现(TLS ALPN 协商 + 静态文件服务 + 缓存 + 目录浏览)✅ 2026-06-04
|
||||||
- [x] **h2c 升级支持** — 明文 HTTP/2(PRI 魔术字直接连接 + Upgrade: h2c 协商)✅ 2026-06-04
|
- [x] **h2c 升级支持** — 明文 HTTP/2(PRI 魔术字直接连接 + Upgrade: h2c 协商)✅ 2026-06-04
|
||||||
|
- [x] **HTTP/2 目录浏览** — 目录无 index.html 时返回目录列表 ✅ 2026-06-04
|
||||||
- [ ] Windows 兼容性
|
- [ ] Windows 兼容性
|
||||||
|
|
||||||
### Phase 4 — 生态
|
### Phase 4 — 生态
|
||||||
@ -46,33 +47,33 @@
|
|||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
- 编译通过,零警告(除 coco 子模块的 linker .note.GNU-stack 提示)
|
- 编译通过,零警告(除 coco 子模块的 linker .note.GNU-stack 提示)
|
||||||
- **52 项集成测试全部通过**(新增 4 项 h2c 测试:prior knowledge、Upgrade 协商、404 ×2)
|
- **54 项集成测试全部通过**(新增 2 项 HTTP/2 目录浏览测试)
|
||||||
- **35 个单元测试全部通过**(Unity 框架)
|
- **35 个单元测试全部通过**(Unity 框架)
|
||||||
- 压测数据:wrk -t4 -c100 -d10s → 16,179 RPS,平均延迟 59.86μs
|
- 压测数据:wrk -t4 -c100 -d10s → 16,179 RPS,平均延迟 59.86μs
|
||||||
- POST 支持:JSON 和 form-urlencoded 回显,multipart 文件上传,Content-Length 解析,8MB 上限
|
- POST 支持:JSON 和 form-urlencoded 回显,multipart 文件上传,Content-Length 解析,8MB 上限
|
||||||
- **配置文件支持**:JSON 格式,8 个字段,命令行参数可覆盖
|
- **配置文件支持**:JSON 格式,8 个字段,命令行参数可覆盖
|
||||||
- **TLS/HTTPS**:OpenSSL 3.0 Memory BIO + coco 协程集成,自签名证书支持,ALPN 协商 h2/http1.1
|
- **TLS/HTTPS**:OpenSSL 3.0 Memory BIO + coco 协程集成,自签名证书支持,ALPN 协商 h2/http1.1
|
||||||
- **HTTP/2**:
|
- **HTTP/2**:完整功能(静态文件服务、目录浏览、缓存协商、压缩、HEAD 请求)
|
||||||
- 基于 nghttp2 的完整会话管理(431 行)
|
- **h2c**:明文 HTTP/2 支持(prior knowledge + Upgrade 协商)
|
||||||
- TLS ALPN 自动协商 h2
|
|
||||||
- 静态文件服务(GET/HEAD、路径安全、ETag、304 Not Modified)
|
|
||||||
- HEAD 请求正确处理(无 body)
|
|
||||||
- 目录自动 fallback 到 index.html
|
|
||||||
- 路径遍历防护(.. 检查)
|
|
||||||
- gzip/brotli 压缩支持(HTTP/2 响应中优先 brotli)
|
|
||||||
- 文件内容通过 nghttp2_data_provider 流式传输
|
|
||||||
- 修复 send_callback 返回值 bug(send_all 返回 0 而非已发送字节数)
|
|
||||||
- 修复 :path 伪头长度解析 bug(4→5)和路径空终止问题
|
|
||||||
|
|
||||||
## 待办池
|
## 待办池
|
||||||
|
|
||||||
1. **[中] Windows 兼容性** — 平台抽象层(platform.h / platform.c),sendfile 替代方案,io_uring 替代方案
|
1. **[中] Windows 兼容性** — 平台抽象层(platform.h / platform.c),sendfile 替代方案,io_uring 替代方案
|
||||||
2. **[中] Doxygen 中文注释** — 所有模块的公共 API 需要完整文档(http2.c 已部分完成)
|
2. **[中] Doxygen 中文注释** — 所有模块的公共 API 需要完整文档(http2.c 已部分完成)
|
||||||
3. **[低] 检查远程更新内容** — 上游有 new commits,需 review 9041d8d
|
3. **[低] 检查远程更新内容** — 上游有 new commits,需 review 9041d8d
|
||||||
4. **[低] HTTP/2 目录浏览** — 目前返回 404,可考虑支持目录列表
|
|
||||||
|
|
||||||
## 最近行动记录
|
## 最近行动记录
|
||||||
|
|
||||||
|
- **2026-06-04: 本轮行动 — HTTP/2 目录浏览支持**
|
||||||
|
- http2.c: 新增 `http2_serve_directory()` 函数,生成 HTML 目录列表页面
|
||||||
|
- http2.c: 在 `http2_serve_static()` 中检测目录无 index.html 时调用目录浏览
|
||||||
|
- http2.c: 目录浏览支持 HTML 转义、文件大小格式化、修改时间显示、上级目录链接
|
||||||
|
- http2.c: 目录浏览支持 HEAD 请求(不发送 body)
|
||||||
|
- http2.c: 添加 `#include <dirent.h>` 头文件
|
||||||
|
- tests/integration_test.sh: 新增 2 项 HTTP/2 目录浏览集成测试(HTTPS h2 + h2c)
|
||||||
|
- 编译通过,零警告
|
||||||
|
- **54 项集成测试全部通过,35 个单元测试全部通过**
|
||||||
|
- 推送到 main
|
||||||
- **2026-06-04: 本轮行动 — h2c 升级支持(明文 HTTP/2)**
|
- **2026-06-04: 本轮行动 — h2c 升级支持(明文 HTTP/2)**
|
||||||
- server.c: 添加 h2c 检测逻辑(PRI 魔术字 + Upgrade: h2c 协商)
|
- server.c: 添加 h2c 检测逻辑(PRI 魔术字 + Upgrade: h2c 协商)
|
||||||
- server.c: `is_h2c_upgrade_request()` 检查 Upgrade 和 Connection 头
|
- server.c: `is_h2c_upgrade_request()` 检查 Upgrade 和 Connection 头
|
||||||
|
|||||||
206
http2.c
206
http2.c
@ -19,6 +19,7 @@
|
|||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
#include <dirent.h>
|
||||||
#include <zlib.h>
|
#include <zlib.h>
|
||||||
#include <brotli/encode.h>
|
#include <brotli/encode.h>
|
||||||
|
|
||||||
@ -108,6 +109,8 @@ static int on_data_chunk_recv_callback(nghttp2_session *session,
|
|||||||
static ssize_t http2_data_source_read_callback(nghttp2_session *session, int32_t stream_id,
|
static ssize_t http2_data_source_read_callback(nghttp2_session *session, int32_t stream_id,
|
||||||
uint8_t *buf, size_t length, uint32_t *data_flags,
|
uint8_t *buf, size_t length, uint32_t *data_flags,
|
||||||
nghttp2_data_source *source, void *user_data);
|
nghttp2_data_source *source, void *user_data);
|
||||||
|
static bool http2_serve_directory(http2_session_t *h2, http2_stream_data_t *stream,
|
||||||
|
const char *real_path, const char *request_path);
|
||||||
static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream);
|
static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream);
|
||||||
|
|
||||||
/* ===================== 会话管理 ===================== */
|
/* ===================== 会话管理 ===================== */
|
||||||
@ -569,6 +572,177 @@ static ssize_t http2_data_source_read_callback(
|
|||||||
return (ssize_t)to_send;
|
return (ssize_t)to_send;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool http2_serve_directory(http2_session_t *h2, http2_stream_data_t *stream,
|
||||||
|
const char *real_path, const char *request_path) {
|
||||||
|
struct stat st;
|
||||||
|
if (stat(real_path, &st) != 0 || !S_ISDIR(st.st_mode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DIR *dir = opendir(real_path);
|
||||||
|
if (!dir) {
|
||||||
|
nghttp2_nv hdrs[] = {
|
||||||
|
{(uint8_t *)":status", (uint8_t *)"403", 7, 3, 0}};
|
||||||
|
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收集所有目录项 */
|
||||||
|
struct dirent *entry;
|
||||||
|
char *entries[4096];
|
||||||
|
int num_entries = 0;
|
||||||
|
while ((entry = readdir(dir)) != NULL && num_entries < 4096) {
|
||||||
|
if (entry->d_name[0] == '.') continue;
|
||||||
|
entries[num_entries] = strdup(entry->d_name);
|
||||||
|
num_entries++;
|
||||||
|
}
|
||||||
|
closedir(dir);
|
||||||
|
|
||||||
|
/* 构建 HTML */
|
||||||
|
char *html = (char *)malloc(65536);
|
||||||
|
if (!html) {
|
||||||
|
for (int i = 0; i < num_entries; i++) free(entries[i]);
|
||||||
|
nghttp2_nv hdrs[] = {
|
||||||
|
{(uint8_t *)":status", (uint8_t *)"500", 7, 3, 0}};
|
||||||
|
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int n = snprintf(html, 65536,
|
||||||
|
"<!DOCTYPE html>\n"
|
||||||
|
"<html><head>\n"
|
||||||
|
"<meta charset=\"utf-8\">\n"
|
||||||
|
"<title>Index of %s</title>\n"
|
||||||
|
"<style>"
|
||||||
|
"body{font-family:system-ui,-apple-system,sans-serif;max-width:800px;margin:40px auto;padding:0 20px}"
|
||||||
|
"h1{border-bottom:1px solid #ddd;padding-bottom:10px}"
|
||||||
|
"table{width:100%%;border-collapse:collapse}"
|
||||||
|
"th{text-align:left;padding:8px;border-bottom:2px solid #ddd}"
|
||||||
|
"td{padding:8px;border-bottom:1px solid #eee}"
|
||||||
|
"a{text-decoration:none;color:#0066cc}"
|
||||||
|
"a:hover{text-decoration:underline}"
|
||||||
|
"</style>\n"
|
||||||
|
"</head><body>\n"
|
||||||
|
"<h1>Index of %s</h1>\n"
|
||||||
|
"<table>\n"
|
||||||
|
"<tr><th>Name</th><th>Size</th><th>Modified</th></tr>\n",
|
||||||
|
request_path, request_path);
|
||||||
|
|
||||||
|
/* 添加返回上级链接 */
|
||||||
|
if (strcmp(request_path, "/") != 0) {
|
||||||
|
n += snprintf(html + n, 65536 - n,
|
||||||
|
"<tr><td><a href=\"../\">../</a></td><td>-</td><td>-</td></tr>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTML 转义辅助 */
|
||||||
|
auto void html_escape(const char *src, char *dst, size_t dst_size) {
|
||||||
|
size_t j = 0;
|
||||||
|
for (size_t i = 0; src[i] && j < dst_size - 1; i++) {
|
||||||
|
switch (src[i]) {
|
||||||
|
case '&':
|
||||||
|
if (j + 5 < dst_size) { memcpy(dst + j, "&", 5); j += 5; }
|
||||||
|
break;
|
||||||
|
case '<':
|
||||||
|
if (j + 4 < dst_size) { memcpy(dst + j, "<", 4); j += 4; }
|
||||||
|
break;
|
||||||
|
case '>':
|
||||||
|
if (j + 4 < dst_size) { memcpy(dst + j, ">", 4); j += 4; }
|
||||||
|
break;
|
||||||
|
case '"':
|
||||||
|
if (j + 6 < dst_size) { memcpy(dst + j, """, 6); j += 6; }
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dst[j++] = src[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst[j] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加目录项 */
|
||||||
|
for (int i = 0; i < num_entries; i++) {
|
||||||
|
char full_path[4096];
|
||||||
|
snprintf(full_path, sizeof(full_path), "%s/%s", real_path, entries[i]);
|
||||||
|
|
||||||
|
struct stat entry_st;
|
||||||
|
char size_str[32] = "-";
|
||||||
|
char mtime_str[32] = "-";
|
||||||
|
|
||||||
|
if (stat(full_path, &entry_st) == 0) {
|
||||||
|
if (S_ISDIR(entry_st.st_mode)) {
|
||||||
|
strncpy(size_str, "-", sizeof(size_str));
|
||||||
|
} else if (entry_st.st_size < 1024) {
|
||||||
|
snprintf(size_str, sizeof(size_str), "%ld B", (long)entry_st.st_size);
|
||||||
|
} else if (entry_st.st_size < 1024 * 1024) {
|
||||||
|
snprintf(size_str, sizeof(size_str), "%.1f KB", entry_st.st_size / 1024.0);
|
||||||
|
} else if (entry_st.st_size < 1024 * 1024 * 1024) {
|
||||||
|
snprintf(size_str, sizeof(size_str), "%.1f MB", entry_st.st_size / (1024.0 * 1024));
|
||||||
|
} else {
|
||||||
|
snprintf(size_str, sizeof(size_str), "%.1f GB", entry_st.st_size / (1024.0 * 1024 * 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
struct tm *tm_info = localtime(&entry_st.st_mtime);
|
||||||
|
if (tm_info) {
|
||||||
|
strftime(mtime_str, sizeof(mtime_str), "%Y-%m-%d %H:%M", tm_info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
char escaped_name[512];
|
||||||
|
html_escape(entries[i], escaped_name, sizeof(escaped_name));
|
||||||
|
|
||||||
|
n += snprintf(html + n, 65536 - n,
|
||||||
|
"<tr><td><a href=\"%s%s\">%s%s</a></td><td>%s</td><td>%s</td></tr>\n",
|
||||||
|
escaped_name,
|
||||||
|
S_ISDIR(entry_st.st_mode) ? "/" : "",
|
||||||
|
escaped_name,
|
||||||
|
S_ISDIR(entry_st.st_mode) ? "/" : "",
|
||||||
|
size_str, mtime_str);
|
||||||
|
|
||||||
|
free(entries[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
n += snprintf(html + n, 65536 - n,
|
||||||
|
"</table>\n"
|
||||||
|
"<hr>\n"
|
||||||
|
"<p><em>Cocoon Server</em></p>\n"
|
||||||
|
"</body></html>\n");
|
||||||
|
|
||||||
|
/* 存储响应并发送 */
|
||||||
|
stream->response_body = html;
|
||||||
|
stream->response_len = (size_t)n;
|
||||||
|
stream->response_sent = 0;
|
||||||
|
|
||||||
|
nghttp2_nv hdrs[8];
|
||||||
|
int num_hdrs = 0;
|
||||||
|
|
||||||
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
|
(uint8_t *)":status", (uint8_t *)"200", 7, 3, 0};
|
||||||
|
|
||||||
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
|
(uint8_t *)"content-type", (uint8_t *)"text/html; charset=utf-8", 12, 24, 0};
|
||||||
|
|
||||||
|
char content_length_str[32];
|
||||||
|
snprintf(content_length_str, sizeof(content_length_str), "%d", n);
|
||||||
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
|
(uint8_t *)"content-length", (uint8_t *)content_length_str, 14, strlen(content_length_str), 0};
|
||||||
|
|
||||||
|
hdrs[num_hdrs++] = (nghttp2_nv){
|
||||||
|
(uint8_t *)"server", (uint8_t *)"Cocoon/1.0", 6, 10, 0};
|
||||||
|
|
||||||
|
if (stream->request.method == HTTP_HEAD) {
|
||||||
|
free(stream->response_body);
|
||||||
|
stream->response_body = NULL;
|
||||||
|
stream->response_len = 0;
|
||||||
|
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, num_hdrs, NULL);
|
||||||
|
} else {
|
||||||
|
nghttp2_data_provider provider;
|
||||||
|
provider.source.ptr = stream;
|
||||||
|
provider.read_callback = http2_data_source_read_callback;
|
||||||
|
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, num_hdrs, &provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream) {
|
static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream) {
|
||||||
if (!h2->root_dir) {
|
if (!h2->root_dir) {
|
||||||
nghttp2_nv hdrs[] = {
|
nghttp2_nv hdrs[] = {
|
||||||
@ -614,26 +788,26 @@ static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 目录:尝试 index.html */
|
/* 目录:尝试 index.html,否则生成目录列表 */
|
||||||
if (S_ISDIR(st.st_mode)) {
|
if (S_ISDIR(st.st_mode)) {
|
||||||
char index_path[4096];
|
char index_path[4096];
|
||||||
size_t real_len = strlen(real_path);
|
size_t real_len = strlen(real_path);
|
||||||
if (real_len + 12 >= sizeof(index_path)) {
|
if (real_len + 12 < sizeof(index_path)) {
|
||||||
nghttp2_nv hdrs[] = {
|
snprintf(index_path, sizeof(index_path), "%s/index.html", real_path);
|
||||||
{(uint8_t *)":status", (uint8_t *)"404", 7, 3, 0}};
|
struct stat index_st;
|
||||||
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL);
|
if (stat(index_path, &index_st) == 0 && S_ISREG(index_st.st_mode)) {
|
||||||
return;
|
snprintf(real_path, sizeof(real_path), "%s", index_path);
|
||||||
}
|
stat(real_path, &st);
|
||||||
snprintf(index_path, sizeof(index_path), "%s/index.html", real_path);
|
} else {
|
||||||
struct stat index_st;
|
/* 生成目录浏览页面 */
|
||||||
if (stat(index_path, &index_st) == 0 && S_ISREG(index_st.st_mode)) {
|
if (http2_serve_directory(h2, stream, real_path, stream->request.path)) {
|
||||||
snprintf(real_path, sizeof(real_path), "%s", index_path);
|
return;
|
||||||
stat(real_path, &st);
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
nghttp2_nv hdrs[] = {
|
if (http2_serve_directory(h2, stream, real_path, stream->request.path)) {
|
||||||
{(uint8_t *)":status", (uint8_t *)"404", 7, 3, 0}};
|
return;
|
||||||
nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL);
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -572,6 +572,18 @@ fi
|
|||||||
assert_http2_brotli "https://$HOST/index.html" "HTTP/2 HTML brotli"
|
assert_http2_brotli "https://$HOST/index.html" "HTTP/2 HTML brotli"
|
||||||
assert_http2_brotli "https://$HOST/style.css" "HTTP/2 CSS brotli"
|
assert_http2_brotli "https://$HOST/style.css" "HTTP/2 CSS brotli"
|
||||||
assert_http2_brotli "https://$HOST/app.js" "HTTP/2 JS brotli"
|
assert_http2_brotli "https://$HOST/app.js" "HTTP/2 JS brotli"
|
||||||
|
|
||||||
|
# HTTP/2 目录浏览测试
|
||||||
|
h2_dir_status=$(curl --http2 -k -s -o /dev/null -w "%{http_code}" "https://$HOST/subdir/")
|
||||||
|
h2_dir_body=$(curl --http2 -k -s "https://$HOST/subdir/")
|
||||||
|
if [[ "$h2_dir_status" == "200" ]] && echo "$h2_dir_body" | grep -q "page.html"; then
|
||||||
|
echo " ✓ HTTP/2 目录浏览 — HTTP 200,包含 page.html"
|
||||||
|
pass
|
||||||
|
else
|
||||||
|
echo " ✗ HTTP/2 目录浏览 — 期望 200+page.html, 实际 $h2_dir_status"
|
||||||
|
fail
|
||||||
|
fi
|
||||||
|
|
||||||
# 停止 HTTP/2 服务器,恢复 HTTP 服务器
|
# 停止 HTTP/2 服务器,恢复 HTTP 服务器
|
||||||
kill_server
|
kill_server
|
||||||
sleep 1
|
sleep 1
|
||||||
@ -623,6 +635,18 @@ else
|
|||||||
fail
|
fail
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# HTTP/2 目录浏览测试
|
||||||
|
# subdir 目录没有 index.html,应返回 200 目录列表
|
||||||
|
h2c_dir_status=$(curl --http2-prior-knowledge -s -o /dev/null -w "%{http_code}" "http://$HOST/subdir/")
|
||||||
|
h2c_dir_body=$(curl --http2-prior-knowledge -s "http://$HOST/subdir/")
|
||||||
|
if [[ "$h2c_dir_status" == "200" ]] && echo "$h2c_dir_body" | grep -q "page.html"; then
|
||||||
|
echo " ✓ h2c 目录浏览 — HTTP 200,包含 page.html"
|
||||||
|
pass
|
||||||
|
else
|
||||||
|
echo " ✗ h2c 目录浏览 — 期望 200+page.html, 实际 $h2c_dir_status"
|
||||||
|
fail
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 结果汇总 ==="
|
echo "=== 结果汇总 ==="
|
||||||
echo "通过: $PASS"
|
echo "通过: $PASS"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user