Skip to content

fix: preserve files when downloads fail or are cancelled - #10796

Open
AIdafashi wants to merge 1 commit into
AstrBotDevs:masterfrom
AIdafashi:fix/atomic-file-downloads
Open

AIdafashi wants to merge 1 commit into
AstrBotDevs:masterfrom
AIdafashi:fix/atomic-file-downloads

Conversation

@AIdafashi

@AIdafashi AIdafashi commented Oct 26, 2026 •

Copy link
Copy Markdown

The shared io.download_file() helper opens the destination with "wb" before the response body finishes. A disconnect, timeout, cancellation, or progress callback failure leaves a partial file behind and destroys any previously complete download at that path.

Modifications

  • Stream into a unique temporary directory beside the resolved destination, then use Path.replace() after the response and progress callbacks complete.
  • Clean up staging files on errors and cancellation. Each download owns its staging directory, so cleanup cannot delete a different download's completed result.
  • Preserve existing file mode bits and follow destination symlinks.
  • Cover both the verified TLS path and the existing certificate-fallback path.

Existing PRs #9459 and #10125 address TLS policy and SSRF respectively; this change addresses destination integrity and leaves network policy unchanged. No new dependencies, public API signatures, or frontend changes.

  • This is NOT a breaking change.

Screenshots or Test Results

Windows, Python 3.12:

  • Reproduced before the fix: 20 failed, 3 passed in the initial downloader regression set. Partial-response, timeout, cancellation and callback-error cases overwrite the destination.
  • Final related regression run: 230 passed, 3 skipped across:
    • tests/unit/test_io_download_file.py
    • tests/test_media_utils.py
    • tests/unit/test_file_message_component.py
    • tests/test_updater_socks.py
    • tests/unit/test_diagnostic_redaction.py
  • Two tests use a real loopback HTTP server: one response is interrupted or its client task is cancelled while another download to the same destination succeeds. The completed result remains intact and staging files are removed.
  • The three skipped cases require symlink creation, which this Windows environment does not permit.
  • uv run ruff format . — 524 files unchanged.
  • uv run ruff check . — passed.
  • Explicit Ruff lint/format checks on tests/unit/test_io_download_file.py — passed.
  • uvx --from pyupgrade==3.21.0 pyupgrade --py310-plus astrbot/core/utils/io.py tests/unit/test_io_download_file.py — passed.
  • git diff --check — passed.

The related tests were run with the pytest.main(...), stream flush and os._exit(...) pattern already used by scripts/run_pytests_ci.sh. The full repository suite and other operating systems were not run locally.

Checklist

  • New features discussed with maintainers: not applicable; this fixes failed downloads.
  • Changes tested, with verification steps and results above.
  • Documentation impact checked: no WebUI navigation, page structure, or labels change, so no UI documentation update is needed.
  • No new dependencies.
  • No malicious code.

Summary by Sourcery

Make file downloads publish atomically so failed or cancelled transfers leave no partial files and do not corrupt existing results.

Bug Fixes:

  • Preserve existing or completed destination files when downloads fail, are cancelled, or progress callbacks raise errors.
  • Ensure interrupted concurrent downloads cannot overwrite a successfully completed result.

Enhancements:

  • Publish downloads only after completion while preserving destination permissions and symlink behavior.
  • Extend regression coverage across verified TLS and certificate-fallback download paths, including staging cleanup and publication failures.

Tests:

  • Add comprehensive downloader regression tests for interruptions, cancellation, callback failures, concurrent downloads, permissions, symlinks, and failed publication.

@sourcery-ai sourcery-ai Bot 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.

Hey - I've found 5 issues

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

## Individual Comments

### Comment 1
<location path="astrbot/core/utils/io.py" line_range="360" />
<code_context>
+                            show_downloading_label=False,
+                        )
+        if target_path.exists():
+            shutil.copymode(target_path, staging_path)
+        staging_path.replace(target_path)
     if show_progress:
</code_context>
<issue_to_address>
**Downloaded bytes gain elevated privileges**

