Skip to content

fix(data-transfer): fix moveFile failure when source and target are on different filesystems - #787

Merged
deepin-bot[bot] merged 1 commit into
linuxdeepin:masterfrom
pppanghu77:fix/movefile-cross-device-master
Sep 8, 2026
Merged

deepin-bot[bot] merged 1 commit into
linuxdeepin:masterfrom
pppanghu77:fix/movefile-cross-device-master

Conversation

@pppanghu77

@pppanghu77 pppanghu77 commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

问题

数据迁移搬运阶段失败,日志报:

moveFile error: rename dir failed: <接收目录>/xxx -> ~/xxx

根因:接收目录(~/Downloads)与主目录可能不在同一文件系统(例如接收目录是 fuse 挂载、主目录是 ext4)。rename(2) 不允许跨文件系统,返回 EXDEV(Errno 18),搬运必然失败;且移动目录时 QFile::rename 会直接报 EISDIR。

同一修复已提交至 release/v20 分支:#786

修复内容(moveFile)

  1. 目录用 QDir::rename 移动:QFile::rename 移动目录报 EISDIR("打开的文件是个目录")
  2. 跨设备兜底:rename 失败(EXDEV)时退化为复制后删除源
    • 目录:新增 copyDirectory() 递归复制(含隐藏文件)→ removeRecursively() 删源
    • 单文件:QFile::copy → QFile::remove
  3. 修复避让逻辑路径破坏 bug:原 dst.remove(fileName) 会删除路径中所有出现的同名段(QString::remove 是全局删除),路径含重复段时被截断成错误路径;改为只截结尾段
  4. 兜底创建目标父目录:~/D: 这类源自 Windows 盘符的目标目录,mkpath 确保存在

验证

  • 本机模拟真实跨设备场景(接收目录与目标目录位于不同文件系统,stat -c %d 设备号不同)三场景实测通过:
    • 跨文件系统移动目录到 ~/D: 路径(含子目录、隐藏文件、无后缀文件)
    • 跨文件系统移动单文件
    • 目标已存在时重名避让 + 跨文件系统目录
  • 用 python3 os.rename 实测复现 Errno 18 EXDEV 确认根因

Bug: https://pms.uniontech.com//bug-view-375881.html

Summary by Sourcery

Make data-transfer file moves reliable across filesystem boundaries while preserving destination path and naming behavior.

Bug Fixes:

  • Fix file and directory moves across different filesystems by falling back to copy-then-delete when rename fails.
  • Correct destination name handling to preserve paths containing repeated directory names and ensure destination parent directories exist.

Enhancements:

  • Use directory-aware moves and preserve hidden files during recursive cross-filesystem directory transfers.

…n different filesystems

- Move directories with QDir::rename instead of QFile::rename, which fails with EISDIR on directories
- Fall back to recursive copy plus source removal when rename fails with EXDEV across filesystems: copyDirectory() for directories and QFile::copy for single files
- Fix rename-collision handling: dst.remove(fileName) deleted every occurrence of the name and corrupted paths with repeated segments; only the trailing segment is cut now
- Ensure the destination parent directory exists before moving, for targets such as ~/D:/ derived from Windows drive letters

修复(数据迁移): 修复源和目标位于不同文件系统时 moveFile 搬运失败的问题

- 目录改用 QDir::rename 移动,避免 QFile::rename 移动目录时报 EISDIR 错误
- rename 跨文件系统失败(EXDEV)时退化为复制后删除源:目录走 copyDirectory() 递归复制,单文件走 QFile::copy
- 修复重名避让逻辑: dst.remove(fileName) 会误删路径中所有同名段导致路径破坏,改为只截取结尾段
- 移动前确保目标父目录存在,兼容 ~/D:/ 这类源自 Windows 盘符的目标路径

Log: 修复数据迁移搬运阶段接收目录与主目录不在同一文件系统时 rename 跨设备失败(EXDEV)导致搬运失败的问题
Bug: https://pms.uniontech.com/bug-view-375881.html
@sourcery-ai

sourcery-ai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates moveFile to correctly move directories and files across different filesystems via copy-and-delete fallbacks, while fixing path truncation in rename avoidance and ensuring destination directories exist.

Sequence diagram for cross-filesystem file and directory moves

