# 架构与协议映射

## 模块概览

- 模块：`q2316367743/webdav`（moon.mod，`source = "src"`，`preferred_target = "native"`——moonhttp 基于 moonbitlang/async，仅 native 可用）
- 依赖：`q2316367743/moonhttp@0.5.0`（HTTP 客户端，流式请求体 / 流式响应 / 取消信号）、`Milky2018/xml@0.5.0`（XML 解析）、`moonbitlang/async@0.22.2`（测试与演示程序；`src/client` 另引其 `io` 与 `fs` 子包——后者只被路径形态的传输使用）
- 根包直接 import moonhttp，是为了从门面再导出 `AbortController` / `AbortSignal`（取消能力要让调用方拿到具体类型）

## 包结构与依赖方向

```
src/webdav.mbt + src/moon.pkg   根包（门面）：using 重导出 + 薄包装函数
        │
        ├──> src/client/   WebdavClient 与全部操作
        │        client（send / url_for / 配置 / apply_conditions）· status（状态码判定 / 207 失败提取）
        │        probe_ops（OPTIONS / exists）· dir_ops · transfer_ops
        │        file_ops（上传三形态：put_file / put_file_stream / put_file_from_path）
        │        download_ops（get_file 全量 / download_file 回调 / download_to_path 写盘，
        │                    三者带 range? 区间下载；write_stream_to_file 公共写盘循环）
        │        download_resume（download_to_path_resume 断点续传状态机）
        │        lock_ops（LOCK / refresh_lock / UNLOCK / get_locks）
        │        lock_parse（lockdiscovery 解析）· lock_cleanup（lock-null 清理）
        │        lock_registry（已知锁注册表 / 423 覆盖判定）· if_header（If 头渲染）
        │        ├──> src/types/    公共类型（配置 / FileInfo / Progress / 错误 /
        │        │                  protocol：Depth·Conditions·IfList·IfCondition·
        │        │                  ServerCapabilities·PropResult·ResourceFailure /
        │        │                  result：Put·Get / range：ByteRange·ContentRange /
        │        │                  lock：ActiveLock·LockResult·LockScope）
        │        │                  + percent 编解码与 href 归一
        │        └──> src/xmlutil/  PROPFIND / PROPPATCH / lockinfo 请求体生成、
        │                  multistatus（207 解析）· lockinfo（lockdiscovery 解析）·
        │                  resource（FileInfo 装配）
        │                 └──> src/types/
        └──> src/types/

src/main/   演示程序（executable，.moonignore 已排除，不随包发布）
```

依赖严格单向，无环。根包只有重导出（RL-02：根目录 / 门面不放业务逻辑）。

## 操作 ↔ HTTP 协议映射

所有请求经 moonhttp `Client::request`（XML / 流式 PUT / 无 body 操作）或 `Client::stream`（流式下载）；
实例默认配置带 `with_validate_status(fn(_) { true })`——**状态码一律由本层判定**，
`WebdavError::Api` 携带中文人读消息 + 响应体摘要（见 `status_message` / `body_hint`）。