When a privileged downloader replaces a destination with setuid or setgid bits, `shutil.copymode` copies those bits to the staging file, and `staging_path.replace` publishes the downloaded contents with them, creating a setuid/setgid executable from untrusted bytes.

Clear setuid and setgid bits from the staging file before publishing it.
</issue_to_address>

### Comment 2
<location path="astrbot/core/utils/io.py" line_range="361" />
<code_context>
+                        )
+        if target_path.exists():
+            shutil.copymode(target_path, staging_path)
+        staging_path.replace(target_path)
     if show_progress:
         print()
</code_context>
<issue_to_address>
**Read-only destinations become replaceable**

When the destination is non-writable but its parent directory is writable, `download_file` writes only to the staging file, then replaces the destination using the parent directory's permissions. On POSIX, a caller that cannot write an existing file can still overwrite it when the parent directory is writable, bypassing file-level write protection.

Check and enforce the existing destination's write authorization before publishing, rejecting the download when the caller could not write the destination directly.
</issue_to_address>

### Comment 3
<location path="astrbot/core/utils/io.py" line_range="360" />
<code_context>
+                            show_downloading_label=False,
+                        )
+        if target_path.exists():
+            shutil.copymode(target_path, staging_path)
+        staging_path.replace(target_path)
     if show_progress:
</code_context>
<issue_to_address>
**Existing access metadata is lost**

When the destination relies on ownership, ACLs, or extended attributes beyond its permission bits, `shutil.copymode` copies only mode bits, and `staging_path.replace` installs a new inode without the destination's other access metadata, changing who can access the completed download.

Preserve the destination's ownership, ACLs, and extended attributes when publishing the downloaded file.

Also at `astrbot/core/utils/io.py:361-362`.
</issue_to_address>

### Comment 4
<location path="astrbot/core/utils/io.py" line_range="361" />
<code_context>
+                        )
+        if target_path.exists():
+            shutil.copymode(target_path, staging_path)
+        staging_path.replace(target_path)
     if show_progress:
         print()
</code_context>
<issue_to_address>
**Older download replaces newer result**

When two successful downloads to the same destination finish in the reverse order from their start, the later-finishing call replaces the destination regardless of which download was started most recently, so an older response can overwrite a newer completed file and callers see stale content.

Serialize publication per destination or reject publication from calls superseded by a newer request.
</issue_to_address>

### Comment 5
<location path="astrbot/core/utils/io.py" line_range="304-306" />
<code_context>
+    # Resolve symlinks as open(path, "wb") does, and stage on the same filesystem
+    # so replacement is atomic. Each call owns its own temporary directory.
+    target_path = Path(path).resolve()
+    with tempfile.TemporaryDirectory(
+        prefix=".astrbot-download-", dir=target_path.parent
+    ) as staging_dir:
+        staging_path = Path(staging_dir) / "download.part"
+        try:
</code_context>
<issue_to_address>
**Writable file cannot be updated**

When the destination is writable but its parent directory does not permit creating entries, `TemporaryDirectory` raises `PermissionError` before the download because it must create a sibling entry, so the existing file is not updated.

Use a staging approach that does not require creating an entry in the destination's parent directory.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 5 findings to address first, and a completed download is published by replacing the destination, so if the received file is wrong, the previous file’s contents are no longer available at that path; reverting will not restore them. The damage is bounded and usually repairable by downloading the file again.

Blocking findings: astrbot/core/utils/io.py:360, astrbot/core/utils/io.py:361, astrbot/core/utils/io.py:360, astrbot/core/utils/io.py:361, astrbot/core/utils/io.py:306


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

Comment thread astrbot/core/utils/io.py
show_downloading_label=False,
)
if target_path.exists():
shutil.copymode(target_path, staging_path)

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.

🟠 High · Downloaded bytes gain elevated privileges

When a privileged downloader replaces a destination with setuid or setgid bits, shutil.copymode copies those bits to the staging file, and staging_path.replace publishes the downloaded contents with them, creating a setuid/setgid executable from untrusted bytes.

Clear setuid and setgid bits from the staging file before publishing it.

Prompt for AI agents
In `astrbot/core/utils/io.py` at line 360:

