Skip to content

fix(webdav): correct COPY/MOVE semantics, dead property persistence, and lock enforcement - #2903

Open
devLythen wants to merge 8 commits into
OpenListTeam:mainfrom
devLythen:main
Open

fix(webdav): correct COPY/MOVE semantics, dead property persistence, and lock enforcement#2903
devLythen wants to merge 8 commits into
OpenListTeam:mainfrom
devLythen:main

Conversation

@devLythen

@devLythen devLythen commented Aug 4, 2026

Copy link
Copy Markdown

Summary / 摘要

修复 WebDAV COPY 方法稳定返回 500 Internal Server Error 的问题,同时修复了 COPY/MOVE 状态码语义、死属性持久化、锁路径解析和共享锁支持。

  • COPY 同目录/跨目录、绝对/相对 Destination 均正常工作,覆盖语义符合 RFC 4918

  • PROPPATCH 设置的自定义属性可持久化,PROPFIND 可检索,MOVE 时自动迁移

  • 锁检查使用用户路径解析后的实际资源路径,支持共享锁

  • 拒绝含非法 XML 声明的 PROPFIND 请求体

  • This PR has breaking changes.
    / 此 PR 包含破坏性变更。

  • This PR changes public API, config, storage format, or migration behavior.
    / 此 PR 修改了公开 API、配置、存储格式或迁移行为。

  • This PR requires corresponding changes in related repositories.
    / 此 PR 需要关联仓库同步修改。

Related repository PRs / 关联仓库 PR:

  • OpenList-Frontend:
  • OpenList-Docs:

Related Issues / 关联 Issue

Testing / 测试

  • go test ./server/webdav ./internal/fs
  • Manual test / 手动测试: Litmus 全量测试套件通过公网 Nginx HTTPS 入口执行
功能 修复前 完成进度
basic 16/16 16/16
copymove 9/13 (COPY 500、MOVE 覆盖语义失败) 13/13
props 9/14 (PROPPATCH 403、namespace 非法通过) 30/30
locks 24/34 (锁不生效、共享锁不支持) 40/41
http 3/3 3/3

其中 locks.fail_complex_cond_put 未通过

该测试要求锁条件中校验无效 ETag,上游 golang.org/x/net/webdav 的 lookup 函数同样标注 TODO: support Condition.Not and Condition.ETag 未实现。主流 WebDAV 客户端不使用 ETag+锁令牌组合条件,无功能影响。

Checklist / 检查清单

  • I have read CONTRIBUTING.
    / 我已阅读 CONTRIBUTING
  • I confirm this contribution follows the repository license, contribution policy, and code of conduct.
    / 我确认此贡献符合仓库许可证、贡献规范和行为准则。
  • I have formatted the changed code with gofmt, go fmt, or prettier where applicable.
    / 我已按适用情况使用 gofmtgo fmtprettier 格式化变更代码。
  • I have requested review from relevant maintainers or code owners where applicable.
    / 我已在适用情况下请求相关维护者或代码所有者审查。

Signed-off-by: Lythen <intro-iu@outlook.com>
Signed-off-by: Lythen <intro-iu@outlook.com>
Signed-off-by: Lythen <intro-iu@outlook.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the server’s WebDAV behavior by aligning COPY/MOVE responses and lock handling with RFC 4918 expectations, and by introducing persistent “dead property” storage so PROPPATCH/PROPFIND can round-trip custom properties across requests and MOVE operations.

Changes:

  • Add shared-lock support and adjust lock confirmation to validate against the user-resolved resource path.
  • Persist dead WebDAV properties in the DB (new model + migration) and surface them via PROPFIND; migrate them on MOVE.
  • Fix COPY/MOVE target naming semantics by introducing a named-copy path (CopyTo) and extending transfer-task metadata to carry the destination name.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
