车东 Che, Dong

Tue 03 November, 2026

23:00 fix(release): 验证器加重试;计时项降级为告警 » Recent Commits to phpman:master
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>
22:50 fix(release): 缓存检查改与静态文件对照,不再用绝对阈值 » Recent Commits to phpman:master
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>
20:24 fix(pcre): 正则里的 \x00 不再被 PHP 展开成 NUL 字节 —— 8.1 上整站 404 » Recent Commits to phpman:master
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>
20:12 fix(release): 验证脚本按入口 URL 拼地址,缓存检查排除握手 » Recent Commits to phpman:master
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>
19:54 fix(security): 安装器权限/路径、Host 回退、CSP nonce(#271 #272 #273) » Recent Commits to phpman:master
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>
19:46 ci: 探针直接调 normalizeParameter(),别再从断言反推 » Recent Commits to phpman:master
ci: 探针直接调 normalizeParameter(),别再从断言反推

Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
19:22 ci: 加一条 php sanity 探针 —— mbstring 坏掉时 normalizeParameter 全返回空 » Recent Commits to phpman:master
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>
19:04 ci: 显式装 mbstring/sqlite3;失败的断言要打出 expected/actual » Recent Commits to phpman:master
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>
18:31 ci: 补一个占位的 .deploy.mk —— 没有它 make test 直接 Stop » Recent Commits to phpman:master
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>
17:59 feat(release): 发布后验证接在打 tag 之前;补 CI;man 的 404 不再只剩标题 (#253) » Recent Commits to phpman:master
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>
15:51 fix(security): 处理审计 #269/#275/#277/#278/#279 » Recent Commits to phpman:master
06:38 refactor(precheck): 去掉 release-precheck.sh 里的 eval,输出改由调用方读 $_out (#276) » Recent Commits to phpman:master
04:28 fix(input): 非法 UTF-8 在入口处拒掉,不再让 /u 正则的 null 变成 500 (#268) » Recent Commits to phpman:master
03:58 fix(make): SSH 连接复用,绕开 DreamHost 的连接数限流 (#267) » Recent Commits to phpman:master

Mon 02 November, 2026

21:49 Merge pull request #266 from chedong/fix/install-sh-260-265 » Recent Commits to phpman:master
21:46 fix(install): port installer hardening onto the PHPMAN_HOME layout » Recent Commits to phpman:master
19:16 fix(json,cache): 非法 UTF-8 不再产生 0 字节 JSON;负行不再被每次请求续期 » Recent Commits to phpman:master

Sun 01 November, 2026

00:18 fix(cache): 负缓存守卫改按 status 判断,不再让 FTS 正文绕过它 » Recent Commits to phpman:master

Thu 29 October, 2026

15:32 docs: 新增 09-QUALITY-GATES — 四层验证、原始材料、G1–G9 门 » Recent Commits to phpman:master

Wed 28 October, 2026

07:41 fix(pydoc,config): 超时不再丢弃 FTS 兜底正文;配置模板不再引用未定义常量 » Recent Commits to phpman:master