**Downloaded bytes gain elevated privileges**

When a privileged downloader replaces a destination with setuid or setgid bits, `shutil.copymode` copies those bits to the staging file, and `staging_path.replace` publishes the downloaded contents with them, creating a setuid/setgid executable from untrusted bytes.

Clear setuid and setgid bits from the staging file before publishing it.

Comment thread astrbot/core/utils/io.py
)
if target_path.exists():
shutil.copymode(target_path, staging_path)
staging_path.replace(target_path)

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.

🟡 Medium · Read-only destinations become replaceable

When the destination is non-writable but its parent directory is writable, download_file writes only to the staging file, then replaces the destination using the parent directory's permissions. On POSIX, a caller that cannot write an existing file can still overwrite it when the parent directory is writable, bypassing file-level write protection.

Check and enforce the existing destination's write authorization before publishing, rejecting the download when the caller could not write the destination directly.

Prompt for AI agents
In `astrbot/core/utils/io.py` at line 361:

**Read-only destinations become replaceable**

When the destination is non-writable but its parent directory is writable, `download_file` writes only to the staging file, then replaces the destination using the parent directory's permissions. On POSIX, a caller that cannot write an existing file can still overwrite it when the parent directory is writable, bypassing file-level write protection.

Check and enforce the existing destination's write authorization before publishing, rejecting the download when the caller could not write the destination directly.

Comment thread astrbot/core/utils/io.py
show_downloading_label=False,
)
if target_path.exists():
shutil.copymode(target_path, staging_path)

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.

🟡 Medium · Existing access metadata is lost

When the destination relies on ownership, ACLs, or extended attributes beyond its permission bits, shutil.copymode copies only mode bits, and staging_path.replace installs a new inode without the destination's other access metadata, changing who can access the completed download.

Preserve the destination's ownership, ACLs, and extended attributes when publishing the downloaded file.

Also at astrbot/core/utils/io.py:361-362.

Prompt for AI agents
In `astrbot/core/utils/io.py` at line 360:

**Existing access metadata is lost**

When the destination relies on ownership, ACLs, or extended attributes beyond its permission bits, `shutil.copymode` copies only mode bits, and `staging_path.replace` installs a new inode without the destination's other access metadata, changing who can access the completed download.

Preserve the destination's ownership, ACLs, and extended attributes when publishing the downloaded file.

Also at `astrbot/core/utils/io.py:361-362`.

Comment thread astrbot/core/utils/io.py
)
if target_path.exists():
shutil.copymode(target_path, staging_path)
staging_path.replace(target_path)

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.

🟡 Medium · Older download replaces newer result

When two successful downloads to the same destination finish in the reverse order from their start, the later-finishing call replaces the destination regardless of which download was started most recently, so an older response can overwrite a newer completed file and callers see stale content.

Serialize publication per destination or reject publication from calls superseded by a newer request.

Prompt for AI agents
In `astrbot/core/utils/io.py` at line 361:

**Older download replaces newer result**

When two successful downloads to the same destination finish in the reverse order from their start, the later-finishing call replaces the destination regardless of which download was started most recently, so an older response can overwrite a newer completed file and callers see stale content.

Serialize publication per destination or reject publication from calls superseded by a newer request.

Comment thread astrbot/core/utils/io.py
Comment on lines +304 to +306
with tempfile.TemporaryDirectory(
prefix=".astrbot-download-", dir=target_path.parent
) as staging_dir:

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.

🟡 Medium · Writable file cannot be updated

When the destination is writable but its parent directory does not permit creating entries, TemporaryDirectory raises PermissionError before the download because it must create a sibling entry, so the existing file is not updated.

Use a staging approach that does not require creating an entry in the destination's parent directory.

Prompt for AI agents
In `astrbot/core/utils/io.py` at lines 304-306:

**Writable file cannot be updated**

When the destination is writable but its parent directory does not permit creating entries, `TemporaryDirectory` raises `PermissionError` before the download because it must create a sibling entry, so the existing file is not updated.

Use a staging approach that does not require creating an entry in the destination's parent directory.

This branch has not been deployed

No deployments
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.

2 participants