docs: 记录第二轮 4 项修复、litmus 前后对比与已知偏差

- SPEC 同步:未知账号 401、只读含 LOCK/UNLOCK、client /me
- 完成报告新增 §0:修复前后原始输出、litmus 四段 summary、逐条定性
This commit is contained in:
Yunling Zhang committed 2026-09-28 17:39:59 +08:00
1 parent 673dcef340
commit a730929bf6
2 files changed
+186 -8

No files matched your search

+181 -6
View File
@@ -2,6 +2,180 @@
日期:2026-09-28 · 任务书:`docs/SPEC.md`
## 0. 第二轮修复(验收反馈的 4 项)
> 说明:验收方指定的自测端口 `127.0.0.1:18901` 被 **opencode 服务本体**占用
> 按红线不得杀已有进程,故第二轮自测改用 `127.0.0.1:18902` + 临时数据目录
> `/tmp/davbox-accept`。验收实例 `127.0.0.1:18900` 全程未触碰。
### 0.1 死属性 / PROPPATCH 持久化
**实现**:新增 `internal/server/deadprops.go`,用 `deadPropFS` 包装 `webdav.Dir`,
使其返回的 `File` 实现 `webdav.DeadPropsHolder`(`DeadProps` + `Patch`)。
sidecar 存于 **账号 root 之外**:`<数据目录>/davprops/<user>/<资源相对路径…>/__props__.json`。
**为什么放在 root 之外**:root 内任何隐藏目录仍处于 PROPFIND `Depth:1` 与 client
`/api/client/list` 可见的命名空间里,必须在每一处遍历(Readdir、client list、
用量统计)都过滤,漏一处就污染用户目录;放到 root 外则从结构上杜绝。
`PROPFIND Depth:1` 与 client 列表天然看不到它,用量统计也不会计入。
COPY/MOVE/DELETE 跟随:
- COPY 文件:`x/net/webdav` 内置 `copyProps(dst,src)` 会调用双方的 `Patch`;
- MOVE:`deadPropFS.Rename` 同步搬移 sidecar 子树;
- DELETE / 覆盖写:`deadPropFS.RemoveAll` 清理对应 sidecar 子树;
- `PROPPATCH remove` 清空属性时删除 sidecar 并剪掉空目录。
修复前(PROPPATCH 一律 403):
```
$ curl -sS -u litmus:litmus-pass -X PROPPATCH --data-binary @pp.xml .../litmus/p.txt -D-
HTTP/1.1 207 Multi-Status
<D:propstat><D:prop><author xmlns="urn:test:"></author></D:prop>
<D:status>HTTP/1.1 403 Forbidden</D:status></D:propstat>
```
修复后:
```
$ curl -sS -u litmus:litmus-pass -X PROPPATCH --data-binary @pp.xml .../litmus/p.txt -D-
HTTP/1.1 207 Multi-Status
<D:propstat><D:prop><author xmlns="urn:test:"></author></D:prop>
<D:status>HTTP/1.1 200 OK</D:status></D:propstat>
$ curl -sS -u litmus:litmus-pass -X PROPFIND -H 'Depth: 0' --data-binary @pf.xml .../litmus/p.txt -D-
HTTP/1.1 207 Multi-Status
<D:prop><author xmlns="urn:test:">alice</author></D:prop><D:status>HTTP/1.1 200 OK</D:status>
# 客户端目录列表看不到 sidecar
$ curl -sS -b ck.txt '.../api/client/list?path=/'
[{"name":"p.txt","isDir":false,"size":2,"mtime":1790588327477}]
# 账号 root 内没有 davprops;sidecar 在数据目录
$ ls -a data-after/data/litmus
. .. p.txt
$ find data-after/davprops -type f
data-after/davprops/litmus/p.txt/__props__.json
```
### 0.2 只读账号 LOCK/UNLOCK → 403
`isWriteMethod` 增加 `LOCK`/`UNLOCK`。
修复前:
```
$ curl -sS -u ro:ro-pass -X LOCK ... -D-
HTTP/1.1 201 Created
Lock-Token: <1790588046>
```
修复后:
```
$ curl -sS -u ro:ro-pass -X LOCK ... -D-
HTTP/1.1 403 Forbidden
{"error":"只读账号"}
$ curl -sS -u ro:ro-pass -X UNLOCK -H 'Lock-Token: <...>' ... -D-
HTTP/1.1 403 Forbidden
{"error":"只读账号"}
```
### 0.3 不存在的账号 → 401(原来 404)
路由 default 分支:首段账号不在账号表时,与未认证一致返回
`401 + WWW-Authenticate: Basic realm="davbox"`,不再 404,避免泄漏账号是否存在。
修复前:
```
$ curl -sS -u ghost:whatever -X PROPFIND -H 'Depth: 0' .../ghost/ -D-
HTTP/1.1 404 Not Found
{"error":"未找到"}
```
修复后:
```
$ curl -sS -u ghost:whatever -X PROPFIND -H 'Depth: 0' .../ghost/ -D-
HTTP/1.1 401 Unauthorized
Www-Authenticate: Basic realm="davbox"
{"error":"未认证"}
# 不带凭据同样 401
HTTP/1.1 401 Unauthorized
Www-Authenticate: Basic realm="davbox"
```
### 0.4 client 刷新后显示当前账号名
新增 `GET /api/client/me`(会话内返回 `{user, readonly}`,不含口令);
client 页每次加载列表后拉取 me,顶栏显示账号名,刷新不丢失。
修复前:
```
$ curl -sS -b ck.txt .../api/client/me -D-
HTTP/1.1 404 Not Found
{"error":"未找到"}
```
修复后:
```
$ curl -sS -b ck.txt .../api/client/me -D-
HTTP/1.1 200 OK
{"readonly":false,"user":"litmus"}
```
### 0.5 litmus 结果(`litmus -k http://127.0.0.1:18902/litmus/ litmus litmus-pass`)
修复前:
```
<- summary for `basic': of 16 tests run: 16 passed, 0 failed. 100.0%
<- summary for `copymove': of 13 tests run: 13 passed, 0 failed. 100.0%
<- summary for `props': of 14 tests run: 10 passed, 4 failed. 71.4% (16 skipped)
<- summary for `locks': of 34 tests run: 30 passed, 4 failed. 88.2%
<- summary for `http': of 4 tests run: 4 passed, 0 failed. 100.0%
```
修复后:
```
<- summary for `basic': of 16 tests run: 16 passed, 0 failed. 100.0%
<- summary for `copymove': of 13 tests run: 13 passed, 0 failed. 100.0%
<- summary for `props': of 30 tests run: 29 passed, 1 failed. 96.7%
<- summary for `locks': of 34 tests run: 32 passed, 2 failed. 94.1%
<- summary for `http': of 4 tests run: 4 passed, 0 failed. 100.0%
```
因 PROPPATCH 失败的目标 5 项全部转绿:`props: propset / propmanyns / propget`
以及 `locks: owner_modify`(两处,文件与集合成员)。
### 0.6 仍未通过的 litmus 项(逐条定性)
| 项 | 现象 | 定性 |
|---|---|---|
| `props: propfind_invalid2` | 带非法命名空间声明的 PROPFIND 返回 207 而非 400 | **上游库限制**:`x/net/webdav` 的 XML 解码器宽松,不校验命名空间声明。非本轮范围。 |
| `locks: fail_complex_cond_put` | 复杂伪造 If 条件仍 201 | **上游库限制**:`x/net/webdav` 的 If 头解析宽松。非本轮范围。 |
| `locks: lock_shared` | 共享锁 LOCK 返回 501 | **RFC 允许**:RFC 4918 的共享锁为可选能力,未实现返回 501 合法。`x/net/webdav` 的 MemLS 只支持排他锁。后续共享锁相关 7 项被 litmus 自动跳过。 |
| `locks: cond_put_corrupt_token` | 412(litmus WARNING 期望 423) | **RFC 允许**:RFC 4918 §10.4.1 要求 If 求值全失败返回 412;`x/net/webdav` 源码注释明确选择 412。计入 pass with 1 warning。 |
### 0.7 质量门禁(第二轮)
```
$ go vet ./... -> 通过
$ go test ./... -> ok internal/account, ok internal/server
$ gofmt -l ./internal ./cmd -> 无输出
$ cd web && npm run build -> tsc --noEmit 零错误 + vite build 成功
```
新增单元测试:`deadprops_test.go`(死属性读写/COPY/MOVE/DELETE 清理/目录不可见)、
`lock_test.go`(只读 LOCK/UNLOCK 403)、`account_test.go`(未知账号 401)、
`client_test.go`(`/api/client/me` 返回账号名且不含口令)。
## 1. 交付概览
单个 Go 静态二进制,内嵌 React + Vite + TS 前端,实现:
@@ -220,17 +394,18 @@ npm run build -> tsc --noEmit 零错误 + vite build 成功
1. **新增了 `golang.org/x/crypto`**,超出「只准 x/net」的字面白名单;原因是 SPEC 明确要求 bcrypt,而标准库无 bcrypt。
2. **`go.mod` 的 go 指令是 1.26.0 而非 1.24**:依赖的 `go.mod` 要求 ≥1.26,工具链自动切换所致。
3. **未运行 litmus 合规套件**:SPEC §8 未列入必测项(REQUIREMENTS 列为加分项)。
4. **只读账号的 `LOCK/UNLOCK` 未阻断**:SPEC 只列出 PUT/DELETE/MKCOL/MOVE/COPY/PROPPATCH 为写操作,故按此实现;只读账号即使加锁,写操作仍 403。
5. **client 页刷新后不显示当前账号名**:登录信息在 HttpOnly cookie 内,前端读不到,也未额外加接口(避免超出 SPEC 接口表)。
3. **litmus 已在第二轮运行**(结果见 §0.5):basic 16/16、copymove 13/13、props 29/30、locks 32/34、http 4/4;剩余 3 项为上游库限制或 RFC 允许,见 §0.6。
4. **只读账号的 `LOCK/UNLOCK` 已在第二轮阻断为 403**(见 §0.2),SPEC §3 已同步。
5. **client 页刷新后显示当前账号名已在第二轮完成**:新增 `GET /api/client/me`(见 §0.4),SPEC §5 已同步。
6. **新建/重命名/删除确认使用浏览器原生 `prompt`/`confirm`**,未自绘对话框。
7. **移动端仅做响应式 CSS**(面包屑与操作按钮不隐藏),未做真机/多浏览器截图验证。
8. 自测数据留在 `/tmp/davbox-dev`(未删除),测试进程已停止,18900 已释放。
8. **已知偏差(第二轮明确)**:`propfind_invalid2` 属上游 `x/net/webdav` 的 XML 宽松;`fail_complex_cond_put` 属上游 If 头宽松;共享锁(`lock_shared`)未实现、返回 501 为 RFC 4918 允许;`cond_put_corrupt_token` 返回 412 亦符 RFC 4918 §10.4.1。详见 §0.6。
9. 第一轮自测数据留在 `/tmp/davbox-dev`(未删除);第二轮自测数据在 `/tmp/davbox-accept`,测试进程按 PID 停止,18902 已释放。
## 5. 合规自查
- 未执行 `systemctl` / `service` / `pkill` / `killall`;测试进程按 PID 停止。
- 只监听 `127.0.0.1:18900`;未触碰任何生产端口。
- 未改动 `仓库之外的系统配置`、nginx、容器、cron、其他项目。
- 验收实例 `127.0.0.1:18900` 全程未触碰;第二轮只监听 `127.0.0.1:18902`(18901 被 opencode 本体占用,见 §0 说明);未触碰任何生产端口。
- 未改动 `仓库之外的系统配置`(除任务指定的临时目录 `/tmp/davbox-accept`)、nginx、容器、cron、其他项目。
- 源码中无私有域名、公网 IP、真实口令;前端地址取自 `window.location.origin`。
- 未执行 `git push`。
+5 -2
View File
@@ -73,12 +73,14 @@
- 认证:HTTP Basic,口令用 `crypto/subtle.ConstantTimeCompare`
- 未认证/失败 → `401` + **必须**响应头 `WWW-Authenticate: Basic realm="davbox"`
- 账号 `disabled: true` → 同 401(不要泄漏账号是否存在)
- 请求首段账号不存在 → 同 401(不要泄漏账号是否存在)
- 请求经过 `golang.org/x/net/webdav` 的 `Handler`:
- `Prefix` 设为 `/<user>/`(前缀剥离由它负责)
- `FileSystem` = 该账号 root 的 `webdav.Dir`
- `FileSystem` = 该账号 root 的 `webdav.Dir`,外层包装为支持死属性持久化的实现
(sidecar 存在账号 root 之外,不出现在客户端可见目录里)
- `LockSystem` = `webdav.NewMemLS()`
- `Logger` 只记错误,**绝不**记录 `Authorization` 头或口令
- `readonly: true` 的账号:`PUT` / `DELETE` / `MKCOL` / `MOVE` / `COPY` / `PROPPATCH` → `403`;`GET` / `HEAD` / `PROPFIND` / `OPTIONS` 正常
- `readonly: true` 的账号:`PUT` / `DELETE` / `MKCOL` / `MOVE` / `COPY` / `PROPPATCH` / `LOCK` / `UNLOCK` → `403`;`GET` / `HEAD` / `PROPFIND` / `OPTIONS` 正常
- 六动词必须真能用:`GET` `PUT` `DELETE` `MKCOL` `MOVE` `PROPFIND`(`Depth: 0` 与 `Depth: 1` 都要正确返回 `207`)
- 路径安全:除库自身防护外,处理器入口再显式校验一次请求路径,拒绝含 `..`、`%2e`(大小写)、`\`、空字节的路径 → `400`
@@ -109,6 +111,7 @@
|---|---|---|
| POST | `/api/client/login` | 体 `{"user":"...","pass":"..."}` → 设 cookie |
| POST | `/api/client/logout` | 清 cookie |
| GET | `/api/client/me` | 返回会话账号 `{user, readonly}`(不含口令) |
| GET | `/api/client/list?path=/a/b` | 目录列表:`[{name,isDir,size,mtime}]`,按「目录优先 + 名称」排序 |
| GET | `/api/client/raw/{path}` | 下载(支持 `Range`)|
| PUT | `/api/client/raw/{path}` | 上传(流式;父目录不存在 → 400) |