sequenceDiagram
    participant Caller
    participant SettingHelper
    participant Filesystem
    Caller->>SettingHelper: moveFile(src, dst)
    SettingHelper->>Filesystem: QDir().mkpath(destination parent)
    alt source is directory
        SettingHelper->>Filesystem: QDir::rename(src, dst)
        alt rename succeeds
            Filesystem-->>SettingHelper: success
        else rename fails across filesystems
            SettingHelper->>Filesystem: copyDirectory(src, dst)
            SettingHelper->>Filesystem: QDir(src).removeRecursively()
            Filesystem-->>SettingHelper: success
        end
    else source is file
        SettingHelper->>Filesystem: QFile::rename(dst)
        alt rename succeeds
            Filesystem-->>SettingHelper: success
        else rename fails across filesystems
            SettingHelper->>Filesystem: QFile::copy(src, dst)
            SettingHelper->>Filesystem: QFile::remove(src)
            Filesystem-->>SettingHelper: success
        end
    end
    SettingHelper-->>Caller: true or false
Loading

Flow diagram for cross-filesystem moveFile fallback

flowchart TD
    A["moveFile(src, dst)"] --> B["QDir().mkpath(destination parent)"]
    B --> C{Source is directory?}
    C -->|Yes| D["QDir::rename(src, dst)"]
    D -->|Success| E[Return true]
    D -->|Failure| F["copyDirectory(src, dst)"]
    F -->|Success| G["QDir(src).removeRecursively()"]
    G --> E
    F -->|Failure| H[Return false]
    C -->|No| I["QFile::rename(dst)"]
    I -->|Success| E
    I -->|Failure| J["QFile::copy(src, dst)"]
    J -->|Success| K["QFile::remove(src)"]
    K --> E
    J -->|Failure| H
Loading

File-Level Changes

Change Details Files
Make file and directory moves work across filesystem boundaries by falling back from rename to copy-then-delete.
  • Use QDir::rename for directories and retain rename as the fast path for files.
  • Recursively copy directory trees, including hidden/system entries, before removing the source.
  • Copy and remove individual files when rename fails.
src/lib/data-transfer/core/utils/settinghepler.cpp
Correct destination path handling and collision avoidance during move operations.
  • Derive the destination filename and parent path without globally removing repeated path segments.
  • Strip only a trailing suffix when generating conflict-avoidance names.
  • Create the destination parent directory before moving or copying.
src/lib/data-transfer/core/utils/settinghepler.cpp

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/lib/data-transfer/core/utils/settinghepler.cpp" line_range="392-406" />
<code_context>
+        if (dir.rename(src, dst))
+            return true;
+        if (copyDirectory(src, dst)) {
+            QDir(src).removeRecursively();   // 数据已就位, 源清理失败不影响结果
+            return true;
+        }
+
+        WLOG << "moveFile error: rename dir failed: " << src.toStdString() << " -> " << dst.toStdString();
+        return false;
+    }
+    // ---------- 文件处理 ----------
+    // 优先 rename 移动; 跨设备时退化为复制后删除源
     QFile f(src);
     LOG << "moveFile dst: " << src.toStdString() << "   " << dst.toStdString();
     if (f.rename(dst))
         return true;
+    if (QFile::copy(src, dst)) {             // 跨设备时退化为复制后删除源
+        QFile::remove(src);
+        return true;
+    }
</code_context>
<issue_to_address>
**issue (bug_risk):** After a cross-filesystem copy succeeds, failure to delete the source is ignored and `moveFile` returns true, so the same data remains in both locations even though callers are told the move completed. This applies to both directory cleanup at `ck` and file cleanup at `cy`.

**Triggers:** When the source cannot be removed because of permissions, read-only media, or a concurrent filesystem change.

**Suggested fix:** Check the removal result and return failure, or report the operation as a copy rather than a completed move when cleanup fails.
</issue_to_address>

### Comment 2
<location path="src/lib/data-transfer/core/utils/settinghepler.cpp" line_range="345-356" />
<code_context>
+    if (!QDir().mkpath(dst))
+        return false;
+
+    const auto entries = srcDir.entryInfoList(
+                QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden | QDir::System);
+    for (const auto &entry : entries) {
+        const QString target = dst + QLatin1Char('/') + entry.fileName();
+        if (entry.isDir()) {
+            if (!copyDirectory(entry.absoluteFilePath(), target))
+                return false;
+        } else if (!QFile::copy(entry.absoluteFilePath(), target)) {
+            WLOG << "copy file failed: " << entry.absoluteFilePath().toStdString()
+                 << " -> " << target.toStdString();
+            return false;
+        }
+    }
+    return true;
</code_context>
<issue_to_address>
**issue (bug_risk):** If copying any entry fails, `copyDirectory` returns false after creating the destination and copying earlier entries, leaving a partial destination tree while the source remains. A retry then treats that partial tree as an existing destination and applies rename-conflict logic, producing additional suffixed directories instead of retrying cleanly.