| 操作 | 方法 | 关键头 | 请求体 | 期望状态码 |
|------|------|--------|--------|-----------|
| `put_file` | PUT | Content-Type（可选）、If-Match / If-None-Match | 流式请求体（BytesReader 拉取，定长 Content-Length） | 200/201/204 |
| `put_file_stream` | PUT | 同上 | 调用方给的 `&@io.Reader`（定长或 chunked） | 200/201/204 |
| `put_file_from_path` | PUT | 同上 | 本地文件句柄（定长 = 文件长度，边读边发） | 200/201/204 |
| `get_file` | GET | Range（`range?`，可选）、If-Match / If-None-Match | - | 200；带 Range 时 206（回 `content_range`） |
| `download_file` | GET | 同上 | - | 200（流式回调）；带 Range 时 206 |
| `download_to_path` | GET | 同上 | - | 200（流式写盘）；带 Range 时 206 |
| `download_to_path_resume` | GET | Range: bytes=N-、If-Range（`etag?`，可选） | - | 206（追加）/ 200（回退重写）/ 416（= 本地已完整时成功） |
| `mkdir` | MKCOL | - | - | 201 |
| `mkdir_all` | MKCOL（逐级） | - | - | 201；405 = 已存在跳过；409 = PROPFIND 复核后跳过或抛出 |
| `delete` | DELETE | - | - | 200/204/207（207 有失败条目 → `Partial`） |
| `exists` | HEAD | - | - | 200/204/304→true，404→false，405/501→回退 PROPFIND |
| `options` | OPTIONS | - | - | 200/204（读 `DAV` / `Allow` / `Server`） |
| `list` | PROPFIND | Depth:0/1/infinity, Content-Type: xml | allprop XML | 207 |
| `stat` | PROPFIND | Depth:0 | allprop XML | 207 |
| `get_properties(_with_status)` | PROPFIND | Depth:0 | named-prop XML | 207 |
| `set_properties` | PROPPATCH | Content-Type: xml | propertyupdate XML | 207 |
| `move_path` | MOVE | Destination(绝对URL), Overwrite T/F, Depth | - | 201/204/207（同上） |
| `copy_path` | COPY | 同上 | - | 201/204/207（同上） |
| `lock` | LOCK | Content-Type: xml、Timeout(可选)、Depth(可选) | lockinfo XML（`scope` 省略 = 独占，可传共享） | 200/201（201 = 创建 lock-null） |
| `refresh_lock` | LOCK | If: (\<令牌\>)、Timeout(可选) | - | 200 |
| `unlock` | UNLOCK | Lock-Token: \<令牌\> | - | 200/204 |
| `get_locks` | PROPFIND | Depth:0、Content-Type: xml | named-prop `lockdiscovery` | 207 |
| `cleanup_lock_null` | UNLOCK → HEAD → DELETE | Lock-Token；DELETE 可带 If-Match | - | 200/204（HEAD 404 时直接返回 false） |

条件头由 `Conditions` 统一生成（`apply_conditions`）：`if_match` / `if_none_match` 落
`If-Match` / `If-None-Match`，`lock_token` 落 `If: (<令牌>)`（RFC 4918 §10.4.1 的
Coded-URL 形态，尖括号必需），`if_lists` 落完整的 `If` 列表语法（可带资源标签）；
取消信号经 `Client::request(cfg, signal?)` 透传（`send` 的可选 `signal?` 参数）。
Basic Auth 由 moonhttp `with_auth` 自动携带（`Authorization: Basic ...`）。

## 关键实现决策

### 1. URL 与路径

- `base_url` 规范化：去尾斜杠（`normalize_base`）。
- 用户 `path` 先 percent 编码（`@types.percent_encode_path`：保留 `/` 与 unreserved，
  其余逐 UTF-8 字节 `%XX`，空格为 `%20`）再拼到 base 后，即请求 URL。
- MOVE/COPY 的 `Destination` 必须是绝对 URL，直接用同一拼法。
- 服务端返回的 href 与用户 path 不同纲：用 `@types.href_to_path` 归一再比
  （剥 `scheme://authority`、丢 `?query` / `#fragment`、percent 解码、去尾斜杠），
  因此绝对 URL 形式的 href 也能正确排除自身、算出显示名。

### 2. 状态码接管与部分失败

moonhttp 默认非 2xx 抛 `HttpError`，但 WebDAV 中 404（exists）/ 207（PROPFIND 与
带部分失败的 DELETE/MOVE/COPY）需要本层分支处理，故统一放行后由 `check_status`
按 ok 集合转 `WebdavError::Api(status~, operation~, message~)`；失败消息附响应体前
200 字符（`body_hint`），便于定位服务端解释。

