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:
+12
-12
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user