# 缺口分析与路线图

本文件列出「外部 WebDAV 客户端还缺什么接口」的结论：P0 是**现有 12 个接口的
协议正确性**问题（已全部落地），P1 / P2 是能力扩展方向。

> 说明：P0、「传输形态补强」、「锁支持」、「递归建目录」与「Range / 断点续传」已落地；
> 其余 P1 / P2 仍是候选。
> 业界对标基于 RFC 4918（WebDAV）与领域常识整理，
> **未经联网核实**，采用前建议逐条复核。

## 传输形态补强（已实现）

「上传只收完整 `Bytes`、下载回调里 await 不了写盘」是本轮解决的问题：

| 形态 | 入口 | 说明 |
|------|------|------|
| 上传 · 全量字节 | `put_file(path~, data~ : Bytes)` | 内容已在内存；发送侧本来就是流式的（`BytesReader` 拉取 + 定长分帧） |
| 上传 · 读取流 | `put_file_stream(path~, reader~ : &@io.Reader, content_length?)` | 边读边发；给长度定长、不给走 chunked；流是一次性资源 |
| 上传 · 本地文件 | `put_file_from_path(path~, local_path~)` | 内部 open / 取长度 / 边读边发 / 关闭，**不整份读进内存** |
| 下载 · 全量到内存 | `get_file(path~)` | 返回内容与响应元数据 |
| 下载 · 分块回调 | `download_file(path~, on_chunk~)` | 同步回调，适合内存消费（拼接 / 哈希） |
| 下载 · 写本地文件 | `download_to_path(path~, local_path~)` | 拿到 200 才建文件，边收边写盘（async 写盘在内部完成） |

配套新增错误分支 `WebdavError::Io`（本地文件打不开 / 创建失败 / 写盘失败），
细节见 `docs/02-api.md` 的「本地文件形态」与 `docs/01-architecture.md` 的第 3 / 4 节。

## 递归建目录（已实现）

单级 `mkdir` 在父目录不存在时只能拿到 409，调用方往往要自己从根往回拼路径。

| 能力 | 入口 | 说明 |
|------|------|------|
| 递归建目录 | `mkdir_all(path~)` | 把路径切成自顶向下的各级依次 MKCOL；405（已存在）跳过、409 先 PROPFIND `Depth:0` 复核（并发创建则跳过，否则抛出原错误）；`/` 与空串不发请求；对已存在的目录幂等 |

实现见 `src/client/mkdir_all.mbt`（`collection_prefixes` + `mkcol_tolerate_existing`），
契约见 `docs/02-api.md` 的「递归建目录（`mkdir_all`）」。

## 锁支持（已实现，0.2.0；原 P1 候选条目）

RFC 4918 §9.10 / §9.11 的写锁闭环，0.2.0 起含共享锁与完整的 `If`：

| 能力 | 入口 | 说明 |
|------|------|------|
| 加锁 | `lock(path~, owner?, timeout?, depth?, scope?) -> LockResult` | 发 lockinfo；`scope` 省略 = 独占，可传 `LockScope::Shared`；`Depth::One` 本地 400，201 → `created`（lock-null） |
| 刷新 | `refresh_lock(path~, token~, timeout?) -> LockResult` | 无请求体的 LOCK + `If`，只续期不换令牌 |
| 解锁 | `unlock(path~, token~)` | 发 `Lock-Token: <令牌>` |
| 锁发现 | `get_locks(path~) -> Array[ActiveLock]` | PROPFIND `Depth:0` 读 `lockdiscovery`，`scope` 为 `LockScope` |
| 令牌 / 条件透传 | `Conditions::new(lock_token=…, if_lists=…)` | 所有写操作自动补 `If`；`IfList` / `IfCondition` 支持多令牌析取、`Not`、`[etag]`、带标签列表 |
| lock-null 清理 | `cleanup_lock_null(path~, token~) -> Bool` | 先 UNLOCK，HEAD 确认 `Content-Length: 0` 才带 `If-Match` DELETE |
| 423 自动补令牌 | `WebdavConfig::retry_on_423` | 默认关；开启后查进程内注册表（同路径 / infinity 祖先），命中则补 `If` 重试一次 |