207 是「可能部分失败」的信号：`check_status_allow_partial` 解析 multi-status，
`failures_from_multistatus` 收集 response 级 status 非 2xx 的条目，非空则抛
`WebdavError::Partial(operation, failures)`——**不再把部分失败当成功**。

### 3. 上传三形态（moonhttp 0.5 流式请求体）

moonhttp 0.5 支持流式请求体（`Config::with_data_from_stream(reader, content_length?)`，
见 moonhttp docs/20）。三条入口只差「数据从哪来」，共用 `src/client/file_ops.mbt` 的
私有 `send_put`：头 / 条件请求 / 进度接线 / 状态码判定只有一份实现。

| 入口 | 数据源 | `content_length` | 分帧 |
|------|--------|------------------|------|
| `put_file` | 内存 `Bytes` → `priv struct BytesReader` | `data.length()` | 定长 |
| `put_file_stream` | 调用方给的 `&@io.Reader` 原样转交 | `content_length?`（`None` → chunked） | 定长或 chunked |
| `put_file_from_path` | `@fs.open(ReadOnly)` 拿到的 `File`（自身实现 `@io.Reader`） | `File::size()` 快照 | 定长 |

`BytesReader`（实现 `@io.Reader` 的拉取式适配器：`_direct_read` 按块把视图切片搬给
泵循环，不引入协程与管道、不拷贝整份数据）只服务 `put_file`。上传进度每 64 KiB
分块回调、声明长度时 `total` 已知，经 `Config::with_on_upload_progress` 接线
（`adapt_progress` 适配，与下载侧同一套 `Progress`）。

包外实现 `@io.Reader` 依赖 async 的 `ReaderBuffer` 公开构造与 trait 的两个
必需方法——moonhttp 的 stream_wire_test 已把这一前提钉死；`src/client/moon.pkg`
因此按包抑制 `alert_internal`。曾用的 `BinaryBodyTransport` 传输层装饰器
（moonhttp 0.4 时代的全量注入方案）已随本次升级整体移除。

路径形态的本地文件保障（`open_local_source`）：先 `@fs.open`，`kind() == Directory`
直接判错；`size()` 取长度快照；请求无论成功失败都 `file.close()`（try/catch 包住
`send_put` 后用 `Result` 汇聚）。本地打不开 / 是目录 → `WebdavError::Io`，此时
**还没发出任何请求**。

### 4. 下载三形态与区间 / 续传

- `get_file`：`Client::request` 全量读进内存，回 `GetResult`（内容 + 响应元数据 +
  206 时的 `content_range`）；
- `download_file` / `download_to_path`：共用私有 `open_get_stream`（组装 GET →
  `Client::stream` → 非期望状态码先 `close()` 再抛 `Api`），再循环
  `read_some(max_len=chunk_size)`：每块交 `on_chunk`（内存消费）或
  `file.write(chunk)`（写盘，async 只能在内部做），`loaded` 累计回调 `on_progress`，
  `total` 取响应头 `Content-Length`（缺失为 `None`）。
- **带 `range?` 时**：请求附 `Range` 头，期望状态码集合变 `[200, 206]`——服务端
  忽略 Range 回 200 全量也照常成功，调用方以 `status` / `GetResult.content_range`
  区分。写盘循环抽成 `write_stream_to_file(stream, local_path~, append~, offset~,
  total~, …)`，`download_to_path` 与续传共用（`append=false, offset=0` 即原路径）。
- 结束 / 异常都保证 `close()`（try/catch 包裹 + Result 汇聚）；读流异常经
  `map_http_error` 归一（取消 → `Cancelled`，其余 → `Http`）；写盘异常 → `Io`。
- `download_to_path` 刻意**先请求、拿到 200 / 206 才建本地文件**：404 等失败不会
  清空本地同名文件；失败或取消时留下部分内容（不自动删除）。