server/webdav/xml.go Updates lockinfo parsing/output; changes PROPFIND body parsing/validation.
server/webdav/webdav.go Adjusts request dispatch, lock confirmation logic, and property lookup call signatures.
server/webdav/prop.go Implements DB-backed dead property persistence and MOVE migration.
server/webdav/lock.go Extends lock model and in-memory lock system to support shared locks.
server/webdav/file.go Refines MOVE/COPY filesystem behavior (destination validation, overwrite semantics, named copy).
internal/model/webdav_property.go Adds DB model for persisted dead properties.
internal/db/db.go Auto-migrates the new WebDAVProperty table.
internal/fs/fs.go Adds CopyTo API for named COPY operations.
internal/fs/other.go Extends transfer task metadata with DstName.
internal/fs/copy_move.go Threads dstName through transfer execution and stream naming to preserve destination name.
Suppressed comments (1)

server/webdav/file.go:126

  • copyFiles verifies that dstDir exists but does not verify it is a directory. If dstDir exists as a file, COPY should fail with 409 (Conflict) rather than attempting a copy into a non-collection path.
	if _, err = fs.Get(ctx, dstDir, &fs.GetArgs{}); err != nil {
		if errs.IsObjectNotFound(err) {
			return http.StatusConflict, err
		}
		return http.StatusMethodNotAllowed, err

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/webdav/prop.go
Comment thread server/webdav/file.go Outdated
Comment thread server/webdav/xml.go Outdated
Signed-off-by: Lythen <intro-iu@outlook.com>
…D body

Signed-off-by: Lythen <intro-iu@outlook.com>
PIKACHUIM

This comment was marked as outdated.

@devLythen

Copy link
Copy Markdown
Author

P1.1 数据库迁移脚本

项目使用 GORM AutoMigrate,建表在 internal/db/db.go 中已有 new(model.WebDAVProperty) 注册。无独立SQL迁移目录,也不依赖特定数据库引擎的 DDL。GORM 的 AutoMigrate 对所有支持的引擎(SQLite / MySQL / PostgreSQL)行为一致,不会产生额外问题。

P1.2 LIKE 转义缺反斜杠

已修复。当前转义为 \ -> \\ , % -> \% , _ -> \_,按此顺序确保不会产生二次转义。

P1.3 moveDeadProps 失败未处理

已处理,MOVE 操作在 HTTP 层面正确失败返回 500,不会出现数据不一致。

P2.1 重试次数过多

调整为 10 次。SQLite "database is locked" 在实际负载下 2 秒内足够恢复,超时直接向上层返回错误由 WebDAV handler 转为 500。

P2.2 hasEmptyNamespacePrefix 手写解析器

该函数仅扫描 xmlns: 前缀和紧随其后的空引号对("" 或 '')。注释和CDATA中出现此模式不会产生安全后果——最多导致合法的 PROPFIND 被误拒为400,但实际客户端不会在注释中嵌入空命名空间声明。保持当前实现以规避引入完整 XML 解析器的复杂性。

P2.3 时间戳字段

WebDAVProperty 表仅作为键值存储,属性本身无创建/修改时间的语义需求。如有调试需要可后续追加。

@devLythen

Copy link
Copy Markdown
Author

@PIKACHUIM

@PIKACHUIM PIKACHUIM left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏 感谢贡献

感谢 @devLythen 提交此PR!我已完成代码评审,以下是评审结果。


🤖 AI 自动审核声明

本评审报告由 AI 自动生成,当前使用 Claude Opus 5 模型进行分析。

⚠️ AI 分析结果仅供参考,可能存在误判或遗漏。如您发现任何问题或有不同意见,欢迎随时提出讨论和纠正。

⚠️ 重要提醒:即使 AI 评审认为代码质量良好且建议合并,最终是否合并仍需由项目维护者进行人工判定。项目维护者会综合考虑代码质量、项目规划、技术方向、团队资源等多方面因素做出决策。


📖 PR背景与需求

PR标题:fix(webdav): correct COPY/MOVE semantics, dead property persistence, and lock enforcement

关联Issue:无明确关联issue

需求说明
修复 WebDAV 服务的多个严重功能问题:

  1. WebDAV COPY 方法稳定返回 500 Internal Server Error
  2. PROPPATCH 设置的自定义属性(死属性)无法持久化,PROPFIND 无法检索
  3. WebDAV 锁机制不生效,锁路径解析错误
  4. 不支持共享锁(shared locks)
  5. COPY/MOVE 操作的 HTTP 状态码语义不符合 RFC 4918 规范

预期目标

  1. COPY 操作支持同目录/跨目录、绝对/相对 Destination,覆盖语义符合 RFC 4918
  2. 死属性可持久化到数据库,支持 PROPFIND 检索和 MOVE 时自动迁移
  3. 锁检查使用正确的用户路径解析后的实际资源路径
  4. 支持共享锁(shared locks)
  5. 拒绝含非法 XML 声明的 PROPFIND 请求体
  6. 通过 Litmus WebDAV 测试套件验证(copymove 13/13, props 30/30, locks 40/41)

📋 问题摘要

  • 功能完整性高:修复了 WebDAV 的核心功能缺陷,大幅提升标准兼容性
  • 测试覆盖充分:通过 Litmus 测试套件验证,从 61/80 提升到 102/103
  • ⚠️ 数据库迁移影响:新增 webdav_properties 表,需要数据库迁移
  • 💡 代码质量建议:部分代码逻辑可以优化,错误处理需加强

📂 逐文件分析

internal/db/db.go

改动意图
注册新的 WebDAV 死属性数据模型,使其能够自动创建数据库表。

代码修改逻辑

  • AutoMigrate 中添加 new(model.WebDAVProperty)
  • 这会在数据库初始化时自动创建 webdav_properties

合理性评估

优点

  1. 遵循项目现有的数据库迁移模式
  2. 自动化表结构管理,无需手动 SQL

⚠️ 注意事项

  1. 这是一个破坏性变更,需要数据库迁移
  2. 现有用户升级时会自动创建新表,但 PR 描述中标记为"修改了存储格式"是正确的
  3. 建议在文档中说明升级注意事项

internal/model/webdav_property.go(新文件)

改动意图
定义 WebDAV 死属性的数据库模型,用于持久化用户自定义的 WebDAV 属性。

代码修改逻辑

type WebDAVProperty struct {
    ID        uint   `gorm:"primaryKey"`
    Path      string `gorm:"index:idx_path_ns_name,priority:1"`
    Namespace string `gorm:"index:idx_path_ns_name,priority:2"`
    Name      string `gorm:"index:idx_path_ns_name,priority:3"`
    Lang      string
    InnerXML  []byte
}
  • 使用复合索引 (Path, Namespace, Name) 确保查询性能
  • InnerXML 存储属性的 XML 内容

合理性评估

优点

  1. 数据模型设计合理,索引策略正确
  2. 复合索引覆盖了主要查询场景(按路径查找属性)
  3. 字段命名清晰,符合 WebDAV 规范

💡 建议

  1. 添加创建/更新时间字段:方便调试和数据维护

    CreatedAt time.Time
    UpdatedAt time.Time
  2. 考虑添加唯一约束:防止重复插入相同的属性

    `gorm:"uniqueIndex:idx_path_ns_name"`

internal/fs/copy_move.go

改动意图
支持 WebDAV COPY 操作中的"命名复制"功能,允许复制时指定目标文件名。

代码修改逻辑

  1. 新增 dstName 参数(第103行):

    • transfer 函数新增 dstName string 参数
    • 允许在复制时指定目标文件名,而不是使用源文件名
  2. 同目录复制的重命名处理(第120-125行):

    if err == nil && dstName != "" {
        srcObjName := stdpath.Base(srcObjActualPath)
        if srcObjName != dstName {
            err = op.Rename(ctx, srcStorage, stdpath.Join(dstDirActualPath, srcObjName), dstName)
        }
    }
    • 当驱动支持原生 Copy 且 dstName 不为空时,先复制,再重命名
  3. 目录复制时的命名处理(第199-205行):

    • 如果指定了 dstName,覆盖默认的源文件名
    • 创建目标目录时使用新名称
  4. 文件流复制时的命名(第265-268行):

    streamObj := srcObj
    if t.DstName != "" {
        streamObj = &model.ObjWrapName{Name: t.DstName, Obj: srcObj}
    }
    • 使用包装对象替换文件名

合理性评估

优点

  1. 实现了 WebDAV COPY 的"命名复制"语义
  2. 支持驱动原生 Copy + Rename 的优化路径
  3. 兼容了目录和文件两种场景

⚠️ 潜在问题

  1. 重命名失败的回滚:第120-125行的逻辑中,如果 Copy 成功但 Rename 失败,会留下一个错误名称的文件,没有回滚机制。建议添加:

    if err == nil && dstName != "" {
        srcObjName := stdpath.Base(srcObjActualPath)
        if srcObjName != dstName {
            copiedPath := stdpath.Join(dstDirActualPath, srcObjName)
            err = op.Rename(ctx, srcStorage, copiedPath, dstName)
            if err != nil {
                // 回滚:删除已复制的文件
                _ = op.Remove(ctx, srcStorage, copiedPath)
            }
        }
    }
  2. 目录复制的提前创建(第203-206行):

    if err := op.MakeDir(t.Ctx(), t.DstStorage, dstActualPath); err != nil {
        return errors.WithMessagef(err, "failed create dst dir [%s]", dstActualPath)
    }
    • 这个逻辑会提前创建目标目录,即使后续复制失败,目录也会留下
    • 原有代码可能依赖"目录不存在则创建"的隐式逻辑,这里变成了显式创建,需要确认是否会影响其他场景

internal/fs/fs.go

改动意图
新增 CopyTo 函数,支持复制时指定目标文件名。

代码修改逻辑

func CopyTo(ctx context.Context, srcObjPath, dstDirPath, dstName string, skipHook ...bool) (task.TaskExtensionInfo, error) {
    res, err := transfer(ctx, copy, srcObjPath, dstDirPath, dstName, skipHook...)
    if err != nil {
        log.Errorf("failed copy %s to %s as %s: %+v", srcObjPath, dstDirPath, dstName, err)
    }
    return res, err
}

合理性评估

优点

  1. API 设计清晰,与现有 Copy 函数保持一致
  2. 日志记录包含目标文件名,便于调试

💡 建议

  1. 添加参数校验dstName 不应包含路径分隔符
    if strings.Contains(dstName, "/") || strings.Contains(dstName, "\\") {
        return nil, errors.New("dstName must not contain path separators")
    }

internal/fs/other.go

改动意图
在任务数据结构中新增 DstName 字段,用于支持命名复制功能。

代码修改逻辑

type TaskData struct {
    ...
    DstName       string        `json:"dst_name,omitempty"`
    ...
}

合理性评估

优点

  1. 使用 omitempty 标签,不会影响现有任务的 JSON 序列化
  2. 向后兼容,旧任务反序列化时 DstName 为空字符串

internal/op/fs.go

改动意图
修正日志格式字符串,将 %+s 改为 %+v

代码修改逻辑

- log.Debugf("remove %s: %+s", storage.GetStorage().MountPath+actualPath, err)
+ log.Debugf("remove %s: %+v", storage.GetStorage().MountPath+actualPath, err)

合理性评估

优点

  1. 修复了格式化字符串的错误用法(%+s 不是有效的格式符)
  2. %+v 可以打印错误的堆栈信息(如果使用了 pkg/errors

💡 建议

  • 这是一个独立的 bug 修复,与 WebDAV 功能无关,可以考虑单独提 PR,便于追溯

server/webdav/file.go

改动意图
实现 WebDAV COPY 操作的命名复制功能,并修复 MOVE 时的死属性迁移。

代码修改逻辑

  1. COPY 操作支持 Destination 路径提取(第128-148行):

    • 解析 Destination 头,支持绝对 URL 和相对路径
    • 提取目标目录和目标文件名
    • 判断源路径是否为目录
  2. 调用 fs.CopyTofs.Copy(第150-174行):

    if !srcIsDir && dstName != "" && dstName != stdpath.Base(reqPath) {
        _, err = fs.CopyTo(ctx, reqPath, dstDir, dstName)
    } else {
        _, err = fs.Copy(ctx, reqPath, dstDir)
    }
    • 如果是文件且目标名称与源名称不同,使用 CopyTo
    • 否则使用传统的 Copy
  3. MOVE 操作迁移死属性(第215行):

    if err := moveDeadProps(reqPath, dst); err != nil {
        return http.StatusInternalServerError, err
    }

合理性评估

优点

  1. 实现了 RFC 4918 规定的 COPY 语义
  2. 支持同目录复制(通过不同文件名区分)
  3. MOVE 时自动迁移自定义属性,符合用户预期

⚠️ 潜在问题

  1. 目标目录检查逻辑(第161-164行):

    dstDirObj, err := op.Get(ctx, user, dstDir)
    if err != nil || !dstDirObj.IsDir() {
        return http.StatusConflict, nil
    }
    • 如果目标目录不存在,返回 409 Conflict 是正确的
    • 但如果 op.Get 返回其他错误(如权限错误),也会返回 409,错误信息丢失
    • 建议区分处理:
      dstDirObj, err := op.Get(ctx, user, dstDir)
      if err != nil {
          if errors.Is(err, errs.ObjectNotFound) {
              return http.StatusConflict, nil
          }
          return http.StatusInternalServerError, err
      }
      if !dstDirObj.IsDir() {
          return http.StatusConflict, nil
      }
  2. COPY 失败时的死属性清理

    • 如果 fs.CopyTo 成功但后续操作失败,死属性不会被清理
    • 建议在 COPY 成功后再处理死属性(虽然当前实现中死属性是在 fs.CopyTo 之外处理的,但需要确认是否有其他隐含逻辑)

server/webdav/lock.go

改动意图
修复锁路径解析错误,支持共享锁(shared locks)。

代码修改逻辑

  1. 修复 lookup 函数的变量作用域(第53行):

    - func (m *memLS) lookup(name string, conditions ...Condition) (n *memLSNode) {
    + func (m *memLS) lookup(name string, conditions ...Condition) *memLSNode {
        for _, c := range conditions {
    -       n = m.byToken[c.Token]
    +       n := m.byToken[c.Token]
    • 原 bugn 作为返回值参数,循环中每次赋值会覆盖,最后一次循环的 n 会被返回,即使前面有匹配项
    • 修复:将 n 改为局部变量,每次循环重新声明
  2. 支持共享锁创建(第235-241行):

    if details.Shared {
        if n := m.byName[details.Root]; n != nil && n.token != "" && n.details.Shared && n.details.ZeroDepth == details.ZeroDepth && !n.held {
            token := m.nextToken()
            n.sharedTokens[token] = struct{}{}
            m.byToken[token] = n
            return token, nil
        }
    }
    • 如果已存在相同路径的共享锁,生成新 token 并加入 sharedTokens 集合
    • 多个客户端可以同时持有共享锁
  3. 共享锁的解锁逻辑(第295-302行):

    if n.details.Shared {
        delete(m.byToken, token)
        delete(n.sharedTokens, token)
        if len(n.sharedTokens) != 0 {
            return nil
        }
    }
    • 只删除当前 token,不删除整个锁节点
    • 只有当所有共享 token 都被释放后,才删除锁节点
  4. 锁节点删除时清理所有共享 token(第352-358行):

    if n.details.Shared {
        for token := range n.sharedTokens {
            delete(m.byToken, token)
        }
    } else {
        delete(m.byToken, n.token)
    }

合理性评估

优点

  1. 修复了严重的锁查找 bug:原 lookup 函数的逻辑错误会导致锁机制完全失效
  2. 共享锁实现符合 WebDAV 规范(RFC 4918)
  3. 数据结构设计合理,使用 map[string]struct{} 存储共享 token 集合,内存高效

💡 建议

  1. 添加共享锁的冲突检测:当创建独占锁时,应检查是否存在共享锁,反之亦然

    if !details.Shared {
        // 创建独占锁前,检查是否存在共享锁
        if n := m.byName[details.Root]; n != nil && n.details.Shared {
            return "", ErrLocked
        }
    }
  2. 共享锁的深度一致性检查:当前代码要求 ZeroDepth 必须一致,这是正确的,但可以添加注释说明原因


server/webdav/prop.go

改动意图
实现死属性的持久化,支持 PROPPATCH 设置、PROPFIND 检索和 MOVE 迁移。

代码修改逻辑

  1. 新增 getDeadProps 函数(第291-306行):

    func getDeadProps(path string) (map[xml.Name]Property, error) {
        database := db.GetDb()
        if database == nil {
            return nil, errors.New("webdav property database is not initialized")
        }
        var rows []model.WebDAVProperty
        if err := database.Where("path = ?", path).Find(&rows).Error; err != nil {
            return nil, err
        }
        props := make(map[xml.Name]Property, len(rows))
        for _, row := range rows {
            props[xml.Name{Space: row.Namespace, Local: row.Name}] = Property{...}
        }
        return props, nil
    }
    • 从数据库读取指定路径的所有死属性
  2. 重构 props 函数(第175行):

    • 新增 name 参数,用于查询死属性
    • 调用 getDeadProps 获取持久化的属性
  3. 重构 propnamesallprop 函数

    • 同样添加 name 参数,支持死属性查询
  4. 重构 patch 函数(第273-309行):

    err = database.Transaction(func(tx *gorm.DB) error {
        for _, patch := range patches {
            for _, prop := range patch.Props {
                if patch.Remove {
                    if err := tx.Where("path = ? AND namespace = ? AND name = ?", name, prop.XMLName.Space, prop.XMLName.Local).Delete(&model.WebDAVProperty{}).Error; err != nil {
                        return err
                    }
                } else {
                    row := model.WebDAVProperty{...}
                    if err := tx.Where(...).Assign(...).FirstOrCreate(&row).Error; err != nil {
                        return err
                    }
                }
                pstat.Props = append(pstat.Props, Property{XMLName: prop.XMLName})
            }
        }
        return nil
    })
    • 使用数据库事务确保原子性
    • FirstOrCreate + Assign 实现 UPSERT 语义
    • 添加重试机制处理 SQLite 的"database is locked"错误(最多重试10次)
  5. 新增 moveDeadProps 函数(第349-381行):

    func moveDeadProps(src, dst string) error {
        // 清理目标路径的旧属性(覆盖语义)
        if err := tx.Where("path = ? OR path LIKE ? ESCAPE '\\'", dst, escapedDst+"/%").Delete(&model.WebDAVProperty{}).Error; err != nil {
            return err
        }
        // 查找源路径及其子路径的所有属性
        var rows []model.WebDAVProperty
        if err := tx.Where("path = ? OR path LIKE ? ESCAPE '\\'", src, escapedSrc+"/%").Find(&rows).Error; err != nil {
            return err
        }
        // 复制到新路径,删除旧记录
        for _, row := range rows {
            newPath := dst + strings.TrimPrefix(row.Path, src)
            copy := row
            copy.ID = 0
            copy.Path = newPath
            if err := tx.Create(&copy).Error; err != nil {
                return err
            }
            if err := tx.Delete(&row).Error; err != nil {
                return err
            }
        }
        return nil
    }
    • 使用 LIKE 匹配子路径(支持目录 MOVE)
    • 使用 ESCAPE '\\' 转义特殊字符(%, _, \

合理性评估

优点

  1. 实现了完整的死属性持久化机制
  2. 使用数据库事务确保数据一致性
  3. MOVE 时自动迁移属性,符合 WebDAV 规范
  4. 添加了 SQLite 锁重试机制,提升并发性能

⚠️ 潜在问题

  1. LIKE 查询的性能问题

    • moveDeadProps 中的 path LIKE 'src/%' 查询在属性较多时性能较差
    • 建议添加索引优化:
      CREATE INDEX idx_webdav_properties_path_prefix ON webdav_properties(path);
    • 或者使用前缀索引(如果数据库支持)
  2. 转义逻辑的正确性(第355-356行):

    escapedSrc := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(src, "\\", "\\\\"), "%", "\\%"), "_", "\\_")
    • 转义顺序正确:先转义 \,再转义 %_
    • 但这个逻辑只适用于 MySQL/PostgreSQLSQLite 的 ESCAPE 语法不同
    • SQLite 中,ESCAPE '\' 表示使用 \ 作为转义字符,但 \ 本身在字符串中也需要转义
    • 建议测试 SQLite 环境下的 MOVE 操作,确保路径包含特殊字符时正常工作
  3. MOVE 失败的回滚

    • 如果 moveDeadProps 在中途失败(如创建新记录失败),已复制的属性不会回滚
    • 虽然使用了事务,但如果外层调用没有正确处理错误,可能导致部分属性丢失
    • 建议在 file.go 中确保 MOVE 失败时回滚文件系统操作和属性操作
  4. 死属性的并发写入

    • 重试机制只处理了 "database is locked" 错误
    • 如果多个客户端同时修改同一属性,可能导致丢失更新
    • 建议添加乐观锁或版本号机制

server/webdav/webdav.go

改动意图
修复锁检查的路径解析逻辑,确保锁机制在用户路径解析后的实际资源路径上生效。

代码修改逻辑

  1. 移除 PROPFIND 的自动降级逻辑(第85-88行):

    - case "PROPFIND":
    -     status, err = h.handlePropfind(brw, r)
    -     if err != nil {
    -         status = http.StatusNotFound
    -     }
    + case "PROPFIND":
    +     status, err = h.handlePropfind(brw, r)
    • 原逻辑问题:任何 PROPFIND 错误都被转换为 404,隐藏了真实错误
    • 修复:返回真实的错误状态码(如 403 Forbidden, 500 Internal Server Error)
  2. 锁条件中的路径解析(第168-175行):

    if user != nil {
        lsrc, err = user.JoinPath(lsrc)
        if err != nil {
            return nil, http.StatusForbidden, err
        }
    }
    • If 头中的资源标签(resource tag)通过 user.JoinPath 解析为实际路径
    • 修复了锁路径解析错误的核心 bug
  3. 调整锁检查与路径解析的顺序

    • 在 DELETE/PUT/MKCOL/PROPPATCH 中,将 user.JoinPath 调用移到 confirmLocks 之前
    • 确保锁检查使用的是解析后的实际路径

合理性评估

优点

  1. 修复了锁机制的核心 bug:原来锁检查使用的是用户请求路径,而不是实际资源路径,导致锁完全失效
  2. PROPFIND 错误处理更合理,不会隐藏真实错误
  3. 删除了冗余的注释,代码更简洁

⚠️ 疑问

  1. 路径解析顺序的影响

    • user.JoinPath 提前到 confirmLocks 之前,意味着锁检查使用的是解析后的路径
    • confirmLocks 内部也会调用 user.JoinPath(第168-175行)
    • 这是否会导致二次解析?建议检查 confirmLocks 的调用链,确保不会重复解析
  2. 错误状态码的变化

    • 移除 PROPFIND 的 404 降级后,客户端收到的错误码会改变
    • 需要确认这是否会影响现有客户端的兼容性(虽然返回真实错误码是正确的)

server/webdav/xml.go

改动意图

  1. 支持共享锁的 XML 序列化
  2. 拒绝含非法 XML 命名空间声明的 PROPFIND 请求体
  3. 限制 PROPFIND 请求体大小,防止 DoS 攻击

代码修改逻辑

  1. 共享锁的 XML 输出(第87-90行):

    scope := "exclusive"
    if ld.Shared {
        scope = "shared"
    }
    • 根据锁类型输出 <D:exclusive/><D:shared/>
  2. 修复 readLockInfo 的锁类型检查(第65行):

    - if li.Exclusive == nil || li.Shared != nil || li.Write == nil {
    + if (li.Exclusive == nil) == (li.Shared == nil) || li.Write == nil {
    • 原逻辑:只允许独占写锁
    • 新逻辑:允许独占锁或共享锁,但不能同时存在,且必须是写锁
    • 使用 XOR 逻辑:(A == nil) == (B == nil) 等价于 (A != nil) XOR (B != nil)
  3. PROPFIND 请求体大小限制(第181-195行):

    const maxBody = 64 << 10  // 64KB
    body, err := io.ReadAll(io.LimitReader(r, maxBody))
    if err != nil {
        return propfind{}, http.StatusBadRequest, err
    }
    if len(body) >= maxBody {
        _, _ = io.Copy(io.Discard, r)
        return propfind{}, http.StatusRequestEntityTooLarge, nil
    }
    • 限制请求体最大 64KB
    • 如果超限,返回 413 Request Entity Too Large
    • 排空剩余数据,保持连接可用
  4. 检查空命名空间前缀(第221-248行):

    func hasEmptyNamespacePrefix(body []byte) bool {
        // 查找 xmlns:="" 这样的非法声明
        for offset := 0; ; {
            i := bytes.Index(body[offset:], []byte("xmlns:"))
            if i < 0 {
                return false
            }
            // 解析 xmlns:prefix="value"
            // 如果 prefix 为空,返回 true
        }
    }
    • 防止客户端发送 xmlns:="" 这样的非法 XML
    • 这种声明会导致 XML 解析器错误

合理性评估

优点

  1. 共享锁的 XML 输出符合 RFC 4918 规范
  2. 请求体大小限制有效防止 DoS 攻击
  3. 拒绝非法 XML 提升了安全性和鲁棒性

⚠️ 潜在问题

  1. hasEmptyNamespacePrefix 的解析逻辑

    • 手写的 XML 解析器容易出错,建议添加单元测试覆盖各种边界情况
    • 测试用例:
      xmlns:=""               <!-- 应检测 -->
      xmlns: =""              <!-- 应检测 -->
      xmlns:foo="bar"         <!-- 不应检测 -->
      xmlns:="value"          <!-- 应检测 -->
  2. 64KB 限制是否合理

    • 对于大型目录的 PROPFIND allprop 请求,响应可能很大,但请求体通常很小
    • 64KB 对于请求体来说已经非常宽松,应该足够
    • 但建议在文档中说明这个限制

🎯 总体评价

功能性:⭐⭐⭐⭐⭐ - 修复了 WebDAV 的核心功能缺陷,大幅提升标准兼容性

安全性:⭐⭐⭐⭐ - 添加了请求体大小限制和 XML 校验,但死属性的并发安全性需加强

代码质量:⭐⭐⭐⭐ - 代码结构清晰,但部分错误处理和回滚机制需完善

实现方案:⭐⭐⭐⭐⭐ - 技术方案合理,符合 WebDAV 规范,测试覆盖充分

建议操作

  • ✅ Approve(建议合并)

理由

这是一个非常重要的功能性 PR,修复了 WebDAV 服务的多个严重问题:

  1. 问题定位准确

    • 准确识别了 COPY 500 错误、死属性无法持久化、锁机制失效等问题的根本原因
    • 锁路径解析的 bug 修复尤其关键,直接影响 WebDAV 的安全性
  2. 功能完整性大幅提升

    • Litmus 测试套件通过率从 61/80 提升到 102/103(仅剩 1 个上游未实现的特性)
    • copymove: 9/13 → 13/13
    • props: 9/14 → 30/30
    • locks: 24/34 → 40/41
    • 这些数字证明了修复的有效性
  3. 技术方案合理

    • 死属性持久化使用数据库,设计合理
    • 共享锁实现符合 RFC 4918 规范
    • 命名复制的实现支持了 WebDAV 的高级特性
  4. 测试覆盖充分

    • 通过 Litmus 全量测试套件验证
    • 在公网 Nginx HTTPS 环境下测试,接近真实场景

需要注意的改进点(非阻塞,但建议后续优化):

  1. 错误处理和回滚机制

    • 复制+重命名失败时的回滚
    • MOVE 失败时的属性回滚
    • 错误状态码的精确区分(如 op.Get 失败时不应都返回 409)
  2. 性能优化

    • moveDeadProps 的 LIKE 查询需要索引优化
    • 死属性的并发写入冲突处理
  3. 文档和测试

    • 添加数据库迁移说明
    • hasEmptyNamespacePrefix 需要单元测试
    • 参数校验(如 dstName 不应包含路径分隔符)
  4. 跨平台兼容性

    • 确认 SQLite 的 ESCAPE 语法是否正确工作
    • 测试路径包含特殊字符时的 MOVE 操作

前置条件

  • 用户升级时会自动创建 webdav_properties
  • 建议在发布说明中标注这是数据库结构变更
  • 建议手动测试 SQLite 环境下的所有修复功能

总体而言,这是一个高质量且重要的 PR,强烈建议合并 ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants