Skip to content

fix(data-transfer): fallback to copy when moveFile crosses filesystems - #786

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

deepin-bot[bot] merged 1 commit into
linuxdeepin:release/v20from
pppanghu77:fix/movefile-cross-device-v20

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。

修复内容(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

@sourcery-ai

sourcery-ai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Reviewer's Guide

Updates moveFile to handle cross-filesystem transfers by attempting native renames first and falling back to copy-then-delete, with recursive directory support, destination parent creation, and corrected collision-path handling.

Sequence diagram for cross-filesystem moveFile fallback

sequenceDiagram
    participant Caller
    participant SettingHelper
    participant Filesystem

    Caller->>SettingHelper: moveFile(src, dst)
    SettingHelper->>Filesystem: mkpath(absolutePath(dst))
    alt src is directory
        SettingHelper->>Filesystem: QDir::rename(src, dst)
        alt rename succeeds
            Filesystem-->>SettingHelper: true
        else rename fails
            SettingHelper->>SettingHelper: copyDirectory(src, dst)
            SettingHelper->>Filesystem: QDir::removeRecursively(src)
            Filesystem-->>SettingHelper: true
        end
    else src is file
        SettingHelper->>Filesystem: QFile::rename(src, dst)
        alt rename succeeds
            Filesystem-->>SettingHelper: true
        else rename fails
            SettingHelper->>Filesystem: QFile::copy(src, dst)
            SettingHelper->>Filesystem: QFile::remove(src)
            Filesystem-->>SettingHelper: true
        end
    end
    SettingHelper-->>Caller: move result
Loading

Flow diagram for collision-safe destination handling

flowchart TD
    A["moveFile(src, dst)"] --> B{Destination exists?}
    B -- No --> D[mkpath destination parent]
    B -- Yes --> C[Extract trailing filename and suffix]
    C --> E[Build numbered destination until unused]
    E --> D
    D --> F{Source is directory?}
    F -- Yes --> G[QDir::rename]
    F -- No --> H[QFile::rename]
    G --> I{Rename succeeds?}
    H --> J{Rename succeeds?}
    I -- Yes --> K[Return true]
    J -- Yes --> K
    I -- No --> L[copyDirectory]
    J -- No --> M[QFile::copy]
    L --> N[QDir::removeRecursively]
    M --> O[QFile::remove]
    N --> K
    O --> K
Loading

File-Level Changes

Change Details Files
Add cross-filesystem move fallback for both directories and files while preserving fast same-filesystem renames.
  • Use QDir::rename for directory moves.
  • Recursively copy directories, including hidden and system entries, then remove the source when rename fails.
  • Copy and remove individual files when QFile::rename fails.
  • Ensure destination parent directories exist before moving.
src/plugins/data-transfer/core/utils/settinghepler.cpp
Correct destination conflict-avoidance path and filename handling.
  • Strip only the trailing filename from the destination path instead of removing every matching substring.
  • Strip only a trailing file extension when generating collision-safe names.
  • Retain destination suffix and append numeric disambiguators for existing targets.
src/plugins/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/plugins/data-transfer/core/utils/settinghepler.cpp" line_range="353-367" />
<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):** The copy-then-delete fallback returns true without checking whether source cleanup succeeded. If `QDir(src).removeRecursively()` or `QFile::remove(src)` fails, both source and destination remain while callers report “Transfer completed”, so a later retry can duplicate the data or leave the migration incomplete.

**Triggers:** When the source cannot be removed after a successful copy, such as due to permissions, read-only storage, or a file being in use.

**Suggested fix:** Return false when source removal fails, or explicitly surface the cleanup failure and prevent the operation from being reported as complete.
</issue_to_address>

### Comment 2
<location path="src/plugins/data-transfer/core/utils/settinghepler.cpp" line_range="355-358" />
<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)) {
+            WLOG << "copy file failed: " << entry.absoluteFilePath().toStdString()
+                 << " -> " << target.toStdString();
</code_context>
<issue_to_address>
**issue (bug_risk):** A failed recursive copy leaves the already-created destination tree in place. `moveFile` then returns false, but a retry sees that partial destination as an existing collision and redirects the remaining source to a new `(1)` path instead of replacing or resuming the incomplete tree.

**Triggers:** When copying any child entry fails after earlier entries have already been copied.

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

```suggestion
        }

        QDir(dst).removeRecursively();
        WLOG << "moveFile error: rename dir failed: " << src.toStdString() << " -> " << dst.toStdString();
        return false;
```
</issue_to_address>

### Comment 3
<location path="src/plugins/data-transfer/core/utils/settinghepler.cpp" line_range="308-313" />
<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();
</code_context>
<issue_to_address>
**issue (bug_risk):** The recursive fallback follows directory symlinks because it classifies entries with `entry.isDir()` and does not exclude symbolic links. A symlink to an external directory is copied recursively outside the source tree, while a self-referential or cyclic symlink causes unbounded recursion until the copy fails, leaving an incomplete destination.

**Triggers:** When the transferred directory contains a symbolic link to a directory.

**Suggested fix:** Detect and handle symlinks explicitly, preserving the link or rejecting it, and exclude them from recursive directory traversal.

```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))
                return false;
        } else if (!QFile::copy(entry.absoluteFilePath(), target)) {
```
</issue_to_address>

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

Comment on lines +353 to +367
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): The copy-then-delete fallback returns true without checking whether source cleanup succeeded. If QDir(src).removeRecursively() or QFile::remove(src) fails, both source and destination remain while callers report “Transfer completed”, so a later retry can duplicate the data or leave the migration incomplete.

Triggers: When the source cannot be removed after a successful copy, such as due to permissions, read-only storage, or a file being in use.

Suggested fix: Return false when source removal fails, or explicitly surface the cleanup failure and prevent the operation from being reported as complete.

Comment on lines +355 to +358
}

WLOG << "moveFile error: rename dir failed: " << src.toStdString() << " -> " << dst.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): A failed recursive copy leaves the already-created destination tree in place. moveFile then returns false, but a retry sees that partial destination as an existing collision and redirects the remaining source to a new (1) path instead of replacing or resuming the incomplete tree.

Triggers: When copying any child entry fails after earlier entries have already been copied.

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

Suggested change
}
WLOG << "moveFile error: rename dir failed: " << src.toStdString() << " -> " << dst.toStdString();
return false;
}
QDir(dst).removeRecursively();
WLOG << "moveFile error: rename dir failed: " << src.toStdString() << " -> " << dst.toStdString();
return false;

Comment on lines +308 to +313
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)) {

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): The recursive fallback follows directory symlinks because it classifies entries with entry.isDir() and does not exclude symbolic links. A symlink to an external directory is copied recursively outside the source tree, while a self-referential or cyclic symlink causes unbounded recursion until the copy fails, leaving an incomplete destination.

Triggers: When the transferred directory contains a symbolic link to a directory.

Suggested fix: Detect and handle symlinks explicitly, preserving the link or rejecting it, and exclude them from recursive directory traversal.

Suggested change
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)) {
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))
return false;
} else if (!QFile::copy(entry.absoluteFilePath(), target)) {

…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
@pppanghu77

Copy link
Copy Markdown
Contributor Author

/retrigger

@pppanghu77

Copy link
Copy Markdown
Contributor Author

/retest

@deepin-ci-robot

Copy link
Copy Markdown

deepin pr auto review

🤖 AI 代码审查报告

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

Pass


📊 总体评价

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

🔍 详细分析

1. 语法逻辑 ✅

评价: 良好 ✅ 通过

潜在问题:

  1. src/plugins/data-transfer/core/utils/settinghepler.cpp:352 - copyDirectory 部分成功后失败时,目标目录中已复制的部分文件未清理,可能残留孤儿文件

建议: 建议在 copyDirectory 失败时清理已复制的部分文件,或在 moveFile 的目录处理分支中,当 copyDirectory 失败后清理目标目录中的残留文件


2. 代码质量 ✅

评价: 优秀 ✅ 通过

潜在问题:

  1. src/plugins/data-transfer/core/utils/settinghepler.cpp:298 - copyDirectory 函数注释较简洁,缺少参数说明和返回值文档

建议: 建议为 copyDirectory 函数添加详细的文档注释,说明参数含义、返回值以及跨设备复制时的行为


3. 代码性能 ✅

评价: 优秀 ✅ 通过

潜在问题:
✅ 未发现明显问题

建议: 性能良好,rename 优先策略在同等文件系统下为 O(1),跨设备时退化为复制是必要的


4. 代码安全 🔒

评价: 优秀 ✅ 通过

🔐 发现 0 个安全漏洞

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

建议: 存在0个安全漏洞,代码安全合规,路径操作使用 Qt 安全 API,无注入风险


💡 改进建议代码示例

// 递归复制目录, 用于跨设备(EXDEV) rename 失败时的退化搬运
// 参数: src - 源目录路径, dst - 目标目录路径
// 返回: true 表示复制成功, false 表示复制失败(部分复制的内容会被清理)
static bool copyDirectory(const QString &src, const QString &dst)
{
    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) {
        const QString target = dst + QLatin1Char('/') + entry.fileName();
        if (entry.isDir()) {
            if (!copyDirectory(entry.absoluteFilePath(), target)) {
                QDir(dst).removeRecursively(); // 清理已复制的部分文件
                return false;
            }
        } else if (!QFile::copy(entry.absoluteFilePath(), target)) {
            WLOG << "copy file failed: " << entry.absoluteFilePath().toStdString()
                 << " -> " << target.toStdString();
            QDir(dst).removeRecursively(); // 清理已复制的部分文件
            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

/forcemerge

@deepin-bot

deepin-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

This pr force merged! (status: unstable)

@deepin-bot
deepin-bot Bot merged commit 3792405 into linuxdeepin:release/v20 Sep 8, 2026
13 of 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