- `download_to_path_resume`（`download_resume.mbt`）把「失败残留」变成续传起点：
  `offset` = 本地已有字节数（0 时委托全量下载），发 `Range: bytes=N-` +
  可选 `If-Range`；206 校验 `Content-Range` 起点后 `@fs.open(append=true)` 追加，
  200 截断重写，416 且 `bytes */total` 的 total == offset 视为已完整成功；
  总长已知时写完校验本地大小，进度按绝对值累计。详见 `docs/06-range-resume.md`。

### 5. XML 解析（src/xmlutil）

`NamespaceReader` 事件流 + local_name 栈：按 `local_name` + `namespace_uri == Some("DAV:")`
匹配（不依赖前缀写法，默认命名空间响应同样正确）。要点：

- `resourcetype` 内出现 `collection`（Start 或 Empty）→ 目录；
- prop 下的叶子元素累积 Text/CData 为属性值（实体由库解码）；
- **propstat 的状态码逐段归属**：属性先进 `pending_props`，遇到 `</D:propstat>`
  时按该段的 `<D:status>` 打标（状态行常排在 `<D:prop>` 之后），404 与「空值」因此
  可区分；response 级 `<D:status>` / `<D:responsedescription>` 单独记在
  `ParsedResource.status` / `.responsedescription`（207 部分失败靠它）；
- `find_prop` 只认 2xx 属性；
- 空自闭合叶子（如 `<D:getetag/>`）记空串属性；
- 文件行数上限（RL-04）把 FileInfo 装配拆到 `src/xmlutil/resource.mbt`
  （`display_name` / `find_prop` / `to_file_info` / `parse_prop_int`）。

### 6. 能力探测与 HEAD 回退（src/client/probe_ops.mbt）

`options(path?, signal?)` 解析 `DAV`（缺省 `[1]`）/ `Allow`（ASCII 大写）/ `Server`，
`ServerCapabilities::supports`（大小写不敏感）与 `has_lock` 供调用方按能力选择路径。

`exists` 先用 HEAD（200/204/304 → true，404 → false）；服务端 405/501 时改写
PROPFIND `Depth:0` allprop 再判定（404 → false，其余重抛）。这样兼容禁用 HEAD 的服务器。

### 7. 条件请求与取消

`Conditions` 承载 `If-Match` / `If-None-Match` / `lock_token`（锁令牌 → `If: (<令牌>)`），
由 `apply_conditions` 写进请求配置，所有写 / 读操作都接受 `conditions?`。取消走 moonhttp 的 `AbortSignal`：
`send` 把 `signal?` 转发给 `Client::request`，`map_http_error` 用
`HttpError::is_cancelled()` 把取消与网络错误分开，取消时取 `AbortSignal::reason()`
填进 `Cancelled(reason?)`。

### 8. 锁（`src/client/lock_ops.mbt` / `lock_parse.mbt` / `lock_cleanup.mbt` / `lock_registry.mbt` / `if_header.mbt`）

- `lock` 发 `lockinfo` 体（`src/xmlutil/lockinfo.mbt` 生成），`scope` 省略 = 独占，
  传 `LockScope::Shared` 生成 `<D:shared/>`；可带 `owner` / `Timeout` / `Depth`；
  `Depth::One` 在发请求前本地拒绝（RFC 4918 §9.10.3 只允许 0 与 infinity），
  `depth` 缺省不发头（服务端按 infinity 处理）。201 = 服务端为未映射路径创建了
  lock-null 资源，`LockResult.created` 标记。
- 令牌来源：响应头 `Lock-Token` 优先，其次响应体首个 `activelock`；两者都无 →
  `Api(operation="LOCK", message="响应缺少锁令牌…")`。响应头自带尖括号，入库前经
  `normalize_lock_token` 归一（trim + 剥一层 `<>`），故 `tok` / `<tok>` 等价，
  拼回去不会出现 `<<tok>>`。响应体 XML 非法但有 `Lock-Token` 头时仍算成功
  （元数据退化为 `LockScope::Exclusive`），没有头才抛 `Xml`。
