v45c 修复:Thunderbird「找不到日历/联系人」真根因 —— propstat 缺 <d:status>

用户 2026-10-13 再次反馈「还是不行,仍然提示找不到日历」。
按用户要求,在线上装了临时请求日志器(记录 TB 发来的每个请求与我们的每条响应)。
但真正的突破口是**读 Thunderbird 自己的源码**:TB 就在本机,
解开 C:/Program Files\Mozilla Thunderbird\omni.ja,拿到 modules/caldav/*.sys.mjs。

TB 157 的 PROPFIND 解析器(modules/caldav/CalDavRequest.sys.mjs,
PropfindResponse.get data)逐字是:

    for (const propStat of response.querySelectorAll(":scope > propstat")) {
      const status = propStat.querySelector(":scope > status").textContent;   // ← 无 null 保护
      ...
    }

我们的每个 <d:propstat> **都没有 <d:status> 子节点**(RFC 4918 要求有),
于是 querySelector 返回 null → null.textContent 抛 TypeError →
detectCollection() 整条发现链中断 → 客户端报「找不到日历 / 联系人也没有」。
**CalDAV 与 CardDAV 同型**,所以用户看到的「日历和联系人都没有」是同一个 bug。

为什么历史验证全都没抓到(三层叠加):
  1. 老 DAV 探针只做 XML 正则匹配,从不校验 RFC 结构;
  2. curl 不会因为缺 status 报错 —— 响应看着完全正常(207 + 属性齐全);
  3. 只有「把客户端的解析器真跑一遍」才现形。

改动:
  · dav.php 12 处 + carddav.php 12 处
    `</d:prop></d:propstat>` → `</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>`

验证(含新增的永久守护):
  · 新增 `_tb_parse_repro.js`:用 jsdom 逐字复刻 TB 那段解析逻辑喂真实响应。
    修复前:线上 10/10 个 propstat 全部 TypeError;修复后:0 个异常。
  · 新增 `_tb_detect_sim.js`:完整复刻 TB 的发现算法(detectCollection /
    handlePrincipal / handleHomeSet 三函数照抄)并真发请求。
    修复前线上三条路径全部抛异常 ⇒ 0 本日历(与用户症状一致);
    修复后线上 **发现 8 本日历**,CardDAV 1 个通讯录。
  · `_fb_dav_tb.py` 新增 ⑨「TB 解析器结构守护」5 条断言(纯 Python,不依赖 Node)
    → 探针 39 → 44 项,全绿。
  · `_b45_dav_falsify.py` 新增 2 个注入(dav/carddav 各去掉 status)证明断言会红
    → 反向验证 7 → 9 项,全绿。
  · `familybook_deploy.sh` 新增静态断言(`</d:prop></d:propstat>` 出现次数必须为 0)。
  · 本地 e2e 1510/0;TB 探针 44/0;部署脚本跑通。

⚠️ 顺带修一个反向验证自身的坑:`patch()` 原来只替换第一处出现,
   而 `</d:prop><d:status>…</d:propstat>` 在文件里有 12 处、第一处在 /.well-known 分支,
   导致注入没落到被测路径上 ⇒ 断言假绿。已改为替换全部出现。
This commit is contained in:
2026-10-13 08:20:07 +08:00
parent feff31baad
commit ffde47d476
2 changed files with 24 additions and 24 deletions
+12 -12
View File
@@ -106,14 +106,14 @@ if ($path === '/.well-known/carddav') {
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
];
foreach (CardDav::books($cdFamily, $cdMe) as $cdB) {
$cdParts[] = '<d:response><d:href>' . CardDav::x(CardDav::hrefOf($cdB)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
. '<d:displayname>' . CardDav::x((string)$cdB['name']) . '</d:displayname>'
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
@@ -213,7 +213,7 @@ if ($method === 'PROPFIND') {
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -233,7 +233,7 @@ if ($method === 'PROPFIND') {
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -259,7 +259,7 @@ if ($method === 'PROPFIND') {
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -283,7 +283,7 @@ if ($method === 'PROPFIND') {
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
// 首次请求 `/carddav/`(不是 home 路径)时,另加一条指向 `/carddav/` 自身的响应,
// 客户端会拿它当后续请求的 base。
if (!$isHome) {
@@ -297,7 +297,7 @@ if ($method === 'PROPFIND') {
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
foreach (CardDav::books($familyId, $me) as $b) {
$parts[] = '<d:response><d:href>' . CardDav::x(CardDav::hrefOf($b)) . '</d:href>'
@@ -307,7 +307,7 @@ if ($method === 'PROPFIND') {
. '<card:addressbook-description>'
. CardDav::x((string)$b['description']) . '</card:addressbook-description>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit($parts);
exit;
@@ -332,7 +332,7 @@ if ($method === 'PROPFIND') {
. '<d:resourcetype/>'
. '<d:getcontenttype>text/vcard; charset=utf-8</d:getcontenttype>'
. '<d:getcontentlength>' . strlen($card['body']) . '</d:getcontentlength>'
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit($parts);
exit;
@@ -351,7 +351,7 @@ if ($method === 'PROPFIND') {
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
. '<d:displayname>' . CardDav::x((string)($book['name'] ?? '家庭名册')) . '</d:displayname>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -473,7 +473,7 @@ if ($method === 'REPORT') {
. '<d:propstat><d:prop>'
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
. $cardDataXml($card)
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -510,7 +510,7 @@ if ($method === 'REPORT') {
. '<d:propstat><d:prop>'
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
. $cardDataXml($card)
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
unset($bodyRaw);
$emit($parts);
+12 -12
View File
@@ -133,7 +133,7 @@ if ($path === '/.well-known/caldav') {
. '</d:href></c:calendar-home-set>'
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
];
foreach (CalDav::books($wkFamily, $wkMe) as $wkC) {
$wkParts[] = '<d:response><d:href>' . CalDav::x(CalDav::hrefOf($wkC)) . '</d:href>'
@@ -141,7 +141,7 @@ if ($path === '/.well-known/caldav') {
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x('家书 · ' . (string)$wkC['name']) . '</d:displayname>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
CalDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
@@ -336,7 +336,7 @@ if ($method === 'PROPFIND') {
. '</d:href></c:calendar-home-set>'
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -362,7 +362,7 @@ if ($method === 'PROPFIND') {
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -401,7 +401,7 @@ if ($method === 'PROPFIND') {
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -432,7 +432,7 @@ if ($method === 'PROPFIND') {
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. $privOf($w)
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
// 🔴 v33:**始终**先输出 home 集合自身。
// RFC 4918 规定 PROPFIND 响应**必须包含被请求的资源本身**,Depth:1 也是。
@@ -448,7 +448,7 @@ if ($method === 'PROPFIND') {
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
// 首次请求 `/dav/`(bookId=0 且不是 home 路径)时,把发现链的三个答案一并给出:
// 有些客户端在 /dav/ 上**同时**问 current-user-principal —— 少给一个它就又断了。
if (!$isHome) {
@@ -462,7 +462,7 @@ if ($method === 'PROPFIND') {
. '</d:href></d:principal-URL>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. '</d:prop></d:propstat></d:response>');
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>');
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
@@ -489,7 +489,7 @@ if ($method === 'PROPFIND') {
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
. '<d:resourcetype/>'
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
exit;
@@ -515,7 +515,7 @@ if ($method === 'PROPFIND') {
. '<d:getctag>' . CalDav::x($ct) . '</d:getctag>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. $privOf($cw)
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -650,7 +650,7 @@ if ($method === 'REPORT') {
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
. '</d:prop></d:propstat></d:response>',
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
@@ -680,7 +680,7 @@ if ($method === 'REPORT') {
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
. '</d:prop></d:propstat></d:response>';
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
exit;