From a730929bf65d1d001623e66f44aaed6350cae8a7 Mon Sep 17 00:00:00 2001 From: Yunling Zhang Date: Mon, 28 Sep 2026 17:39:59 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E8=BD=AE=204=20=E9=A1=B9=E4=BF=AE=E5=A4=8D=E3=80=81litmus=20?= =?UTF-8?q?=E5=89=8D=E5=90=8E=E5=AF=B9=E6=AF=94=E4=B8=8E=E5=B7=B2=E7=9F=A5?= =?UTF-8?q?=E5=81=8F=E5=B7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPEC 同步:未知账号 401、只读含 LOCK/UNLOCK、client /me - 完成报告新增 §0:修复前后原始输出、litmus 四段 summary、逐条定性 --- docs/COMPLETION_REPORT.md | 187 ++++++++++++++++++++++++++++++++++++-- docs/SPEC.md | 7 +- 2 files changed, 186 insertions(+), 8 deletions(-) diff --git a/docs/COMPLETION_REPORT.md b/docs/COMPLETION_REPORT.md index b6707be..3cec942 100644 --- a/docs/COMPLETION_REPORT.md +++ b/docs/COMPLETION_REPORT.md @@ -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//<资源相对路径…>/__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 + +HTTP/1.1 403 Forbidden +``` + +修复后: + +``` +$ curl -sS -u litmus:litmus-pass -X PROPPATCH --data-binary @pp.xml .../litmus/p.txt -D- +HTTP/1.1 207 Multi-Status + +HTTP/1.1 200 OK + +$ 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 +aliceHTTP/1.1 200 OK + +# 客户端目录列表看不到 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`。 diff --git a/docs/SPEC.md b/docs/SPEC.md index 2c44b55..30969bc 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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` 设为 `//`(前缀剥离由它负责) - - `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) |