- 作用域以服务端响应为准：响应体里的 `<D:lockscope>` 优先，缺省时用请求的作用域
  回填（`parse_lockdiscovery(..., default_scope?)`，`refresh_lock` 不传 → 独占）。
- `refresh_lock` = 无请求体的 LOCK + `If: (<令牌>)`（刷新不换令牌，`created` 恒 false）；
  `unlock` 发 `Lock-Token: <令牌>`；两者令牌归一后为空时本地抛 `Api(400)`。
- `If` 头由 `if_header.mbt` 渲染：`if_lists`（列表内合取、列表间析取、`Not`、
  `[etag]`、带标签列表）在前，`lock_token` 作为无标签列表追加在后；混用带标签与
  无标签列表、空列表、空资源标签、空令牌都在发包前回 `Api(400)`。
- `cleanup_lock_null` 收尾 §7.3 的空资源：先 `unlock`（404/409 视为锁已消失），
  再 HEAD 确认 `Content-Length` 明确为 0，才带 `If-Match` DELETE；长度未知或非 0
  返回 false，宁可不删也不误删。
- 423 自动补令牌重试：`WebdavConfig::retry_on_423` 打开时，`send` 在 423 后查
  `lock_registry` 的已知锁（同路径或 `depth == "infinity"` 的祖先），命中则复制
  条件补 `If` **重试一次**；`MOVE` / `COPY` 的目标锁不在此范围，一次性流
  （`put_file_stream` / `put_file_from_path`）不可重放也不参与。注册表最多 256 条，
  是进程内缓存，不代表服务端真实状态。
- `get_locks` 走 `send` 而非私有 `send_propfind`：`parse_multistatus` 会把嵌套的
  `lockdiscovery` 压成叶子文本，锁发现需要结构化解析，故复用 `parse_lockdiscovery`
  直接吃 207 响应体（请求体由 `propfind_named_body(["lockdiscovery"], ns_uri="DAV:")` 生成）。
- 写操作带 `Conditions::new(lock_token=…)` 或 `if_lists` 时自动补 `If`，PUT/DELETE/
  MOVE/COPY/PROPPATCH/MKCOL 因此都获得锁令牌与条件支持；服务端 423 → `Api`
  （`status_message` 已含「资源被锁定」），412 → `Api`；除显式开启的 423 重试外
  本层不自动重试。扩展细节见 `docs/05-lock-extensions.md`。

## 测试策略

- 白盒：`src/types/urlcodec_wbtest.mbt`（编解码、`href_to_path` 归一形态）、
  `src/xmlutil/xmlutil_wbtest.mbt`（请求体形状、前缀差异 / 中文 href / 自定义属性解析、
  propstat 200/404/403 逐段归属、response 级 status）、
  `src/xmlutil/lockinfo_wbtest.mbt`（lockinfo 体形状与转义、共享锁请求体、
  lockdiscovery 解析：裸数字 / `opaquelocktoken:` 令牌、`shared` / `exclusive`
  作用域与缺省回填、owner 为空的取舍、前缀与命名空间差异、非法 XML 抛错）、
  `src/types/lock_wbtest.mbt`（`LockScope` 互转、`IfCondition` / `IfList` 构造、
  `Conditions::with_lock_token` 拷贝语义）、
  `src/client/lock_registry_wbtest.mbt`（路径规范化、祖先判定、去重、响应 / 请求
  Depth 优先级、256 条上限与后进先出）。