**Triggers:** When a directory contains an unreadable file, unsupported entry, or a copy error after at least one entry has already been transferred.

**Suggested fix:** Remove the partial destination tree before returning failure, or copy into a temporary destination and rename it into place only after the complete copy succeeds.
</issue_to_address>

### Comment 3
<location path="src/lib/data-transfer/core/utils/settinghepler.cpp" line_range="347-350" />
<code_context>
+    for (const auto &entry : entries) {
+        const QString target = dst + QLatin1Char('/') + entry.fileName();
+        if (entry.isDir()) {
+            if (!copyDirectory(entry.absoluteFilePath(), target))
+                return false;
+        } else if (!QFile::copy(entry.absoluteFilePath(), target)) {
</code_context>
<issue_to_address>
**issue (bug_risk):** A directory symlink is classified as a directory and recursively passed to `copyDirectory`; because the recursion follows the symlink and does not track visited paths, a symlink to an ancestor causes unbounded recursive copying until path/resource exhaustion instead of preserving or rejecting the symlink.

**Triggers:** When the source directory contains a symlink to a directory, especially a symlink pointing to the source or one of its ancestors.

**Suggested fix:** Handle `entry.isSymLink()` before `entry.isDir()` and either copy the link itself or explicitly reject symlinks during fallback copying.

```suggestion
    for (const auto &entry : entries) {
        const QString target = dst + QLatin1Char('/') + entry.fileName();
        if (entry.isSymLink())
            return false;
        if (entry.isDir()) {
            if (!copyDirectory(entry.absoluteFilePath(), target))
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +392 to +406
QDir(src).removeRecursively(); // 数据已就位, 源清理失败不影响结果
return true;
}

WLOG << "moveFile error: rename dir failed: " << src.toStdString() << " -> " << dst.toStdString();
return false;
}
// ---------- 文件处理 ----------
// 优先 rename 移动; 跨设备时退化为复制后删除源
QFile f(src);
LOG << "moveFile dst: " << src.toStdString() << " " << dst.toStdString();
if (f.rename(dst))
return true;
if (QFile::copy(src, dst)) { // 跨设备时退化为复制后删除源
QFile::remove(src);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): After a cross-filesystem copy succeeds, failure to delete the source is ignored and moveFile returns true, so the same data remains in both locations even though callers are told the move completed. This applies to both directory cleanup at ck and file cleanup at cy.

Triggers: When the source cannot be removed because of permissions, read-only media, or a concurrent filesystem change.

Suggested fix: Check the removal result and return failure, or report the operation as a copy rather than a completed move when cleanup fails.

Comment on lines +345 to +356
const auto entries = srcDir.entryInfoList(
QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden | QDir::System);
for (const auto &entry : entries) {
const QString target = dst + QLatin1Char('/') + entry.fileName();
if (entry.isDir()) {
if (!copyDirectory(entry.absoluteFilePath(), target))
return false;
} else if (!QFile::copy(entry.absoluteFilePath(), target)) {
WLOG << "copy file failed: " << entry.absoluteFilePath().toStdString()
<< " -> " << target.toStdString();
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): If copying any entry fails, copyDirectory returns false after creating the destination and copying earlier entries, leaving a partial destination tree while the source remains. A retry then treats that partial tree as an existing destination and applies rename-conflict logic, producing additional suffixed directories instead of retrying cleanly.

Triggers: When a directory contains an unreadable file, unsupported entry, or a copy error after at least one entry has already been transferred.

Suggested fix: Remove the partial destination tree before returning failure, or copy into a temporary destination and rename it into place only after the complete copy succeeds.

Comment on lines +347 to +350
for (const auto &entry : entries) {
const QString target = dst + QLatin1Char('/') + entry.fileName();
if (entry.isDir()) {
if (!copyDirectory(entry.absoluteFilePath(), target))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): A directory symlink is classified as a directory and recursively passed to copyDirectory; because the recursion follows the symlink and does not track visited paths, a symlink to an ancestor causes unbounded recursive copying until path/resource exhaustion instead of preserving or rejecting the symlink.

Triggers: When the source directory contains a symlink to a directory, especially a symlink pointing to the source or one of its ancestors.

Suggested fix: Handle entry.isSymLink() before entry.isDir() and either copy the link itself or explicitly reject symlinks during fallback copying.

Suggested change
for (const auto &entry : entries) {
const QString target = dst + QLatin1Char('/') + entry.fileName();
if (entry.isDir()) {
if (!copyDirectory(entry.absoluteFilePath(), target))
for (const auto &entry : entries) {
const QString target = dst + QLatin1Char('/') + entry.fileName();
if (entry.isSymLink())
return false;
if (entry.isDir()) {
if (!copyDirectory(entry.absoluteFilePath(), target))

@deepin-ci-robot

Copy link
Copy Markdown

deepin pr auto review

🤖 AI 代码审查报告

总体评分: 91 分 (通过阈值: 70分)

Pass


📊 总体评价

项目 结果
审查结论 代码审查通过
评分详情 总体评分 91 分,大于 70 分通过阈值。本次提交修复了跨文件系统 moveFile 失败的问题,代码逻辑正确,无安全漏洞,代码质量良好。

🔍 详细分析

1. 语法逻辑 ✅

评价: 良好 ✅ 通过

潜在问题:

  1. src/lib/data-transfer/core/utils/settinghepler.cpp:384 - moveFile() 中 dstDir + "/" + baseName 路径拼接:dstDir 已含尾部 '/',再拼 '/' 产生双斜杠;当 dstDir 为空(相对路径场景)时,拼接 '/' 导致路径变为根目录路径

建议: 建议使用 dstDir.endsWith('/') ? dstDir + baseName : dstDir + '/' + baseName 的方式拼接路径,或使用 QDir::toNativeSeparators 规范化路径


2. 代码质量 ✅

评价: 优秀 ✅ 通过

潜在问题:

  1. src/lib/data-transfer/core/utils/settinghepler.cpp:393 - copyDirectory() 失败时未清理目标目录的部分复制数据,可能导致残留孤儿文件

建议: 建议在 copyDirectory 失败后,清理目标目录中的部分复制数据(QDir(dst).removeRecursively()),或在函数注释中明确说明失败时的数据状态


3. 代码性能 ✅

评价: 优秀 ✅ 通过

潜在问题:

  1. src/lib/data-transfer/core/utils/settinghepler.cpp:338 - copyDirectory() 未处理符号链接,存在符号链接循环导致无限递归的风险
  2. src/lib/data-transfer/core/utils/settinghepler.cpp:351 - QFile::copy 不保留文件权限位、时间戳等元数据

建议: 建议在 copyDirectory 中添加 symlink 检测(entry.isSymLink()),对符号链接直接复制链接本身或跳过;如需保留文件属性,可考虑使用系统调用或 QFileInfo 获取并恢复权限


4. 代码安全 🔒

评价: 优秀 ✅ 通过

🔐 发现 0 个安全漏洞

安全漏洞详情:
✅ 未发现安全漏洞

建议: 建议增加递归深度限制(如 maxDepth 参数),或检测符号链接后跳过/特殊处理


💡 改进建议代码示例

// 修复路径拼接双斜杠问题
QString sep = dstDir.endsWith('/') ? "" : "/";
dst = dstDir + sep + baseName + "(" + QString::number(i) + ")" + suffix;

// copyDirectory 增加符号链接防护和递归深度限制
static bool copyDirectory(const QString &src, const QString &dst, int depth = 0)
{
    if (depth > 100)  // 防止无限递归
        return false;
    QDir srcDir(src);
    if (!srcDir.exists())
        return false;
    if (!QDir().mkpath(dst))
        return false;

    const auto entries = srcDir.entryInfoList(
                QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden | QDir::System);
    for (const auto &entry : entries) {
        if (entry.isSymLink())  // 跳过符号链接,避免循环
            continue;
        const QString target = dst + QLatin1Char('/') + entry.fileName();
        if (entry.isDir()) {
            if (!copyDirectory(entry.absoluteFilePath(), target, depth + 1))
                return false;
        } else if (!QFile::copy(entry.absoluteFilePath(), target)) {
            WLOG << "copy file failed: " << entry.absoluteFilePath().toStdString()
                 << " -> " << target.toStdString();
            return false;
        }
    }
    return true;
}

本报告由 AI 代码审查工具自动生成

@deepin-ci-robot

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: pppanghu77, re2zero

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@pppanghu77

Copy link
Copy Markdown
Contributor Author

/merge

@deepin-bot
deepin-bot Bot merged commit 521e384 into linuxdeepin:master Sep 8, 2026
16 checks passed
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