Repository navigation
fix: preserve files when downloads fail or are cancelled #10796
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| import shutil | ||
| import socket | ||
| import ssl | ||
| import tempfile | ||
| import time | ||
| import uuid | ||
| from pathlib import Path | ||
|
|
@@ -280,7 +281,10 @@ async def download_file( | |
| progress_callback=None, | ||
| allow_insecure_ssl_fallback: bool = True, | ||
| ) -> None: | ||
| """Download a remote file to a local path. | ||
| """Download a remote file and replace the destination on success. | ||
|
|
||
| Keep the destination intact until the response and progress callbacks have | ||
| completed. Failed or cancelled downloads leave no partial file behind. | ||
|
|
||
| Args: | ||
| url: Remote URL to download. | ||
|
|
@@ -294,54 +298,67 @@ async def download_file( | |
| None. | ||
| """ | ||
|
|
||
| try: | ||
| ssl_context = ssl.create_default_context( | ||
| cafile=certifi.where(), | ||
| ) # 使用 certifi 提供的 CA 证书 | ||
| connector = aiohttp.TCPConnector(ssl=ssl_context) | ||
| async with aiohttp.ClientSession( | ||
| trust_env=True, | ||
| connector=connector, | ||
| ) as session: | ||
| async with session.get(url, timeout=1800) as resp: | ||
| _raise_for_download_status(resp, url) | ||
| with open(path, "wb") as f: | ||
| await _download_response_to_file( | ||
| resp, | ||
| f, | ||
| url, | ||
| show_progress, | ||
| progress_callback, | ||
| ) | ||
| except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError): | ||
| if not allow_insecure_ssl_fallback: | ||
| raise | ||
| # 关闭SSL验证(仅在证书验证失败时作为fallback) | ||
| logger.warning( | ||
| f"SSL certificate verification failed for {_safe_url_for_log(url)}. " | ||
| "Falling back to unverified connection (CERT_NONE). " | ||
| ) | ||
| logger.warning( | ||
| f"SSL certificate verification failed for {_safe_url_for_log(url)}. " | ||
| "Falling back to unverified connection (CERT_NONE). " | ||
| "This is insecure and exposes the application to man-in-the-middle attacks. " | ||
| "Please investigate certificate issues with the remote server." | ||
| ) | ||
| ssl_context = ssl.create_default_context() | ||
| ssl_context.check_hostname = False | ||
| ssl_context.verify_mode = ssl.CERT_NONE | ||
| async with aiohttp.ClientSession() as session: | ||
| async with session.get(url, ssl=ssl_context, timeout=120) as resp: | ||
| _raise_for_download_status(resp, url) | ||
| with open(path, "wb") as f: | ||
| await _download_response_to_file( | ||
| resp, | ||
| f, | ||
| url, | ||
| show_progress, | ||
| progress_callback, | ||
| show_downloading_label=False, | ||
| ) | ||
| # 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: | ||
| ssl_context = ssl.create_default_context( | ||
| cafile=certifi.where(), | ||
| ) # Use certifi's CA certificates. | ||
| connector = aiohttp.TCPConnector(ssl=ssl_context) | ||
| async with aiohttp.ClientSession( | ||
| trust_env=True, | ||
| connector=connector, | ||
| ) as session: | ||
| async with session.get(url, timeout=1800) as resp: | ||
| _raise_for_download_status(resp, url) | ||
| with staging_path.open("wb") as f: | ||
| await _download_response_to_file( | ||
| resp, | ||
| f, | ||
| url, | ||
| show_progress, | ||
| progress_callback, | ||
| ) | ||
| except ( | ||
| aiohttp.ClientConnectorSSLError, | ||
| aiohttp.ClientConnectorCertificateError, | ||
| ): | ||
| if not allow_insecure_ssl_fallback: | ||
| raise | ||
| # Retry without verification only after certificate failures. | ||
| logger.warning( | ||
| f"SSL certificate verification failed for {_safe_url_for_log(url)}. " | ||
| "Falling back to unverified connection (CERT_NONE). " | ||
| ) | ||
| logger.warning( | ||
| f"SSL certificate verification failed for {_safe_url_for_log(url)}. " | ||
| "Falling back to unverified connection (CERT_NONE). " | ||
| "This is insecure and exposes the application to man-in-the-middle attacks. " | ||
| "Please investigate certificate issues with the remote server." | ||
| ) | ||
| ssl_context = ssl.create_default_context() | ||
| ssl_context.check_hostname = False | ||
| ssl_context.verify_mode = ssl.CERT_NONE | ||
| async with aiohttp.ClientSession() as session: | ||
| async with session.get(url, ssl=ssl_context, timeout=120) as resp: | ||
| _raise_for_download_status(resp, url) | ||
| with staging_path.open("wb") as f: | ||
| await _download_response_to_file( | ||
| resp, | ||
| f, | ||
| url, | ||
| show_progress, | ||
| progress_callback, | ||
| show_downloading_label=False, | ||
| ) | ||
| if target_path.exists(): | ||
| shutil.copymode(target_path, staging_path) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Clear setuid and setgid bits from the staging file before publishing it. Prompt for AI agentsIn `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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Preserve the destination's ownership, ACLs, and extended attributes when publishing the downloaded file. Also at Prompt for AI agentsIn `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`. |
||
| staging_path.replace(target_path) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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 agentsIn `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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 agentsIn `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. |
||
| if show_progress: | ||
| print() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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,
TemporaryDirectoryraisesPermissionErrorbefore 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