- 黑盒：`src/client/*_test.mbt` 用 `MockTransport`（moonhttp/transport 公开）
  断言每个操作的请求形状（方法 / URL / 头 / body）与错误分支；`probe_ops_test`
  覆盖 OPTIONS 解析与 HEAD 回退，`transfer_ops_test` 覆盖 207 部分失败（`Partial`）、
  `dir_ops_test` 覆盖 Depth 与绝对 href 归一、`file_ops_test` 覆盖三种上传形态
  （含 `Io` 分支：本地文件不存在 / 路径是目录时**不发请求**）、
  `download_ops_test` 覆盖 `get_file` / `download_file` / `download_to_path`
  （分块写盘、覆盖已有文件、404 不创建本地文件、父目录不存在 → `Io`），
  `download_range_test` 覆盖区间下载与断点续传（Range 头形状、206 `content_range`
  解析、200 回退、416 分支、resume 的追加 / 重写 / 已完成 / 起点不符 / 缺头，
  以及 `ByteRange` 渲染与 `Content-Range` 解析单元）；
  `lock_ops_test` 覆盖 LOCK 请求形状与令牌来源优先级、刷新 / 解锁的头形态与
  空令牌本地拦截、锁发现请求体与解析、`If` 与 `If-Match` 共存、423 错误映射；
  `if_header_test` 覆盖 `If` 渲染（多令牌 / `Not` / `[etag]` / 带标签 / 与
  `lock_token` 组合）与四类本地 400（且断言零请求），`lock_scope_test` 覆盖共享锁
  请求体与作用域回填优先级、`lock_retry_test` 覆盖 423 重试的命中 / 不命中 / 只重试
  一次 / 可重放性，`lock_cleanup_test` 覆盖 HEAD 判定与 `If-Match` 删除；
  `src/webdav_test.mbt` 从 `@webdav` 门面走完整操作流（`from_responses` 按序供响应，
  含 OPTIONS、门面流式上传 + 下载到本地路径、门面重导出 / 取消信号用例）。
  坑：`MockTransport` 记录 `RequestBody::Stream` 引用但**不消费**，
  `MemoryReader` / `PipeRead` 的生产者协程会永久阻塞并在收尾时报 dead lock，
  故 mock 用例统一用 `temp_reader`（临时文件 + `@fs.File`）造 pull 型流。
- 当前规模：`moon test --target native` 164 个用例全绿。
- 一键测试：`bash src/main/run_tests.sh`——自动起 WebDAV 服务端（优先
  brew 的 `webdav`，回退 `src/main/testdata/verify_server.py`）、跑
  `moon test` 全套测试、连真实服务端跑 `moon run src/main` 全流程
  （含递归建目录 `mkdir_all` 建多级中文路径 + 幂等重跑，Range 区间 206 与
  断点续传拼接比对，以及 testdata 真实文件往返：`binary.bin` 100 KiB 走 `put_file_from_path`
  本地路径上传（64 KiB 分块进度）→ `download_to_path` 写盘 → 逐字节比对，
  以及 `put_file` 全量形态 + 中文文件名端到端编码），
  最后关闭服务端并汇总 PASS/FAIL。真实链路还会跑一遍锁流程（LOCK → 无令牌 PUT
  被拒 423 → 带令牌 PUT → 刷新 → 锁发现 → UNLOCK，另加共享锁、带标签 `If`、
  lock-null 清理与 423 自动补令牌重试；不支持的能力按中文说明软跳过，不计失败，
  服务端未声明 Class 2 时整段跳过）。
  `src/main/testdata/` 即真实测试文件（样本文件 + 兜底服务端脚本）。

  兜底服务器 `verify_server.py` 刻意对齐协议细节：`Depth:1` 不递归、
  `Depth:infinity` 才整树、支持 `propname`、没存过的死属性回 404 propstat；
  `run_tests.sh` 以 `VERIFY_NO_HEAD_PREFIX=/demo` 启动它，让 `/demo` 下的 HEAD
  返回 405，从而在真实链路上覆盖 `exists` 的 PROPFIND 回退分支。
  锁相关的处理器拆在 `verify_lock.py`（内存锁表 + `LockMixin`）：令牌带
  `opaquelocktoken:` 前缀（hacdias 用裸数字，两条路径互补）、`If` 里的令牌
  必须带尖括号、无匹配令牌的写操作回 423、同路径可有多把共享锁而独占锁与任何锁
  互斥、LOCK 未映射路径时创建空文件并回 201（lock-null）、PROPFIND 能真的读回
  `lockdiscovery`（hacdias 只回空元素，故 demo 对该步做「读不到就跳过」处理）。
  `If` 的解析子集单独拆在 `verify_if.py`（带标签列表 / `Not` / `[etag]` / 资源标签
  继承），由 `verify_lock.py` 导入；hacdias 对共享锁回 501、对带标签 `If` 回 412，
  demo 按预期软跳过。

  手工起服务端（brew `webdav`，hacdias）时的注意：v5 的权限字母是
  C/R/U/D（Create/Read/Update/Delete），不是旧版的 R/W/D，写 RWD 会启动失败：

  ```yaml
  address: 127.0.0.1
  port: 8082
  directory: /tmp/webdav-brew-test
  users:
    - username: demo
      password: demo
      permissions: CRUD
  ```

  已知差异：hacdias/webdav 的 PROPPATCH 返回 207 但**不持久化死属性**
  （PROPFIND 按名读回时 propstat 带 404），`get_properties` 表现为属性不存在；
  `rclone serve webdav` 同样基于 x/net/webdav，死属性行为一致。回退的
  Python 服务器则把死属性存在内存里，能读回（`run_tests.sh` 两条路径
  的差异会体现在 demo 的 `PROPPATCH color=` 一行）。

