mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-28 23:46:21 +00:00
fix(channels): fix Telegram inbound file download and sandbox readability (#5581)
* fix(channels): fix Telegram inbound file download and sandbox readability - Shut down the download Bot on the Telegram loop (Bot.shutdown(), which closes its HTTPX clients) before the loop stops, instead of the non-existent Bot.session.close(). - Grant group/other read on channel-downloaded uploads so the non-root AIO/Docker sandbox process can read the root-written 0o600 file. - Apply the sandbox permission change with os.fchmod on a descriptor opened with O_NOFOLLOW (validated as a regular file via fstat), bound to the validated upload inode, so a symlink swapped in after lstat cannot redirect the chmod to a target outside the uploads directory. The open also uses O_NONBLOCK so a sandbox-swapped FIFO cannot block the read-only open before the regular-file check (matching the existing open_upload_file_no_symlink convention). Centralized in a shared apply_upload_sandbox_permits helper reused by the channel inbound path and the HTTP upload readable/writable helpers. - Surface the download failure cause chain in logs with the Bot API URL masked: the configured token is redacted and both URL forms are collapsed, covering the file download URL (/file/bot<token>/...) and the method URLs (/bot<token>/getMe, /bot<token>/getFile). - Migrate the existing receive_file tests onto the download Bot and add coverage for _get_download_bot (loop-bound creation + caching, cleanup on init failure), download-bot routing over the application bot, real-Bot shutdown closing both HTTPX clients, receive_file timeout-containment, masked cause-chain logging (file and method URL forms), and inbound-file sandbox perms (including the swap-after-lstat symlink regression). * fix(uploads): surface sandbox permission failures --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
74f006951c
commit
c12a3e6fa0
@ -7,6 +7,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from collections.abc import Awaitable, Callable, Mapping
|
from collections.abc import Awaitable, Callable, Mapping
|
||||||
@ -52,6 +53,7 @@ from deerflow.skills.slash import parse_slash_skill_reference
|
|||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||||
from deerflow.trace_context import ensure_trace_context
|
from deerflow.trace_context import ensure_trace_context
|
||||||
|
from deerflow.uploads.manager import apply_upload_sandbox_permits
|
||||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -1082,6 +1084,19 @@ def _prepare_artifact_delivery(
|
|||||||
return response_text, attachments
|
return response_text, attachments
|
||||||
|
|
||||||
|
|
||||||
|
def _make_inbound_file_sandbox_readable(file_path: Path) -> None:
|
||||||
|
"""Make a channel-downloaded upload readable by the sandbox process.
|
||||||
|
|
||||||
|
The gateway writes inbound files as root with 0o600; in AIO/Docker sandbox
|
||||||
|
mode the sandbox runs as a non-root user on the bind-mounted path and
|
||||||
|
cannot read the file without group/other read bits. Delegates to the shared
|
||||||
|
apply_upload_sandbox_permits helper so the permission change stays bound to
|
||||||
|
the validated upload inode (O_NOFOLLOW + fchmod) and cannot be redirected
|
||||||
|
through a symlink swapped in after validation.
|
||||||
|
"""
|
||||||
|
apply_upload_sandbox_permits(file_path, stat.S_IRGRP | stat.S_IROTH)
|
||||||
|
|
||||||
|
|
||||||
async def _ingest_inbound_files(thread_id: str, msg: InboundMessage, *, user_id: str | None = None) -> list[dict[str, Any]]:
|
async def _ingest_inbound_files(thread_id: str, msg: InboundMessage, *, user_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
if not msg.files:
|
if not msg.files:
|
||||||
return []
|
return []
|
||||||
@ -1160,6 +1175,9 @@ async def _ingest_inbound_files(thread_id: str, msg: InboundMessage, *, user_id:
|
|||||||
dest = uploads_dir / safe_name
|
dest = uploads_dir / safe_name
|
||||||
try:
|
try:
|
||||||
dest = await asyncio.to_thread(write_upload_file_no_symlink, uploads_dir, safe_name, data)
|
dest = await asyncio.to_thread(write_upload_file_no_symlink, uploads_dir, safe_name, data)
|
||||||
|
# Root-written 0o600 files are unreadable to the non-root
|
||||||
|
# sandbox; grant group/other read like the HTTP upload path.
|
||||||
|
await asyncio.to_thread(_make_inbound_file_sandbox_readable, dest)
|
||||||
except UnsafeUploadPathError:
|
except UnsafeUploadPathError:
|
||||||
logger.warning("[Manager] skipping inbound file with unsafe destination: %s", safe_name)
|
logger.warning("[Manager] skipping inbound file with unsafe destination: %s", safe_name)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -93,6 +93,14 @@ class TelegramChannel(Channel):
|
|||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._tg_loop: asyncio.AbstractEventLoop | None = None
|
self._tg_loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._main_loop: asyncio.AbstractEventLoop | None = None
|
self._main_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
# Dedicated Bot for inbound file downloads. The Application's Bot is
|
||||||
|
# built on the manager/main loop (see start()), so its httpx
|
||||||
|
# connection pool is bound to that loop; opening a fresh download
|
||||||
|
# connection from the Telegram loop then trips "Event bound to a
|
||||||
|
# different event loop". A Bot initialized on the Telegram loop keeps
|
||||||
|
# its pool on the loop that performs the download.
|
||||||
|
self._download_bot: Any = None
|
||||||
|
self._download_bot_lock = asyncio.Lock()
|
||||||
# Tasks submitted from the main dispatcher loop back to PTB's loop.
|
# Tasks submitted from the main dispatcher loop back to PTB's loop.
|
||||||
# Only the Telegram loop mutates this set.
|
# Only the Telegram loop mutates this set.
|
||||||
self._tg_bridge_tasks: set[asyncio.Task[Any]] = set()
|
self._tg_bridge_tasks: set[asyncio.Task[Any]] = set()
|
||||||
@ -175,7 +183,7 @@ class TelegramChannel(Channel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if telegram_loop and telegram_loop.is_running():
|
if telegram_loop and telegram_loop.is_running():
|
||||||
drain_future = asyncio.run_coroutine_threadsafe(self._cancel_telegram_bridge_tasks(), telegram_loop)
|
drain_future = asyncio.run_coroutine_threadsafe(self._shutdown_telegram_resources(), telegram_loop)
|
||||||
try:
|
try:
|
||||||
remaining = max(0.0, deadline - shutdown_loop.time())
|
remaining = max(0.0, deadline - shutdown_loop.time())
|
||||||
await asyncio.wait_for(asyncio.wrap_future(drain_future), timeout=remaining)
|
await asyncio.wait_for(asyncio.wrap_future(drain_future), timeout=remaining)
|
||||||
@ -184,9 +192,9 @@ class TelegramChannel(Channel):
|
|||||||
raise
|
raise
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
drain_future.cancel()
|
drain_future.cancel()
|
||||||
logger.warning("[Telegram] timed out cancelling inbound file downloads during shutdown")
|
logger.warning("[Telegram] timed out shutting down Telegram loop resources during shutdown")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("[Telegram] failed to cancel inbound file downloads during shutdown: %s", type(exc).__name__)
|
logger.warning("[Telegram] failed to shut down Telegram loop resources during shutdown: %s", type(exc).__name__)
|
||||||
finally:
|
finally:
|
||||||
if telegram_loop and telegram_loop.is_running():
|
if telegram_loop and telegram_loop.is_running():
|
||||||
try:
|
try:
|
||||||
@ -462,7 +470,6 @@ class TelegramChannel(Channel):
|
|||||||
if not msg.files:
|
if not msg.files:
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
bot = self._application.bot if self._application is not None else None
|
|
||||||
materialized: list[dict[str, Any]] = []
|
materialized: list[dict[str, Any]] = []
|
||||||
unavailable: list[str] = []
|
unavailable: list[str] = []
|
||||||
|
|
||||||
@ -478,6 +485,24 @@ class TelegramChannel(Channel):
|
|||||||
unavailable.append(f"{filename} (exceeds the 20 MB download limit)")
|
unavailable.append(f"{filename} (exceeds the 20 MB download limit)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Resolve the loop-bound download Bot. It is created on the
|
||||||
|
# Telegram loop so its httpx pool is bound to the loop that
|
||||||
|
# performs the download (the Application's Bot is main-loop-bound).
|
||||||
|
# First-time resolution performs a network getMe, so it runs inside
|
||||||
|
# its own guard: an init failure must mark only this attachment
|
||||||
|
# unavailable, never abort the whole message.
|
||||||
|
try:
|
||||||
|
bot = await self._get_download_bot()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"[Telegram] failed to initialize download bot for inbound file %s: %s%s",
|
||||||
|
filename,
|
||||||
|
type(exc).__name__,
|
||||||
|
self._describe_download_cause(exc),
|
||||||
|
)
|
||||||
|
unavailable.append(f"{filename} (download failed)")
|
||||||
|
continue
|
||||||
|
|
||||||
if bot is None or not file_id:
|
if bot is None or not file_id:
|
||||||
logger.error("[Telegram] cannot download inbound file: %s", filename)
|
logger.error("[Telegram] cannot download inbound file: %s", filename)
|
||||||
unavailable.append(f"{filename} (download unavailable)")
|
unavailable.append(f"{filename} (download unavailable)")
|
||||||
@ -487,8 +512,14 @@ class TelegramChannel(Channel):
|
|||||||
resolved_size, content = await self._run_on_telegram_loop(self._download_inbound_file(bot, file_id))
|
resolved_size, content = await self._run_on_telegram_loop(self._download_inbound_file(bot, file_id))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Exception strings from HTTP clients can contain request URLs.
|
# Exception strings from HTTP clients can contain request URLs.
|
||||||
# Log only the class name so a Bot API token can never leak.
|
# Log only class names so a Bot API token can never leak, but
|
||||||
logger.error("[Telegram] failed to download inbound file %s: %s", filename, type(exc).__name__)
|
# include the cause chain to distinguish timeout / reset / TLS.
|
||||||
|
logger.error(
|
||||||
|
"[Telegram] failed to download inbound file %s: %s%s",
|
||||||
|
filename,
|
||||||
|
type(exc).__name__,
|
||||||
|
self._describe_download_cause(exc),
|
||||||
|
)
|
||||||
unavailable.append(f"{filename} (download failed)")
|
unavailable.append(f"{filename} (download failed)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -520,6 +551,69 @@ class TelegramChannel(Channel):
|
|||||||
|
|
||||||
# -- helpers -----------------------------------------------------------
|
# -- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _describe_download_cause(self, exc: BaseException) -> str:
|
||||||
|
"""Build a token-safe suffix describing a download failure's cause.
|
||||||
|
|
||||||
|
HTTP client exception strings can embed request URLs. Both Bot API URL
|
||||||
|
forms carry the token in the path — the file download URL
|
||||||
|
(``/file/bot<token>/…``) and the method URLs (``/bot<token>/getMe``,
|
||||||
|
``/bot<token>/getFile``) — so the configured token is removed first and
|
||||||
|
any remaining Bot API URL is collapsed before the cause chain is logged.
|
||||||
|
The cause class and message are kept to distinguish timeout / reset / TLS.
|
||||||
|
"""
|
||||||
|
cause = exc.__cause__ or exc.__context__
|
||||||
|
if cause is None:
|
||||||
|
return ""
|
||||||
|
token = self.config.get("bot_token", "")
|
||||||
|
cause_msg = str(cause)
|
||||||
|
if token:
|
||||||
|
cause_msg = cause_msg.replace(token, "[redacted]")
|
||||||
|
cause_msg = re.sub(r"api\.telegram\.org/bot[^/\s]+/", "api.telegram.org/bot[redacted]/", cause_msg)
|
||||||
|
cause_msg = re.sub(r"api\.telegram\.org/file/\S+", "api.telegram.org/file/[redacted]", cause_msg)
|
||||||
|
cause_msg = cause_msg[:200]
|
||||||
|
return f" caused_by={type(cause).__name__}:{cause_msg}"
|
||||||
|
|
||||||
|
async def _get_download_bot(self) -> Any:
|
||||||
|
"""Return a PTB Bot whose httpx client is bound to the Telegram loop.
|
||||||
|
|
||||||
|
The Application's Bot is constructed on the manager/main loop, so its
|
||||||
|
shared connection pool is bound there; opening a fresh download
|
||||||
|
connection from the Telegram loop fails with "bound to a different
|
||||||
|
event loop". This dedicated Bot is created and initialized on the
|
||||||
|
Telegram loop, keeping its pool on the loop that performs downloads.
|
||||||
|
"""
|
||||||
|
if self._download_bot is not None:
|
||||||
|
return self._download_bot
|
||||||
|
telegram_loop = self._tg_loop
|
||||||
|
if telegram_loop is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _init() -> Any:
|
||||||
|
if self._download_bot is not None:
|
||||||
|
return self._download_bot
|
||||||
|
async with self._download_bot_lock:
|
||||||
|
if self._download_bot is not None:
|
||||||
|
return self._download_bot
|
||||||
|
from telegram import Bot
|
||||||
|
|
||||||
|
bot = Bot(token=self.config.get("bot_token", ""))
|
||||||
|
try:
|
||||||
|
await bot.initialize()
|
||||||
|
except BaseException:
|
||||||
|
# initialize() performs a network getMe and may have left
|
||||||
|
# partially-open HTTPX clients. Close them (no-op if the
|
||||||
|
# request objects never initialized) before the error
|
||||||
|
# propagates, and never cache a bot that failed to start.
|
||||||
|
try:
|
||||||
|
await bot.shutdown()
|
||||||
|
except BaseException:
|
||||||
|
logger.debug("[Telegram] failed to close partially-initialized download bot", exc_info=True)
|
||||||
|
raise
|
||||||
|
self._download_bot = bot
|
||||||
|
return bot
|
||||||
|
|
||||||
|
return await self._run_on_telegram_loop(_init())
|
||||||
|
|
||||||
async def _download_inbound_file(self, bot: Any, file_id: str) -> tuple[int | None, bytearray | None]:
|
async def _download_inbound_file(self, bot: Any, file_id: str) -> tuple[int | None, bytearray | None]:
|
||||||
"""Fetch one file entirely on the event loop that owns PTB's HTTP client."""
|
"""Fetch one file entirely on the event loop that owns PTB's HTTP client."""
|
||||||
telegram_file = await bot.get_file(file_id)
|
telegram_file = await bot.get_file(file_id)
|
||||||
@ -553,6 +647,32 @@ class TelegramChannel(Channel):
|
|||||||
if still_pending:
|
if still_pending:
|
||||||
logger.warning("[Telegram] %d inbound file download task(s) did not cancel promptly", len(still_pending))
|
logger.warning("[Telegram] %d inbound file download task(s) did not cancel promptly", len(still_pending))
|
||||||
|
|
||||||
|
async def _shutdown_telegram_resources(self) -> None:
|
||||||
|
"""Cancel in-flight PTB bridge work and close the download Bot.
|
||||||
|
|
||||||
|
Runs on the Telegram loop (scheduled from ``stop()``) and must finish
|
||||||
|
before that loop is stopped, so the download Bot's HTTPX clients are
|
||||||
|
shut down on the loop that owns them.
|
||||||
|
"""
|
||||||
|
await self._cancel_telegram_bridge_tasks()
|
||||||
|
await self._shutdown_download_bot()
|
||||||
|
|
||||||
|
async def _shutdown_download_bot(self) -> None:
|
||||||
|
"""Close the download Bot's HTTPX clients on the Telegram loop.
|
||||||
|
|
||||||
|
python-telegram-bot 22.7 exposes no ``Bot.session``; the HTTPX clients
|
||||||
|
are closed by ``Bot.shutdown()``, which is a no-op if the bot was never
|
||||||
|
initialized. Must run on the loop the bot was initialized on.
|
||||||
|
"""
|
||||||
|
bot = self._download_bot
|
||||||
|
if bot is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await bot.shutdown()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("[Telegram] failed to shut down download bot", exc_info=True)
|
||||||
|
self._download_bot = None
|
||||||
|
|
||||||
async def _run_on_telegram_loop(self, coroutine: Coroutine[Any, Any, Any]) -> Any:
|
async def _run_on_telegram_loop(self, coroutine: Coroutine[Any, Any, Any]) -> Any:
|
||||||
"""Await a PTB coroutine without using its HTTP client across event loops."""
|
"""Await a PTB coroutine without using its HTTP client across event loops."""
|
||||||
telegram_loop = self._tg_loop
|
telegram_loop = self._tg_loop
|
||||||
|
|||||||
@ -24,6 +24,7 @@ from deerflow.uploads.manager import (
|
|||||||
UPLOAD_STAGING_SUFFIX,
|
UPLOAD_STAGING_SUFFIX,
|
||||||
PathTraversalError,
|
PathTraversalError,
|
||||||
UnsafeUploadPathError,
|
UnsafeUploadPathError,
|
||||||
|
apply_upload_sandbox_permits,
|
||||||
claim_unique_filename,
|
claim_unique_filename,
|
||||||
delete_file_safe,
|
delete_file_safe,
|
||||||
enrich_file_listing,
|
enrich_file_listing,
|
||||||
@ -123,16 +124,11 @@ def _make_file_sandbox_writable(file_path: os.PathLike[str] | str) -> None:
|
|||||||
In AIO sandbox mode, the gateway writes the authoritative host-side file
|
In AIO sandbox mode, the gateway writes the authoritative host-side file
|
||||||
first, then the sandbox runtime may rewrite the same mounted path. Granting
|
first, then the sandbox runtime may rewrite the same mounted path. Granting
|
||||||
world-writable access here prevents permission mismatches between the
|
world-writable access here prevents permission mismatches between the
|
||||||
gateway user and the sandbox runtime user.
|
gateway user and the sandbox runtime user. Delegates to the shared
|
||||||
|
apply_upload_sandbox_permits helper so the change stays bound to the
|
||||||
|
validated upload inode (O_NOFOLLOW + fchmod).
|
||||||
"""
|
"""
|
||||||
file_stat = os.lstat(file_path)
|
apply_upload_sandbox_permits(file_path, stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH | stat.S_IRGRP | stat.S_IROTH)
|
||||||
if stat.S_ISLNK(file_stat.st_mode):
|
|
||||||
logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
writable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH | stat.S_IRGRP | stat.S_IROTH
|
|
||||||
chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}
|
|
||||||
os.chmod(file_path, writable_mode, **chmod_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_file_sandbox_readable(file_path: os.PathLike[str] | str) -> None:
|
def _make_file_sandbox_readable(file_path: os.PathLike[str] | str) -> None:
|
||||||
@ -142,16 +138,10 @@ def _make_file_sandbox_readable(file_path: os.PathLike[str] | str) -> None:
|
|||||||
permissions, then bind-mounts the host directory into the container. The
|
permissions, then bind-mounts the host directory into the container. The
|
||||||
sandbox process inside the container runs as a non-root user and cannot
|
sandbox process inside the container runs as a non-root user and cannot
|
||||||
read those files without group/other read bits. This function adds
|
read those files without group/other read bits. This function adds
|
||||||
``S_IRGRP | S_IROTH`` so the sandbox can read the uploaded content.
|
``S_IRGRP | S_IROTH`` so the sandbox can read the uploaded content, via the
|
||||||
|
shared apply_upload_sandbox_permits helper (O_NOFOLLOW + fchmod).
|
||||||
"""
|
"""
|
||||||
file_stat = os.lstat(file_path)
|
apply_upload_sandbox_permits(file_path, stat.S_IRGRP | stat.S_IROTH)
|
||||||
if stat.S_ISLNK(file_stat.st_mode):
|
|
||||||
logger.warning("Skipping sandbox chmod for symlinked upload path: %s", file_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
readable_mode = stat.S_IMODE(file_stat.st_mode) | stat.S_IRGRP | stat.S_IROTH
|
|
||||||
chmod_kwargs = {"follow_symlinks": False} if os.chmod in os.supports_follow_symlinks else {}
|
|
||||||
os.chmod(file_path, readable_mode, **chmod_kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool:
|
def _uses_thread_data_mounts(sandbox_provider: SandboxProvider) -> bool:
|
||||||
|
|||||||
@ -333,6 +333,73 @@ def copy_upload_file_no_symlink(base_dir: Path, filename: str, src: Path) -> Pat
|
|||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def apply_upload_sandbox_permits(file_path: os.PathLike[str] | str, extra_mode_bits: int) -> None:
|
||||||
|
"""Apply sandbox permission bits to an upload, bound to its validated inode.
|
||||||
|
|
||||||
|
The gateway writes uploads as root with ``0o600``. In AIO/Docker sandbox mode
|
||||||
|
the sandbox runs as a non-root user on the bind-mounted path, so it needs
|
||||||
|
extra group/other (and, for the writable variant, write) bits.
|
||||||
|
|
||||||
|
The change is applied with ``os.fchmod`` on a descriptor opened with
|
||||||
|
``O_NOFOLLOW`` (and ``O_NONBLOCK`` where available) and validated as a
|
||||||
|
regular file via ``os.fstat``. That binds the permission change to the exact
|
||||||
|
inode that was validated instead of re-resolving the pathname, so a sandbox
|
||||||
|
process that swaps the upload for a symlink after validation cannot redirect
|
||||||
|
the change to a target outside the uploads directory. ``O_NONBLOCK`` stops a
|
||||||
|
swapped-in FIFO from blocking the open before the type check. On platforms
|
||||||
|
without ``O_NOFOLLOW``/``os.fchmod`` (Windows) the ``os.chmod`` path (with
|
||||||
|
the lstat symlink guard) is retained. A path that disappears or becomes a
|
||||||
|
symlink during validation is skipped; other permission errors propagate so
|
||||||
|
callers do not report an upload the sandbox still cannot access.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
file_stat = os.lstat(file_path)
|
||||||
|
except (FileNotFoundError, NotADirectoryError):
|
||||||
|
return
|
||||||
|
if stat.S_ISLNK(file_stat.st_mode):
|
||||||
|
return
|
||||||
|
|
||||||
|
if hasattr(os, "O_NOFOLLOW") and hasattr(os, "fchmod"):
|
||||||
|
open_flags = os.O_RDONLY | os.O_NOFOLLOW
|
||||||
|
if hasattr(os, "O_NONBLOCK"):
|
||||||
|
# The uploads directory is sandbox-writable, so the sandbox can swap
|
||||||
|
# the just-written file for a FIFO before this open. Without
|
||||||
|
# O_NONBLOCK an O_RDONLY open on a FIFO blocks in the kernel waiting
|
||||||
|
# for a writer (before the fstat regular-file check below), hanging
|
||||||
|
# ingestion and occupying a Gateway file-IO executor thread that
|
||||||
|
# coroutine cancellation cannot interrupt. O_NONBLOCK returns
|
||||||
|
# immediately; the S_ISREG check then skips the non-regular inode.
|
||||||
|
open_flags |= os.O_NONBLOCK
|
||||||
|
try:
|
||||||
|
fd = os.open(file_path, open_flags)
|
||||||
|
except OSError as exc:
|
||||||
|
# The path disappeared, stopped resolving, or became a symlink
|
||||||
|
# after lstat. Leave permissions untouched for these expected
|
||||||
|
# replacement races, but surface operational failures such as
|
||||||
|
# EACCES so callers cannot report an unreadable upload as ready.
|
||||||
|
if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP}:
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
opened = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(opened.st_mode):
|
||||||
|
return
|
||||||
|
os.fchmod(fd, stat.S_IMODE(opened.st_mode) | extra_mode_bits)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Windows / platforms without O_NOFOLLOW + fchmod: retain the lstat-guarded
|
||||||
|
# chmod fallback. Expected replacement races are no-ops; permission errors
|
||||||
|
# must still reach the caller.
|
||||||
|
try:
|
||||||
|
os.chmod(file_path, stat.S_IMODE(file_stat.st_mode) | extra_mode_bits)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP}:
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def list_files_in_dir(directory: Path) -> dict:
|
def list_files_in_dir(directory: Path) -> dict:
|
||||||
"""List files (not directories) in *directory*.
|
"""List files (not directories) in *directory*.
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import stat
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from support.symlinks import symlink_or_skip
|
from support.symlinks import symlink_or_skip
|
||||||
|
|
||||||
from app.channels.base import Channel
|
from app.channels.base import Channel
|
||||||
@ -404,6 +406,170 @@ class TestInboundFileIngestion:
|
|||||||
assert (uploads_dir / "victim_1.txt").read_bytes() == b"new attachment data"
|
assert (uploads_dir / "victim_1.txt").read_bytes() == b"new attachment data"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inbound file sandbox-readability tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestInboundFileSandboxPerms:
|
||||||
|
def test_make_inbound_file_sandbox_readable_sets_group_other_read(self, tmp_path):
|
||||||
|
from app.channels.manager import _make_inbound_file_sandbox_readable
|
||||||
|
|
||||||
|
f = tmp_path / "photo.jpg"
|
||||||
|
f.write_bytes(b"\x89PNG data")
|
||||||
|
os.chmod(f, 0o600)
|
||||||
|
|
||||||
|
_make_inbound_file_sandbox_readable(f)
|
||||||
|
|
||||||
|
mode = stat.S_IMODE(os.stat(f).st_mode)
|
||||||
|
assert mode == 0o644
|
||||||
|
assert mode & stat.S_IRGRP
|
||||||
|
assert mode & stat.S_IROTH
|
||||||
|
|
||||||
|
def test_make_inbound_file_sandbox_readable_preserves_owner_bits(self, tmp_path):
|
||||||
|
from app.channels.manager import _make_inbound_file_sandbox_readable
|
||||||
|
|
||||||
|
f = tmp_path / "report.pdf"
|
||||||
|
f.write_bytes(b"pdf")
|
||||||
|
os.chmod(f, 0o640)
|
||||||
|
|
||||||
|
_make_inbound_file_sandbox_readable(f)
|
||||||
|
|
||||||
|
mode = stat.S_IMODE(os.stat(f).st_mode)
|
||||||
|
# Owner rw and the existing group read are preserved; only the missing
|
||||||
|
# other-read bit is added and no write is granted to group/other.
|
||||||
|
assert mode == 0o644
|
||||||
|
assert not (mode & stat.S_IWOTH)
|
||||||
|
|
||||||
|
def test_make_inbound_file_sandbox_readable_skips_symlink(self, tmp_path):
|
||||||
|
from support.symlinks import symlink_or_skip
|
||||||
|
|
||||||
|
from app.channels.manager import _make_inbound_file_sandbox_readable
|
||||||
|
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
target.write_text("secret")
|
||||||
|
os.chmod(target, 0o600)
|
||||||
|
link = tmp_path / "link.txt"
|
||||||
|
symlink_or_skip(link, target)
|
||||||
|
|
||||||
|
# Must not raise and must leave the symlink target's mode untouched.
|
||||||
|
_make_inbound_file_sandbox_readable(link)
|
||||||
|
|
||||||
|
assert stat.S_IMODE(os.stat(target).st_mode) == 0o600
|
||||||
|
|
||||||
|
def test_make_inbound_file_sandbox_readable_missing_noop(self, tmp_path):
|
||||||
|
from app.channels.manager import _make_inbound_file_sandbox_readable
|
||||||
|
|
||||||
|
# Best-effort helper: a missing file is a silent no-op.
|
||||||
|
_make_inbound_file_sandbox_readable(tmp_path / "does-not-exist.txt")
|
||||||
|
|
||||||
|
def test_make_inbound_file_sandbox_readable_swap_after_lstat_does_not_follow_symlink(self, tmp_path, monkeypatch):
|
||||||
|
from app.channels.manager import _make_inbound_file_sandbox_readable
|
||||||
|
|
||||||
|
# An unrelated target OUTSIDE the uploads dir with restrictive perms.
|
||||||
|
target = tmp_path / "outside" / "secret.txt"
|
||||||
|
target.parent.mkdir()
|
||||||
|
target.write_text("secret")
|
||||||
|
os.chmod(target, 0o600)
|
||||||
|
|
||||||
|
uploads_dir = tmp_path / "uploads"
|
||||||
|
uploads_dir.mkdir()
|
||||||
|
upload = uploads_dir / "report.txt"
|
||||||
|
upload.write_bytes(b"upload") # a regular file at lstat time
|
||||||
|
|
||||||
|
# Select the symlink-following chmod fallback: a platform whose os.chmod
|
||||||
|
# is not in os.supports_follow_symlinks degrades to a following chmod.
|
||||||
|
monkeypatch.setattr(os, "supports_follow_symlinks", frozenset(), raising=False)
|
||||||
|
|
||||||
|
real_lstat = os.lstat
|
||||||
|
|
||||||
|
def racing_lstat(path, *args, **kwargs):
|
||||||
|
st = real_lstat(path)
|
||||||
|
# The sandbox swaps the just-validated regular file for a symlink to
|
||||||
|
# the out-of-dir target immediately after lstat (the TOCTOU race).
|
||||||
|
if path == upload:
|
||||||
|
os.unlink(path)
|
||||||
|
os.symlink(target, path)
|
||||||
|
return st
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "lstat", racing_lstat)
|
||||||
|
|
||||||
|
_make_inbound_file_sandbox_readable(upload)
|
||||||
|
|
||||||
|
# The permission change must stay bound to the upload's validated inode
|
||||||
|
# and must not follow the swapped-in symlink to a target outside the
|
||||||
|
# uploads dir (the old following-chmod fallback would chmod it 0600->0644).
|
||||||
|
assert stat.S_IMODE(os.lstat(target).st_mode) == 0o600
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not (hasattr(os, "mkfifo") and hasattr(os, "O_NOFOLLOW")), reason="POSIX-only: mkfifo + O_NOFOLLOW")
|
||||||
|
def test_make_inbound_file_sandbox_readable_swap_after_lstat_does_not_block_on_fifo(self, tmp_path, monkeypatch):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from app.channels.manager import _make_inbound_file_sandbox_readable
|
||||||
|
|
||||||
|
uploads_dir = tmp_path / "uploads"
|
||||||
|
uploads_dir.mkdir()
|
||||||
|
upload = uploads_dir / "report.txt"
|
||||||
|
upload.write_bytes(b"upload") # a regular file at lstat time
|
||||||
|
|
||||||
|
real_lstat = os.lstat
|
||||||
|
|
||||||
|
def racing_lstat(path, *args, **kwargs):
|
||||||
|
st = real_lstat(path)
|
||||||
|
# The sandbox swaps the just-validated regular file for a FIFO
|
||||||
|
# immediately after lstat. Without O_NONBLOCK the read-only open
|
||||||
|
# below blocks in the kernel waiting for a writer, before the
|
||||||
|
# S_ISREG type check can skip the non-regular inode.
|
||||||
|
if path == upload:
|
||||||
|
os.unlink(path)
|
||||||
|
os.mkfifo(path)
|
||||||
|
return st
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "lstat", racing_lstat)
|
||||||
|
|
||||||
|
completed = threading.Event()
|
||||||
|
|
||||||
|
def call():
|
||||||
|
_make_inbound_file_sandbox_readable(upload)
|
||||||
|
completed.set()
|
||||||
|
|
||||||
|
worker = threading.Thread(target=call, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
finished = completed.wait(5)
|
||||||
|
if not finished:
|
||||||
|
# Unblock the blocked open so the leaked thread can exit, then fail.
|
||||||
|
unblocker = os.open(upload, os.O_WRONLY | os.O_NONBLOCK)
|
||||||
|
os.close(unblocker)
|
||||||
|
worker.join(timeout=5)
|
||||||
|
pytest.fail("apply_upload_sandbox_permits blocked on a swapped-in FIFO (missing O_NONBLOCK)")
|
||||||
|
|
||||||
|
# Returned promptly and left the FIFO untouched (no chmod on a FIFO).
|
||||||
|
assert stat.S_ISFIFO(os.lstat(upload).st_mode)
|
||||||
|
|
||||||
|
def test_ingest_inbound_files_makes_file_sandbox_readable(self, tmp_path):
|
||||||
|
from app.channels import manager
|
||||||
|
|
||||||
|
uploads_dir = tmp_path / "uploads"
|
||||||
|
uploads_dir.mkdir()
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel_name="telegram",
|
||||||
|
chat_id="chat-1",
|
||||||
|
user_id="user-1",
|
||||||
|
text="see attachment",
|
||||||
|
files=[{"type": "image", "filename": "photo.jpg", "_content": b"\x89PNG data"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("deerflow.uploads.manager.ensure_uploads_dir", return_value=uploads_dir):
|
||||||
|
_run(manager._ingest_inbound_files("thread-1", msg))
|
||||||
|
|
||||||
|
dest = uploads_dir / "photo.jpg"
|
||||||
|
mode = stat.S_IMODE(os.stat(dest).st_mode)
|
||||||
|
# The 0o600 root-written upload is made group/other readable so the
|
||||||
|
# non-root sandbox process can read it.
|
||||||
|
assert mode & stat.S_IRGRP
|
||||||
|
assert mode & stat.S_IROTH
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Channel base class _on_outbound with attachments
|
# Channel base class _on_outbound with attachments
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -9406,7 +9406,7 @@ class TestTelegramInboundMessages:
|
|||||||
downloaded = bytearray(b"data")
|
downloaded = bytearray(b"data")
|
||||||
telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=AsyncMock(return_value=downloaded))
|
telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=AsyncMock(return_value=downloaded))
|
||||||
bot = SimpleNamespace(get_file=AsyncMock(return_value=telegram_file))
|
bot = SimpleNamespace(get_file=AsyncMock(return_value=telegram_file))
|
||||||
ch._application = SimpleNamespace(bot=bot)
|
ch._download_bot = bot
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9475,7 +9475,7 @@ class TestTelegramInboundMessages:
|
|||||||
return LoopBoundFile()
|
return LoopBoundFile()
|
||||||
|
|
||||||
ch._tg_loop = telegram_loop
|
ch._tg_loop = telegram_loop
|
||||||
ch._application = SimpleNamespace(bot=LoopBoundBot())
|
ch._download_bot = LoopBoundBot()
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9534,7 +9534,7 @@ class TestTelegramInboundMessages:
|
|||||||
ch._tg_loop = telegram_loop
|
ch._tg_loop = telegram_loop
|
||||||
ch._thread = loop_thread
|
ch._thread = loop_thread
|
||||||
ch._running = True
|
ch._running = True
|
||||||
ch._application = SimpleNamespace(bot=LoopBoundBot())
|
ch._download_bot = LoopBoundBot()
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9619,7 +9619,7 @@ class TestTelegramInboundMessages:
|
|||||||
stopped_loop = asyncio.new_event_loop()
|
stopped_loop = asyncio.new_event_loop()
|
||||||
bot = SimpleNamespace(get_file=AsyncMock())
|
bot = SimpleNamespace(get_file=AsyncMock())
|
||||||
ch._tg_loop = stopped_loop
|
ch._tg_loop = stopped_loop
|
||||||
ch._application = SimpleNamespace(bot=bot)
|
ch._download_bot = bot
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9647,7 +9647,7 @@ class TestTelegramInboundMessages:
|
|||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
bot = SimpleNamespace(get_file=AsyncMock())
|
bot = SimpleNamespace(get_file=AsyncMock())
|
||||||
ch._application = SimpleNamespace(bot=bot)
|
ch._download_bot = bot
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9681,7 +9681,7 @@ class TestTelegramInboundMessages:
|
|||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
telegram_file = SimpleNamespace(file_size=2, download_as_bytearray=AsyncMock(return_value=bytearray(b"four")))
|
telegram_file = SimpleNamespace(file_size=2, download_as_bytearray=AsyncMock(return_value=bytearray(b"four")))
|
||||||
ch._application = SimpleNamespace(bot=SimpleNamespace(get_file=AsyncMock(return_value=telegram_file)))
|
ch._download_bot = SimpleNamespace(get_file=AsyncMock(return_value=telegram_file))
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9707,7 +9707,7 @@ class TestTelegramInboundMessages:
|
|||||||
ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
download = AsyncMock(return_value=bytearray(b"four"))
|
download = AsyncMock(return_value=bytearray(b"four"))
|
||||||
telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=download)
|
telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=download)
|
||||||
ch._application = SimpleNamespace(bot=SimpleNamespace(get_file=AsyncMock(return_value=telegram_file)))
|
ch._download_bot = SimpleNamespace(get_file=AsyncMock(return_value=telegram_file))
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9732,7 +9732,7 @@ class TestTelegramInboundMessages:
|
|||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
ch = telegram.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=AsyncMock(return_value=bytearray(b"four")))
|
telegram_file = SimpleNamespace(file_size=4, download_as_bytearray=AsyncMock(return_value=bytearray(b"four")))
|
||||||
ch._application = SimpleNamespace(bot=SimpleNamespace(get_file=AsyncMock(return_value=telegram_file)))
|
ch._download_bot = SimpleNamespace(get_file=AsyncMock(return_value=telegram_file))
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9754,7 +9754,7 @@ class TestTelegramInboundMessages:
|
|||||||
async def go():
|
async def go():
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
ch._application = SimpleNamespace(bot=SimpleNamespace(get_file=AsyncMock(side_effect=RuntimeError("GET https://api.telegram.org/bottest-token/getFile failed"))))
|
ch._download_bot = SimpleNamespace(get_file=AsyncMock(side_effect=RuntimeError("GET https://api.telegram.org/bottest-token/getFile failed")))
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel_name="telegram",
|
channel_name="telegram",
|
||||||
chat_id="100",
|
chat_id="100",
|
||||||
@ -9948,6 +9948,354 @@ class TestTelegramInboundMessages:
|
|||||||
|
|
||||||
_run(go())
|
_run(go())
|
||||||
|
|
||||||
|
def test_get_download_bot_returns_none_without_telegram_loop(self):
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
assert ch._tg_loop is None
|
||||||
|
|
||||||
|
result = await ch._get_download_bot()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert ch._download_bot is None
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_get_download_bot_creates_loop_bound_bot_and_caches_it(self):
|
||||||
|
from app.channels import telegram as telegram_module
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = telegram_module.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
telegram_loop = asyncio.new_event_loop()
|
||||||
|
loop_started: Future[None] = Future()
|
||||||
|
|
||||||
|
def run_telegram_loop():
|
||||||
|
asyncio.set_event_loop(telegram_loop)
|
||||||
|
telegram_loop.call_soon(loop_started.set_result, None)
|
||||||
|
telegram_loop.run_forever()
|
||||||
|
|
||||||
|
loop_thread = threading.Thread(target=run_telegram_loop, daemon=True)
|
||||||
|
loop_thread.start()
|
||||||
|
try:
|
||||||
|
loop_started.result(timeout=2)
|
||||||
|
ch._tg_loop = telegram_loop
|
||||||
|
|
||||||
|
created_tokens: list[str] = []
|
||||||
|
init_loops: list[asyncio.AbstractEventLoop] = []
|
||||||
|
|
||||||
|
class FakeBot:
|
||||||
|
def __init__(self, token: str) -> None:
|
||||||
|
created_tokens.append(token)
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
init_loops.append(asyncio.get_running_loop())
|
||||||
|
|
||||||
|
with patch("telegram.Bot", FakeBot):
|
||||||
|
first = await ch._get_download_bot()
|
||||||
|
second = await ch._get_download_bot()
|
||||||
|
|
||||||
|
# One Bot, built with the configured token and initialized on the
|
||||||
|
# Telegram loop; the second call reuses the cached instance.
|
||||||
|
assert first is second
|
||||||
|
assert first is ch._download_bot
|
||||||
|
assert created_tokens == ["test-token"]
|
||||||
|
assert init_loops == [telegram_loop]
|
||||||
|
finally:
|
||||||
|
if telegram_loop.is_running():
|
||||||
|
telegram_loop.call_soon_threadsafe(telegram_loop.stop)
|
||||||
|
await asyncio.to_thread(loop_thread.join, 2)
|
||||||
|
if loop_thread.is_alive():
|
||||||
|
pytest.fail("Telegram test event loop did not stop")
|
||||||
|
telegram_loop.close()
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_receive_file_uses_download_bot_not_application_bot(self):
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
# The Application's Bot is main-loop-bound; the download must never
|
||||||
|
# route through it.
|
||||||
|
app_bot = SimpleNamespace(get_file=AsyncMock())
|
||||||
|
download_bot = SimpleNamespace(
|
||||||
|
get_file=AsyncMock(
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
file_size=2,
|
||||||
|
download_as_bytearray=AsyncMock(return_value=bytearray(b"data")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ch._application = SimpleNamespace(bot=app_bot)
|
||||||
|
ch._download_bot = download_bot
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel_name="telegram",
|
||||||
|
chat_id="100",
|
||||||
|
user_id="42",
|
||||||
|
text="caption",
|
||||||
|
files=[{"type": "file", "file_id": "document-id", "filename": "report.pdf", "size": 2}],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await ch.receive_file(msg, "thread-1")
|
||||||
|
|
||||||
|
download_bot.get_file.assert_awaited_once_with("document-id")
|
||||||
|
app_bot.get_file.assert_not_awaited()
|
||||||
|
assert result.files[0]["_content"] == b"data"
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_stop_shuts_down_download_bot_on_telegram_loop(self):
|
||||||
|
"""Regression: stop() must close the download Bot's HTTPX clients.
|
||||||
|
|
||||||
|
python-telegram-bot 22.7 has no ``Bot.session``; the old code called
|
||||||
|
``bot.session.close()`` (always an AttributeError, suppressed), leaving
|
||||||
|
both request clients open. shutdown() now runs on the Telegram loop.
|
||||||
|
Uses the real Bot so the client-close assertion is meaningful.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
from telegram import Bot
|
||||||
|
from telegram.request import HTTPXRequest
|
||||||
|
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
def ok_get_me(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json={"ok": True, "result": {"id": 1, "is_bot": True, "first_name": "b", "username": "b"}})
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
telegram_loop = asyncio.new_event_loop()
|
||||||
|
loop_started: Future[None] = Future()
|
||||||
|
|
||||||
|
def run_telegram_loop():
|
||||||
|
asyncio.set_event_loop(telegram_loop)
|
||||||
|
telegram_loop.call_soon(loop_started.set_result, None)
|
||||||
|
telegram_loop.run_forever()
|
||||||
|
|
||||||
|
loop_thread = threading.Thread(target=run_telegram_loop, daemon=True)
|
||||||
|
loop_thread.start()
|
||||||
|
try:
|
||||||
|
loop_started.result(timeout=2)
|
||||||
|
ch._tg_loop = telegram_loop
|
||||||
|
ch._thread = loop_thread
|
||||||
|
ch._running = True
|
||||||
|
|
||||||
|
# A real, initialized Bot (offline via MockTransport) whose
|
||||||
|
# HTTPX clients are bound to the Telegram loop; both open.
|
||||||
|
real_bot = Bot(token="test-token", request=HTTPXRequest(httpx_kwargs={"transport": httpx.MockTransport(ok_get_me)}))
|
||||||
|
await ch._run_on_telegram_loop(real_bot.initialize())
|
||||||
|
ch._download_bot = real_bot
|
||||||
|
assert real_bot._request[0]._client.is_closed is False
|
||||||
|
assert real_bot._request[1]._client.is_closed is False
|
||||||
|
|
||||||
|
await ch.stop()
|
||||||
|
|
||||||
|
# Shut down on the Telegram loop (before it stops): both HTTPX
|
||||||
|
# clients are closed and the reference is cleared.
|
||||||
|
assert ch._download_bot is None
|
||||||
|
assert real_bot._request[0]._client.is_closed is True
|
||||||
|
assert real_bot._request[1]._client.is_closed is True
|
||||||
|
finally:
|
||||||
|
if telegram_loop.is_running():
|
||||||
|
telegram_loop.call_soon_threadsafe(telegram_loop.stop)
|
||||||
|
await asyncio.to_thread(loop_thread.join, 2)
|
||||||
|
if loop_thread.is_alive():
|
||||||
|
pytest.fail("Telegram test event loop did not stop")
|
||||||
|
telegram_loop.close()
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_get_download_bot_cleans_up_on_init_failure(self):
|
||||||
|
"""A bot whose getMe fails is never cached and its clients are closed."""
|
||||||
|
import httpx
|
||||||
|
from telegram import Bot
|
||||||
|
from telegram.error import TimedOut
|
||||||
|
from telegram.request import HTTPXRequest
|
||||||
|
|
||||||
|
from app.channels import telegram as telegram_module
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = telegram_module.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
telegram_loop = asyncio.new_event_loop()
|
||||||
|
loop_started: Future[None] = Future()
|
||||||
|
|
||||||
|
def run_telegram_loop():
|
||||||
|
asyncio.set_event_loop(telegram_loop)
|
||||||
|
telegram_loop.call_soon(loop_started.set_result, None)
|
||||||
|
telegram_loop.run_forever()
|
||||||
|
|
||||||
|
loop_thread = threading.Thread(target=run_telegram_loop, daemon=True)
|
||||||
|
loop_thread.start()
|
||||||
|
try:
|
||||||
|
loop_started.result(timeout=2)
|
||||||
|
ch._tg_loop = telegram_loop
|
||||||
|
|
||||||
|
def timeout_handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ReadTimeout("read timed out")
|
||||||
|
|
||||||
|
real_bot = Bot(token="test-token", request=HTTPXRequest(httpx_kwargs={"transport": httpx.MockTransport(timeout_handler)}))
|
||||||
|
|
||||||
|
with patch("telegram.Bot", lambda token: real_bot):
|
||||||
|
with pytest.raises(TimedOut):
|
||||||
|
await ch._get_download_bot()
|
||||||
|
|
||||||
|
# A bot that failed to initialize is never cached, and its
|
||||||
|
# partially-open HTTPX clients are closed.
|
||||||
|
assert ch._download_bot is None
|
||||||
|
assert real_bot._request[0]._client.is_closed is True
|
||||||
|
assert real_bot._request[1]._client.is_closed is True
|
||||||
|
finally:
|
||||||
|
if telegram_loop.is_running():
|
||||||
|
telegram_loop.call_soon_threadsafe(telegram_loop.stop)
|
||||||
|
await asyncio.to_thread(loop_thread.join, 2)
|
||||||
|
if loop_thread.is_alive():
|
||||||
|
pytest.fail("Telegram test event loop did not stop")
|
||||||
|
telegram_loop.close()
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_receive_file_init_timeout_does_not_abort_message(self):
|
||||||
|
"""A first-time getMe timeout is contained to one attachment.
|
||||||
|
|
||||||
|
The old code awaited _get_download_bot() outside the per-attachment
|
||||||
|
handler, so a TimedOut escaped receive_file() and the manager aborted
|
||||||
|
the whole message. Now the caption is preserved and only the
|
||||||
|
attachment is reported unavailable.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
from telegram import Bot
|
||||||
|
from telegram.request import HTTPXRequest
|
||||||
|
|
||||||
|
from app.channels import telegram as telegram_module
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = telegram_module.TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
telegram_loop = asyncio.new_event_loop()
|
||||||
|
loop_started: Future[None] = Future()
|
||||||
|
|
||||||
|
def run_telegram_loop():
|
||||||
|
asyncio.set_event_loop(telegram_loop)
|
||||||
|
telegram_loop.call_soon(loop_started.set_result, None)
|
||||||
|
telegram_loop.run_forever()
|
||||||
|
|
||||||
|
loop_thread = threading.Thread(target=run_telegram_loop, daemon=True)
|
||||||
|
loop_thread.start()
|
||||||
|
try:
|
||||||
|
loop_started.result(timeout=2)
|
||||||
|
ch._tg_loop = telegram_loop
|
||||||
|
|
||||||
|
def timeout_handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
raise httpx.ReadTimeout("read timed out")
|
||||||
|
|
||||||
|
real_bot = Bot(token="test-token", request=HTTPXRequest(httpx_kwargs={"transport": httpx.MockTransport(timeout_handler)}))
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel_name="telegram",
|
||||||
|
chat_id="100",
|
||||||
|
user_id="42",
|
||||||
|
text="caption",
|
||||||
|
files=[{"type": "file", "file_id": "document-id", "filename": "report.pdf", "size": 10}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("telegram.Bot", lambda token: real_bot):
|
||||||
|
result = await ch.receive_file(msg, "thread-1")
|
||||||
|
|
||||||
|
# No exception escapes; the caption survives and the attachment
|
||||||
|
# is reported unavailable.
|
||||||
|
assert result.files == []
|
||||||
|
assert result.text.startswith("caption")
|
||||||
|
assert "report.pdf" in result.text
|
||||||
|
assert "download failed" in result.text
|
||||||
|
# And the partially-initialized bot was cleaned up, not cached.
|
||||||
|
assert ch._download_bot is None
|
||||||
|
assert real_bot._request[1]._client.is_closed is True
|
||||||
|
finally:
|
||||||
|
if telegram_loop.is_running():
|
||||||
|
telegram_loop.call_soon_threadsafe(telegram_loop.stop)
|
||||||
|
await asyncio.to_thread(loop_thread.join, 2)
|
||||||
|
if loop_thread.is_alive():
|
||||||
|
pytest.fail("Telegram test event loop did not stop")
|
||||||
|
telegram_loop.close()
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_receive_file_download_failure_logs_cause_chain_without_token(self, caplog):
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
bus = MessageBus()
|
||||||
|
ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"})
|
||||||
|
|
||||||
|
def boom(file_id: str) -> None:
|
||||||
|
raise RuntimeError("download aborted") from ConnectionResetError("GET https://api.telegram.org/file/bottest-token/ABC123/photo.jpg")
|
||||||
|
|
||||||
|
ch._download_bot = SimpleNamespace(get_file=AsyncMock(side_effect=boom))
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel_name="telegram",
|
||||||
|
chat_id="100",
|
||||||
|
user_id="42",
|
||||||
|
text="caption",
|
||||||
|
files=[{"type": "file", "file_id": "document-id", "filename": "report.pdf", "size": 10}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
result = await ch.receive_file(msg, "thread-1")
|
||||||
|
|
||||||
|
assert result.files == []
|
||||||
|
assert "report.pdf" in result.text
|
||||||
|
# The cause chain is surfaced for diagnosis...
|
||||||
|
assert "caused_by=" in caplog.text
|
||||||
|
assert "ConnectionResetError" in caplog.text
|
||||||
|
# ...with the token-bearing Bot API file URL masked (host kept for
|
||||||
|
# diagnosis; the exact redaction marker varies by which pass handled
|
||||||
|
# it — our own vs the global UrlRedactionFilter).
|
||||||
|
assert "api.telegram.org" in caplog.text
|
||||||
|
assert "ABC123" not in caplog.text
|
||||||
|
assert "test-token" not in caplog.text
|
||||||
|
|
||||||
|
_run(go())
|
||||||
|
|
||||||
|
def test_describe_download_cause_redacts_bot_api_urls(self):
|
||||||
|
"""Both Bot API URL forms carry the token; neither may publish it.
|
||||||
|
|
||||||
|
PTB builds the file download URL as ``/file/bot<token>/…`` and the
|
||||||
|
method URLs (getMe, getFile) as ``/bot<token>/<method>`` — the token is
|
||||||
|
in the path in every form. The helper redacts the configured token and
|
||||||
|
collapses any remaining Bot API URL so a chained HTTP exception cannot
|
||||||
|
leak it, while keeping the method name for diagnosis.
|
||||||
|
"""
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
ch = TelegramChannel(bus=MessageBus(), config={"bot_token": "test-token"})
|
||||||
|
|
||||||
|
def with_cause(cause: BaseException) -> BaseException:
|
||||||
|
exc = RuntimeError("x")
|
||||||
|
exc.__cause__ = cause
|
||||||
|
return exc
|
||||||
|
|
||||||
|
file_exc = with_cause(ConnectionResetError("GET https://api.telegram.org/file/bottest-token/ABC123/photo.jpg"))
|
||||||
|
getme_exc = with_cause(ConnectionResetError("GET https://api.telegram.org/bottest-token/getMe"))
|
||||||
|
getfile_exc = with_cause(ConnectionResetError("GET https://api.telegram.org/bottest-token/getFile"))
|
||||||
|
|
||||||
|
for exc in (file_exc, getme_exc, getfile_exc):
|
||||||
|
cause = ch._describe_download_cause(exc)
|
||||||
|
assert "test-token" not in cause
|
||||||
|
assert "bottest-token" not in cause
|
||||||
|
|
||||||
|
# The file URL is fully collapsed; the method URLs keep their method
|
||||||
|
# name for diagnosis with the token redacted.
|
||||||
|
assert "api.telegram.org/file/[redacted]" in ch._describe_download_cause(file_exc)
|
||||||
|
assert "bot[redacted]/getMe" in ch._describe_download_cause(getme_exc)
|
||||||
|
assert "bot[redacted]/getFile" in ch._describe_download_cause(getfile_exc)
|
||||||
|
|
||||||
|
# A cause with no URL passes through untouched.
|
||||||
|
assert ch._describe_download_cause(with_cause(TimeoutError("read timed out"))) == " caused_by=TimeoutError:read timed out"
|
||||||
|
|
||||||
|
|
||||||
class TestTelegramProcessingOrder:
|
class TestTelegramProcessingOrder:
|
||||||
"""Ensure 'working on it...' is sent before inbound is published."""
|
"""Ensure 'working on it...' is sent before inbound is published."""
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import pytest
|
|||||||
from deerflow.uploads.manager import (
|
from deerflow.uploads.manager import (
|
||||||
PathTraversalError,
|
PathTraversalError,
|
||||||
UnsafeUploadPathError,
|
UnsafeUploadPathError,
|
||||||
|
apply_upload_sandbox_permits,
|
||||||
claim_unique_filename,
|
claim_unique_filename,
|
||||||
cleanup_stale_upload_staging_files,
|
cleanup_stale_upload_staging_files,
|
||||||
copy_upload_file_no_symlink,
|
copy_upload_file_no_symlink,
|
||||||
@ -21,6 +22,31 @@ from deerflow.uploads.manager import (
|
|||||||
write_upload_file_no_symlink,
|
write_upload_file_no_symlink,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not (hasattr(os, "O_NOFOLLOW") and hasattr(os, "fchmod")), reason="POSIX-only: O_NOFOLLOW + fchmod")
|
||||||
|
def test_apply_upload_sandbox_permits_propagates_permission_errors(tmp_path):
|
||||||
|
upload = tmp_path / "attachment.bin"
|
||||||
|
upload.write_bytes(b"attachment")
|
||||||
|
upload.chmod(0o600)
|
||||||
|
|
||||||
|
with patch.object(os, "fchmod", side_effect=PermissionError("permission denied")):
|
||||||
|
with pytest.raises(PermissionError, match="permission denied"):
|
||||||
|
apply_upload_sandbox_permits(upload, stat.S_IRGRP | stat.S_IROTH)
|
||||||
|
|
||||||
|
assert stat.S_IMODE(upload.stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_upload_sandbox_permits_fallback_propagates_permission_errors(tmp_path, monkeypatch):
|
||||||
|
upload = tmp_path / "attachment.bin"
|
||||||
|
upload.write_bytes(b"attachment")
|
||||||
|
upload.chmod(0o600)
|
||||||
|
monkeypatch.delattr(os, "O_NOFOLLOW", raising=False)
|
||||||
|
|
||||||
|
with patch.object(os, "chmod", side_effect=PermissionError("permission denied")):
|
||||||
|
with pytest.raises(PermissionError, match="permission denied"):
|
||||||
|
apply_upload_sandbox_permits(upload, stat.S_IRGRP | stat.S_IROTH)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# normalize_filename
|
# normalize_filename
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user