令牌在客户端统一归一（trim + 剥一层 `<>`，`tok` / `<tok>` 等价）。
`Depth: 1` 的 LOCK 按 RFC 4918 §9.10.3 仍本地拦 400；不做 5xx / 断线退避重试，
锁注册表不跨进程。细节见 `docs/05-lock-extensions.md`、`docs/02-api.md` 的「锁」与
`docs/01-architecture.md` 第 8 节；`src/main/testdata/verify_lock.py` + `verify_if.py`
给 Python 兜底服务器补上锁表与 `If` 解析，让独占 / 共享锁、带标签 `If`、lock-null
与 423 重试在真实链路可跑（hacdias 不支持的部分按预期软跳过）。

## Range / 断点续传（已实现，0.3.0；原 P1 候选第 1 条）

RFC 7233 区间下载与断点续传，解决「`download_to_path` 失败后只能整体重下」：

| 能力 | 入口 | 说明 |
|------|------|------|
| 区间下载 | `get_file` / `download_file` / `download_to_path` 增 `range? : ByteRange` | 发 `Range` 头；服务端接受回 206（`GetResult.status` / `content_range` 区分），忽略则回 200 全量 |
| 区间构造 | `ByteRange::from(start?, end?)` / `ByteRange::suffix(length)` | `bytes=N-` / `bytes=N-M` / `bytes=-N`；`end` 为含端 |
| 断点续传 | `download_to_path_resume(path~, local_path~, etag?, …)` | 本地已有 N 字节则发 `Range: bytes=N-`，206 追加、200 回退重写、416 且已下完视为成功；`etag?` 发 `If-Range` 防服务端文件已变 |
| Content-Range 解析 | `GetResult.content_range? : ContentRange` | `bytes first-last/total` → 区间起止与资源总长（`*` 时 `total` 为 `None`） |

细节见 `docs/06-range-resume.md`；进度在续传路径按**绝对值**累计（`loaded` 从本地
已有字节起计）。不支持的形态：多区间（`bytes=0-1,5-9`）、multipart/byteranges
响应、`Accept-Ranges` 显式暴露（探测支持与否直接发一次小区间请求看是否 206 更可靠）。

## P0：协议正确性（已实现）

这些不是「缺新接口」，而是现有接口在真实服务器上会出错的地方。

| 缺口 | 现象 | 做法 |
|------|------|---------|
| 207 的 propstat 状态被丢弃 | 属性不存在（404）与值为空无法区分，`get_properties` 把失败当空串 | `ParsedProp.status` + 逐段 propstat 状态归属；`find_prop` 只认 2xx（`src/xmlutil/multistatus.mbt`） |
| 部分失败不可见 | DELETE/MOVE/COPY 返回 207 且部分条目失败时，客户端当成功 | 允许 207 + `failures_from_multistatus` → `WebdavError::Partial(operation, failures)`（`src/client/status.mbt`） |
| href 不是路径 | 服务器回绝对 URL 或带 query 的 href，`list` 显示名错、自身排除失效 | `@types.href_to_path`：剥 scheme/authority、丢 query/fragment、percent 解码、去尾斜杠 |
| `exists` 依赖 HEAD | 服务器禁用 HEAD（405/501）时探测直接失败 | HEAD 405/501 → PROPFIND `Depth:0` 回退（`src/client/probe_ops.mbt`） |
| 无法探测服务器能力 | 不知道是否支持 LOCK、允许哪些方法 | 新增 `options(path?, signal?) -> ServerCapabilities`（`dav_class` / `allow` / `server`，含 `supports` / `has_lock`） |
| Depth 固定 | `list` 只能 Depth:1，无法整树列举 | `list(path~, depth?)` + `list_recursive`（Depth:infinity） |
| 无条件请求 | 不支持 If-Match / If-None-Match，无法安全覆盖 / 避免竞态 | 全部网络方法增 `conditions?`（`Conditions::new(if_match?, if_none_match?)`） |
| 无法取消 | 长传输无法中断 | 全部网络方法增 `signal? : AbortSignal`，取消映射为 `WebdavError::Cancelled(reason?)`（`@moonhttp` 的 `AbortController` 从门面再导出） |
| 错误面太窄 | 只有 Http / Xml / Api 三类，拿不到响应体与服务端解释 | `Api` 消息附响应体前 200 字符；新增 `Partial` / `Cancelled` 分支（本轮又加 `Io`，共六类） |
| 成功响应信息丢失 | PUT/GET 只回 Unit / Bytes，ETag、Last-Modified、Location 拿不到 | `put_file -> PutResult`、`get_file -> GetResult`（**破坏性变更**，调用方加 `ignore` 或改用结果） |
| 属性读回丢失状态 | 只想看「哪些属性存在 / 失败原因」时无处可取 | `get_properties_with_status(...) -> Array[PropResult]` |