## 注意事项

- 上传 / 下载各有三种形态，选择口径：内容已在内存 → `put_file` / `get_file`；
  数据源是流或磁盘上的大文件 → `put_file_stream` / `put_file_from_path`；
  要落盘 → `download_to_path`（同步 `on_chunk` 回调里 await 不了 async 写盘）；
  要区间取回 → 三个下载方法的 `range? : ByteRange`；要从中断处继续 →
  `download_to_path_resume`（带 `etag?` 发 `If-Range` 防服务端文件已变），
  详见 `docs/06-range-resume.md`；
- `mkdir` 是单级（409 = 父不存在），递归创建用 `mkdir_all`（`src/client/mkdir_all.mbt`）：
  逐级 MKCOL（`collection_prefixes` 切前缀），405 视为已存在跳过、409 先 PROPFIND
  `Depth:0` 复核——存在（并发创建）则跳过，不存在才抛出，避免父级不是集合时
  「逐级跳过、最后假装成功」；`/` 与空串不发请求，重复 / 结尾斜杠折叠；
- 锁支持**独占与共享写锁**、完整的 `If`（多令牌列表析取、列表内合取、`Not`、
  `[etag]`、带标签列表 `</url> (<令牌>)`）、lock-null 清理（`cleanup_lock_null`）
  与可选的 423 自动补令牌重试（`WebdavConfig::retry_on_423`，默认关）；
  `Depth: 1` 的 LOCK 按 RFC 4918 §9.10.3 本地拦 400，5xx / 断线不做退避重试，
  锁注册表只是进程内缓存；`has_lock()` 仍是纯能力探测（`DAV: 2` 或 `Allow: LOCK`），
  细节见 `docs/05-lock-extensions.md`；
- 本地文件系统错误走 `WebdavError::Io`（`to_string()` 为「本地文件错误: …」），
  与 HTTP 层错误分开；`WebdavErrorDetail` 因此从五类变六类（下游穷尽 match 需补分支）；
- 扩展方法（REPORT / ACL 等）在 moonhttp 侧可直接经 `Method::Other(name)` 发送，
  架构上不需要改动，属于「加接口」；
- 根包 `DEFAULT_TIMEOUT_MS` / `DEFAULT_PROP_NS` 是从子包常量再导出的同值 const；
- 缺口清单与 P1 / P2 路线图见 `docs/03-gap-analysis.md`（标注「未联网核实」）。
