fix(release): 验证器加重试;计时项降级为告警 链路实测:5 次 HEAD 有 1 次收不到任何响应,而站点一直正常。任何「一次读数 定生死」都会随机挡住发布,所以每个网络读数重试 3 次(间隔 2s),连续失败 才计入。 计时项(缓存页 vs 静态对照)改成 [WARN]:跨洋抖动 + 共享主机的 PHP-FCGI 排队(#259)让页面耗时的方差大于缓存命中与未命中的差距 —— 与静态文件比较 仍有 2/5 次误判,而站点完全正常。一个只产生假警报的发布门会训练人忽略验证 输出,所以留着数字、不参与判定。 真正确定的缓存断言需要服务端证据(X-Cache 头或 hits 计数器),留给 #253。 验证:连跑 6 次 0 failed;故意指向错误入口仍 4 failed、exit 1;正确目标 exit 0。 Co-Authored-By: Claude code 2.1.311 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
fix(release): 缓存检查改与静态文件对照,不再用绝对阈值 「热连接第二次 < 1s」仍然会误杀:实测同一分钟内缓存页 0.44→2.50s、静态 phpman.css 0.55→23s,抖动全在跨洋链路上,远大于缓存命中与未命中的差距。 改成同一次 curl 调用里连发三条(--next 复用连接):热身、缓存页、静态 css。 比的是缓存页与静态文件之差(+0.5s 容差)—— 链路抖动同时作用在两者上,比 绝对耗时稳定得多;真退化成逐请求重算,差值会拉开到秒级。 连跑四次均通过(page 0.45–0.84s,static 0.58–1.20s)。 Co-Authored-By: Claude code 2.1.311 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
fix(pcre): 正则里的 \x00 不再被 PHP 展开成 NUL 字节 —— 8.1 上整站 404 `"/[\x00-\x1F\x7F]+/"` 是双引号字符串,PHP 会把 \x00 展开成一个**真正的 NUL 字节**再交给 PCRE。8.1 那个镜像的 PCRE(10.42)直接拒收: PHP Warning: preg_replace(): Null byte in regex preg_last_error_msg() -> "Internal error" → 返回 NULL → normalizeParameter() 返回 "" 于是 normalizeParameter() 对**任何**入参都返回空串 —— 在 8.1 上整个站点的 每个页面都走「空参数」分支。这不是新引入的:8.1 从来没被跑过,是这次加 CI 才第一次暴露。 三处同样的写法,改成单引号,让 PCRE 看到的是转义序列而不是字节: src/util.php:183 控制字符剔除 src/format_html.php:100 $oscUri(OSC 8 链接的 scheme 白名单) src/format_common.php:24 同一份 $oscUri 后两处是同一个字符串抄了两遍。8.2/8.3 的 PCRE 接受模式里的 NUL,所以在那边 行为不变 —— 本地(8.2)跑全套 789 passed 印证了这一点。 顺带:这次排查也修了 run_all.php 的失败输出(原来只打含 ❌ 的行,把 expected/actual 丢掉了),否则 CI 上只能看到 "❌ simple command" 而不知道 它返回了什么。 Refs: #253 Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
fix(release): 验证脚本按入口 URL 拼地址,缓存检查排除握手 两处都会让健康的发布被判死: 1. BASE 当成了站点根。Makefile 传的是 $(DEMO_URL),含 phpMan.php, 脚本又自己拼一次,得到 /phpMan.php/phpMan.php/man/ls/1,四项检查全部 报「取不到页面」,v4.12.18 因此卡在 _verify-release,tag 没推出去。 现在 BASE 就是入口 URL(用户打开的那个地址),只给主机的形式仍然接受。 2. 「缓存第二次命中 < 1s」量的是跨洋 TLS 握手。静态 phpman.css 单次请求 也要 1.2s,其中握手 0.7–0.8s,跟缓存无关。改成在同一次 curl 调用里用 --next 发两次,第二条复用连接,只量服务端处理:0.32s。 Co-Authored-By: Claude code 2.1.311 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
fix(security): 安装器权限/路径、Host 回退、CSP nonce(#271 #272 #273) 三条按 11-03 审计的核实结论逐项改。 **#271 安装/部署链路** - `generate_config()` 复制完配置立刻 `chmod 600` —— 它含 `MCP_API_KEY`,`cp` 继承 umask,默认 022 下是 0644,同机其他用户可读 - `Makefile` 的 staging 配置从 `chmod 644` 改 600(生产侧本来就是 600) - `db/`、`logs/`、`backups/` 无条件 `chmod 750` —— 里面有客户端 IP/UA/URL 和 SQLite 缓存。mod_fcgid 下 web 服务就是同一个用户,owner+group 足够;若 web 用户是别人,安装器本来就会警告要 `--data-owner` - `PHPMAN_HOME` 落在 `--webroot` 之内 → 直接 `die`。两边都先 `pwd -P` 解析再比 前缀,符号链接、尾斜杠、`..` 都绕不过去 - 新增 `fix_data_ownership()`,在三处 `build-index` 之后调用 —— 索引以 root 建库 时新生成的 SQLite 分片仍属 root,构建前的 chown 覆盖不到,web 用户读得到但写不了 **#272 Host 回退** `getSafeHost()` 在未配置 `PHPMAN_BASE_URL` 时,只有在 `HTTP_HOST` 与 `SERVER_NAME` 一致时才采用 Host。`SERVER_NAME` 来自 vhost 配置、`HTTP_HOST` 来自请求 —— 不一致就是伪造,让它可以决定 canonical 与 Schema.org `@id`。 只比较主机部分,所以合法的 `:8080` 不算不一致;`SERVER_NAME` 为空时(部分 CGI) 仍用 Host,此时没有比对对象。大小写不敏感比较,返回值保留请求里的拼写。 **#273 CSP nonce** - 每请求生成 `PHPMAN_CSP_NONCE`,`script-src` 用它替换 `'unsafe-inline'` —— 带 `'unsafe-inline'` 时 CSP 作为 XSS 兜底的能力为零 - nonce 定义在 `web_header.php` 的**文件作用域**,不是 `showHeader()` 里 —— bootstrap 每个请求都会 require 它,而 `test_footer_format_links.php` 只调 `showFooter()` 不调 `showHeader()`,定义在函数里会未定义 - 内联点:GA 片段与两块 JSON-LD 都带上了 nonce - 两个内联 `onclick`(`#toc-toggle`、`#alpha-toggle`)移到 `phpman.js` 做事件 委托 —— 内联事件处理器同样受 `script-src` 约束 **`style-src` 保留 `'unsafe-inline'`**:剩下 8 处内联样式全在 `src/cache.php` 的 调试面板里,为一个开发者视图上 `style-src-attr` 哈希不值当。代码注释里写明了这个取舍。 测试: - `test_safe_host.php` +5 条(伪造 Host 落败、带端口、大小写、无 Host 时回退) - `test_security_hardening.php` +9 条(nonce 存在且足够长、JSON-LD 带 nonce、 两处切换器无 onclick、phpman.js 确实接管了这两个 id) **都验证过在修复前会失败**:`git stash` 掉 `util.php` 后 2 条红(#272); stash 掉 `format_html.php` + `source_search.php` 后 3 条红(#273 的 onclick)。 注意 TOC 那条一开始是**空过**的 —— 用 `formatManPerlDoc` 驱动时 TOC 根本没渲染, 改成直接调 `renderTocSidebar()` 才真的测到。 804 passed / 0 failed。install.sh 在 bash 5 与 3.2 下 `-n` 均通过。 Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
ci: 探针直接调 normalizeParameter(),别再从断言反推 Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
ci: 加一条 php sanity 探针 —— mbstring 坏掉时 normalizeParameter 全返回空
8.1 上 test_normalize 的 9 条失败全是 normalizeParameter 返回 '',而它唯一
可能返回空的地方是 mb_check_encoding() 判假。mbstring 明明 Enabled,所以先把
PHP 版本、extension_loaded、mb_check_encoding("ls","UTF-8")、default_charset
四个事实打出来,别再靠猜。
Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
ci: 显式装 mbstring/sqlite3;失败的断言要打出 expected/actual 两处: 1. setup-php 显式要 mbstring 与 sqlite3。mbstring 是 install.sh 里的硬依赖 (check_mbstring),normalizeParameter() 每次请求都调 mb_check_encoding(); sqlite3 是缓存层。不写就得指望每个 PHP 镜像的默认扩展集一样 —— 8.1 上 test_normalize 与 test_overstrike 的失败看起来正是这个原因。 2. test/run_all.php 原来只在失败时打印含 ❌ 的行。而 assert_equals 的 "expected:" / "actual:" 两行不含 ❌,于是被丢掉 —— CI 上看到的就是 "❌ simple command" 而没有它到底返回了什么,等于无法诊断。现在把 ❌ 行 连同它下面缩进的 expected/actual 一起打印。 Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
ci: 补一个占位的 .deploy.mk —— 没有它 make test 直接 Stop Makefile 第 47 行在 .deploy.mk 缺失时直接 $(error)。该文件含服务器设置、 被 .gitignore 排除,所以 CI 里没有。test / test-unit 不读那些值,复制示例里 的占位符即可 —— 这样 CI 与发布跑的是同一套 make 目标,而不是手抄一份语法检查。 Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
feat(release): 发布后验证接在打 tag 之前;补 CI;man 的 404 不再只剩标题 (#253) `make test` 只是 `php -l` / `sh -n`,看不见行为回归 —— 2026-10-19 那次就是 代码上线后每次缓存未命中都 500,而 make test 全绿。 一、deploy/verify-release.sh <expected-tag> [base-url] 只读 HTTP,六项检查,逐行 PASS/FAIL/SKIP,任一不过非 0: - 页脚的 GIT_DESCRIBE == 期望 tag - 静态资源 ?v= == PHPMAN_VERSION - 已知不存在的页面返回 404 **且带兜底说明**(不是裸标题) - 同一缓存页面再请求一次 < 1s - ETag 形态(32 位十六进制、带引号) - MCP initialize 的 serverInfo.version —— 设了 PHPMAN_MCP_KEY 才查,否则 SKIP(页脚与静态资源已经各有一份版本,开发机上没有生产 key 不该让发布失败) 接成 `_verify-release`,放在 `_release-tag` **之前**:版本号推出去就挪不动, 行为不对的构建不该拿到一个。 刻意不做两件事:不自动回滚(验证器自己出故障时自动回滚比只报警更糟); 不复算 ETag(HTML 的 ETag 含 cacheAge,不知道 DB 状态算不出来,强行复算只会 制造伪失败)。 实测(对 v4.12.17 生产):4 PASS / 1 FAIL(man 的兜底说明还没上线)/ 1 SKIP, 退出码 1 —— 它确实抓到了自己存在的理由。 二、.github/workflows/ci.yml - unit:PHP 8.1/8.2/8.3 矩阵跑 `make test` + `php test/run_all.php`; fetch-depth: 0,因为 #250 的版本号测试要读真实 tag 与历史 - shellcheck:advisory(脚本早于 shellcheck,风格问题不该挡合并) - staging-smoke:每日对 test.chedong.com 跑回归,advisory(打的是活站点) 三、man 的 404 不再只剩标题 apropos 无结果时 markdown 正文是 41 字节,只有标题 —— 读起来像坏了。写验证器 时发现 #253 的设计假设 man 已经有兜底说明,但 #252 只覆盖了 pydoc 和 ri。 manNoMatchNotice() 复用 noMatchNotice(),措辞里可以说 apropos 确实搜过(它只 要 ~0.03s,不像 pydoc 的 10–14s)。cacheNotFoundOnce() 仍写 NULL 正文的负行, 所以说明是展示期的,改文案不需要动缓存。 验证:全套 789 passed / 0 failed(新增 5 条 man 说明断言);workflow YAML 用 ruby 解析通过;验证器对生产实跑结果如上。 Refs: #253 Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>