## P1：能力扩展（下一阶段候选）

按「使用频率 × 实现成本」粗排，尚未实现；已落地的原候选条目
（锁能力扩展 → 0.2.0，Range / 断点续传 → 0.3.0）移入上方各节：

1. **认证面扩展**：moonhttp 只内置 Basic（`with_auth`）。Digest 需要自己拼
   `Authorization`（自定义头可绕过，moonhttp 的 `set_if_absent` 不覆盖用户头），
   Bearer / OAuth 同理；可选做 `AuthProvider` 抽象。
2. **重定向 / 代理 / 超时策略暴露**：moonhttp 的 `max_redirects`、
   `with_proxy`、按请求 timeout 目前被客户端默认配置挡住，可按需透传。
   大文件的整跳超时（覆盖「写完整个请求体」）尤其值得暴露。
3. **属性删除与命名空间区分**：PROPPATCH 现在只 set；补 remove
   （`<D:remove>`），并让 `PropResult` 带上命名空间以免同名属性撞车。
4. **取消语义细化**：区分「用户取消」与「超时取消」（moonhttp 的
   `AbortSignal::reason()` 已能携带原因，`Cancelled(reason)` 已预留位置）。

## P2：更远的方向（按需）

- **REPORT / SEARCH / DeltaV（版本控制）**：moonhttp 的 `Method::Other` 可直接发
  任意扩展方法，XML 体可复用 xmlutil 的生成 / 解析框架，属于「加接口」而非「改架构」。
- **ACL 方法**（RFC 3744）：同上，`Other("ACL")` 可行。
- **深度 infinity 的流式解析**：现在 207 响应整体读进内存再解析，整树列举大目录时
  内存与响应体同阶；可改为 `Client::stream` + 增量 XML 解析。
- **并发与连接复用**：目前一次一个请求；批量操作（并行 `stat`、并发上传）需要
  连接池策略与限流。
- **重试与退避**：对 5xx、连接重置的自动重试与退避策略（423 的「补令牌重试一次」
  已由 `WebdavConfig::retry_on_423` 落地，见 `docs/05-lock-extensions.md`）。
- **push 型上传（Writer 数据源）**：本轮只收读取流（pull）。「边生成边推」可用官方
  `@io.MemoryReader` 桥（回调里往 `&Writer` 写）或 `@io.pipe()` + 生产者协程，
  暂不引入第二个 API。
- **目录镜像 / 同步**（更像 rclone 的 `sync` 语义）：属于上层工具而非客户端协议层；
  本轮只补了单文件的路径形态上传 / 下载。

## 与本次实现的对应关系

P0 的行、「传输形态补强」、「递归建目录」、「锁支持」与「Range / 断点续传」表已在
代码中落地，逐项见 `docs/01-architecture.md` 的「协议映射」与 `docs/02-api.md` 的
签名说明；P1 / P2 其余条目仍为路线图，**未实现**，不构成当前 API 契约。
