From f14ab65574869d3c72c2f4366b6e6ba717d883a2 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Mon, 6 Jul 2026 16:37:28 +0200 Subject: [PATCH] fix: address stable-review findings across config, update, downloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-readiness review of main...3.3-dev (multi-agent + codex verify pass) surfaced 10 confirmed defects; all fixed with red-first tests (+24): - config: masked list secrets restored by identity match, not index — deleting/reordering a notification provider no longer corrupts the survivor's credentials; unresolvable masks return 400 instead of guessing - statics: mount dist/fonts — self-hosted Inter 404'd in every production build and the whole UI fell back to OS fonts - boot_overlay: chown replaced trees to the app user (crash-loop under UMASK=077); snapshot-and-restore around the EXDEV in-place fallback so a mid-copy failure can't gut /app/module - updater: rollback refuses to swap without the retained signed backup bundle — reverts to image version honestly instead of half-swapping - qbittorrent: credential-failure latch (cleared on settings save) plus single-flighted auth sharing the negative result with queued waiters — failed logins no longer accumulate into a WebUI IP ban - download_client: holder refcount registered before auth (no mid-login close), stale clients accumulated in a list (no pool leak on rapid settings saves), holder released on cancellation during __aenter__ - entrypoint: ownership marker written only after chown -R succeeds - aria2: torrents_info provides num_seeds/num_leechs/eta/added_on and maps states to qB vocabulary (UI showed undefined / NaNhNaNm / raw states) - program API: deprecated GET aliases for /restart /start /stop /shutdown (3.2 automation compatibility) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs --- backend/src/boot_overlay.py | 68 ++++- backend/src/main.py | 5 + backend/src/module/api/config.py | 95 ++++++- backend/src/module/api/program.py | 19 ++ backend/src/module/core/context.py | 4 + .../downloader/client/aria2_downloader.py | 42 ++- .../module/downloader/client/qb_downloader.py | 33 ++- .../src/module/downloader/download_client.py | 136 ++++++---- backend/src/module/update/updater.py | 21 +- backend/src/test/test_api_config.py | 106 ++++++++ backend/src/test/test_api_program.py | 24 ++ backend/src/test/test_aria2_downloader.py | 71 ++++- .../src/test/test_download_session_reuse.py | 247 ++++++++++++++++++ backend/src/test/test_update.py | 184 +++++++++++++ entrypoint.sh | 8 +- 15 files changed, 992 insertions(+), 71 deletions(-) diff --git a/backend/src/boot_overlay.py b/backend/src/boot_overlay.py index cf0b78c7..0d671f17 100644 --- a/backend/src/boot_overlay.py +++ b/backend/src/boot_overlay.py @@ -70,12 +70,8 @@ def _read_applied(updates_root: Path) -> dict | None: return None -def _replace_contents(dst: Path, src: Path) -> None: - """让 dst 的子项与 src 一致,但不 rename dst 本身。 - - 这是 EXDEV 的兜底路径:overlayfs 拒绝把镜像下层目录跨挂载边界 rename, - 但逐文件删/写会触发 copy-up,因而可行(非原子,但拓扑无关)。 - """ +def _clear_and_fill(dst: Path, src: Path) -> None: + """删光 dst 的子项后从 src 复制(EXDEV 兜底的非原子核心步骤)。""" for child in list(dst.iterdir()): if child.is_dir() and not child.is_symlink(): shutil.rmtree(child) @@ -89,6 +85,63 @@ def _replace_contents(dst: Path, src: Path) -> None: shutil.copy2(child, target) +def _replace_contents(dst: Path, src: Path) -> None: + """让 dst 的子项与 src 一致,但不 rename dst 本身。 + + 这是 EXDEV 的兜底路径:overlayfs 拒绝把镜像下层目录跨挂载边界 rename, + 但逐文件删/写会触发 copy-up,因而可行。非原子——中途失败(磁盘满/IO + 错误)会留下"删光了但没灌满"的残树,启动进去必然 ImportError 崩溃循环。 + 因此先把 dst 现有内容快照到 .ab_bak:快照失败则 dst 未被触碰、直接放弃; + 填充失败则从快照恢复旧树后再抛出,保证 dst 要么是新树要么是旧树。 + """ + backup = dst.parent / (dst.name + ".ab_bak") + if backup.exists(): + shutil.rmtree(backup) + shutil.copytree(dst, backup) + try: + _clear_and_fill(dst, src) + except BaseException: + try: + _clear_and_fill(dst, backup) + # 恢复也是 root 在复制:属主变回 root,受限 UMASK 下的 600 + # 模式会让 ab 读不了恢复出来的旧树——同样要交还应用用户 + # (成功路径的 chown 在上层,异常路径走不到那里)。 + _chown_app_tree(dst) + except Exception: + logger.critical( + "Failed to restore %s from its snapshot after a partial " + "overlay write; the tree may be incomplete.", + dst, + ) + raise + finally: + shutil.rmtree(backup, ignore_errors=True) + + +def _chown_app_tree(path: Path) -> None: + """把覆盖层落地的树交还应用用户(ab)。 + + boot_overlay 以 root 运行;extractall/copytree 产物的权限跟随 UMASK + (entrypoint 在调用前已 ``umask ${UMASK}``),受限 UMASK(如 077)下是 + 600/700 root:root——以 ab 运行的 main.py 读不了 /app/module,启动即 + ImportError 崩溃循环。落地后统一 chown 给 ab,使可读性与 UMASK 无关 + (3.2 的每次启动全量 ``chown -R /app`` 已移除,必须在这里补上)。 + 只处理刚写入的树,不触碰镜像层文件,避免无谓的 overlayfs copy-up。 + """ + if not hasattr(os, "geteuid") or os.geteuid() != 0: + return + try: + import pwd + + pwd.getpwnam("ab") + except (ImportError, KeyError): + logger.warning("User 'ab' not found; skipping ownership fix for %s", path) + return + import subprocess + + subprocess.run(["chown", "-R", "ab:ab", str(path)], check=False) + + def _replace_tree(src: Path, dst: Path) -> None: """用 src 原子地替换 dst(先复制到 .new,再 rename 交换,最后清理 .old)。 @@ -352,11 +405,13 @@ def apply_overlay( except Exception as exc: logger.error("Failed to overlay module tree: %s", exc) return False + _chown_app_tree(app_root / "module") src_dist = tmp / "webui-dist" if src_dist.exists(): try: _replace_tree(src_dist, app_root / "dist") + _chown_app_tree(app_root / "dist") except Exception as exc: logger.error("Failed to overlay webui dist: %s", exc) @@ -370,6 +425,7 @@ def apply_overlay( (app_root / ".venv" / _VENV_LOCK_MARKER).write_text( lock_sha, encoding="utf-8" ) + _chown_app_tree(app_root / ".venv") except Exception as exc: logger.error("Failed to overlay staged venv: %s", exc) else: diff --git a/backend/src/main.py b/backend/src/main.py index 48815c78..37b43744 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -93,6 +93,11 @@ def posters(path: str): if VERSION != "DEV_VERSION": app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets") app.mount("/images", StaticFiles(directory="dist/images"), name="images") + # 自托管 Inter 字体:index.html 以 /fonts/*.woff2 预加载,不挂载则被下面的 + # SPA 兜底路由回成 index.html,全站字体静默回退。在线更新可能覆盖进来 + # 更旧的、没有 fonts/ 的 dist,故按存在性挂载。 + if os.path.isdir("dist/fonts"): + app.mount("/fonts", StaticFiles(directory="dist/fonts"), name="fonts") # app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons") templates = Jinja2Templates(directory="dist") diff --git a/backend/src/module/api/config.py b/backend/src/module/api/config.py index e8c46517..cc000554 100644 --- a/backend/src/module/api/config.py +++ b/backend/src/module/api/config.py @@ -41,20 +41,90 @@ def _sanitize_dict(d: dict) -> dict: return result +class MaskRestoreError(ValueError): + """无法为掩码密钥可靠定位旧值来源(列表项被删/重排且身份字段同时被改)。""" + + +def _identity(d: dict) -> tuple: + """列表项的身份 = 全部非敏感标量字段(敏感侧是掩码,无法参与匹配)。""" + return tuple( + sorted( + (k, v) + for k, v in d.items() + if not isinstance(v, (dict, list)) and not _is_sensitive(k) + ) + ) + + +def _contains_mask(value) -> bool: + if isinstance(value, dict): + return any(_contains_mask(v) for v in value.values()) + if isinstance(value, list): + return any(_contains_mask(v) for v in value) + return value == _MASK + + +def _restore_masked_list(incoming: list, current: list) -> None: + """按身份匹配列表项后再恢复各自的掩码密钥。 + + 掩码恢复不能盲目按下标:删掉第 0 个通知渠道后,幸存渠道的 ``********`` + 会从被删渠道取值——密钥被静默写坏且原值永久丢失。只有含掩码的项才 + 需要配对(新增项带明文密钥,不参与、也不许抢占候选)。长度未变时视为 + 原地编辑:同下标同身份 → 唯一身份(识别重排)→ 按下标兜底;长度变了 + (增/删)只接受唯一身份匹配——包括"两项身份完全相同、删了其中一个" + 的场景在内,凡无法唯一定位来源的掩码项直接报错,绝不猜。 + """ + masked = [ + i + for i, item in enumerate(incoming) + if isinstance(item, dict) and _contains_mask(item) + ] + if not masked: + return + unconsumed = {j for j, item in enumerate(current) if isinstance(item, dict)} + matched: dict[int, int] = {} + + def match_unique_identity(i: int) -> None: + candidates = [ + j for j in unconsumed if _identity(current[j]) == _identity(incoming[i]) + ] + if len(candidates) == 1: + matched[i] = candidates[0] + unconsumed.discard(candidates[0]) + + if len(incoming) == len(current): + for i in masked: + if i in unconsumed and _identity(incoming[i]) == _identity(current[i]): + matched[i] = i + unconsumed.discard(i) + for i in masked: + if i not in matched: + match_unique_identity(i) + for i in masked: + if i not in matched and i in unconsumed: + matched[i] = i + unconsumed.discard(i) + else: + for i in masked: + match_unique_identity(i) + + for i in masked: + if i in matched: + _restore_masked(incoming[i], current[matched[i]]) + else: + raise MaskRestoreError( + f"cannot determine which stored entry list item #{i} refers to; " + "re-enter its secret value and save again" + ) + + def _restore_masked(incoming: dict, current: dict) -> dict: """Replace masked sentinel values with real values from current config.""" for k, v in incoming.items(): if isinstance(v, dict) and isinstance(current.get(k), dict): _restore_masked(v, current[k]) elif isinstance(v, list) and isinstance(current.get(k), list): - cur_list = current[k] - for i, item in enumerate(v): - if ( - isinstance(item, dict) - and i < len(cur_list) - and isinstance(cur_list[i], dict) - ): - _restore_masked(item, cur_list[i]) + _restore_masked_list(v, current[k]) elif v == _MASK and _is_sensitive(k): incoming[k] = current.get(k, v) return incoming @@ -86,6 +156,15 @@ async def update_config(config: Config, ctx: AppContext = Depends(get_context)): "msg_zh": "更新配置成功。", }, ) + except MaskRestoreError as e: + logger.warning(e) + return JSONResponse( + status_code=400, + content={ + "msg_en": str(e), + "msg_zh": "无法恢复被掩码的密钥来源,请重新输入该密钥后再保存。", + }, + ) except Exception as e: logger.warning(e) return JSONResponse( diff --git a/backend/src/module/api/program.py b/backend/src/module/api/program.py index 82a091c3..9fadb825 100644 --- a/backend/src/module/api/program.py +++ b/backend/src/module/api/program.py @@ -108,3 +108,22 @@ async def shutdown_program(ctx: AppContext = Depends(get_context)): ) async def check_downloader_status(ctx: AppContext = Depends(get_context)): return await ctx.check_downloader() + + +# 3.2 兼容:这些控制端点在 3.2 及更早版本是 GET,外部自动化(cron/Home +# Assistant 等)沿用旧方法,升级后不得 405 静默失效。GET 别名标记为 +# deprecated,计划下个大版本移除。 +for _path, _endpoint in ( + ("/restart", restart), + ("/start", start), + ("/stop", stop), + ("/shutdown", shutdown_program), +): + router.add_api_route( + _path, + _endpoint, + methods=["GET"], + response_model=APIResponse, + dependencies=[Depends(get_current_user)], + deprecated=True, + ) diff --git a/backend/src/module/core/context.py b/backend/src/module/core/context.py index 221ff7aa..26d37ff7 100644 --- a/backend/src/module/core/context.py +++ b/backend/src/module/core/context.py @@ -12,6 +12,7 @@ import time from module.checker import Checker from module.conf import LEGACY_DATA_PATH, VERSION, settings from module.database import Database +from module.downloader.download_client import clear_credential_latch from module.downloader.download_client import shutdown as downloader_shutdown from module.models import ResponseModel from module.network.request_url import reset_shared_client @@ -420,6 +421,9 @@ class AppContext: reset_mikan_cache() reset_poster_cache() reset_llm_parser() + # 用户保存了设置即视为已处理凭据问题:解除下载器的凭据失败闩锁, + # 允许重试(哪怕保存的值没变——qB 侧密码可能被改回来了)。 + clear_credential_latch() self.notifier.rebuild() if self.scheduler.running: await self.scheduler.stop_all() diff --git a/backend/src/module/downloader/client/aria2_downloader.py b/backend/src/module/downloader/client/aria2_downloader.py index 53337ca6..7d57d56d 100644 --- a/backend/src/module/downloader/client/aria2_downloader.py +++ b/backend/src/module/downloader/client/aria2_downloader.py @@ -26,6 +26,22 @@ _STATUS_FILTER_MAP: dict[str, set[str]] = { } +def _map_qb_state(aria2_status: str, finished: bool) -> str: + """aria2 status -> qB 状态词汇。WebUI 的状态徽标/文案只认 qB 的取值, + 直接透传 aria2 原词会显示未翻译的原始字符串。""" + if aria2_status == "active": + return "uploading" if finished else "downloading" + if aria2_status == "waiting": + return "queuedUP" if finished else "queuedDL" + if aria2_status == "paused": + return "pausedUP" if finished else "pausedDL" + if aria2_status == "complete": + return "pausedUP" + if aria2_status in ("error", "removed"): + return "error" + return aria2_status + + class Aria2RpcError(Exception): """aria2 JSON-RPC 返回的业务错误(服务器收到了请求,但拒绝执行)。""" @@ -453,6 +469,17 @@ class Aria2Downloader: # 转 int 再判断,否则磁力链拉元数据阶段 totalLength "0" 会除零。 total = int(d.get("totalLength", 0) or 0) completed = int(d.get("completedLength", 0) or 0) + dlspeed = int(d.get("downloadSpeed", 0) or 0) + num_seeds = int(d.get("numSeeders", 0) or 0) + connections = int(d.get("connections", 0) or 0) + finished = total > 0 and completed >= total + # ETA 沿 qB 约定:8640000 = 未知/无穷(UI 渲染为 "-")。 + if finished: + eta = 0 + elif dlspeed > 0 and total > 0: + eta = (total - completed) // dlspeed + else: + eta = 8640000 result.append( { "hash": gid, @@ -460,11 +487,22 @@ class Aria2Downloader: "save_path": d.get("dir", ""), "tags": tags_str, "category": torrent_category or "", - "state": aria2_status, + "state": _map_qb_state(aria2_status, finished), "size": total, "progress": completed / total if total > 0 else 0.0, - "dlspeed": int(d.get("downloadSpeed", 0) or 0), + "dlspeed": dlspeed, "upspeed": int(d.get("uploadSpeed", 0) or 0), + "num_seeds": num_seeds, + # qB 的 num_leechs ≈ 非做种连接数;aria2 只报连接总数。 + "num_leechs": max(connections - num_seeds, 0), + "eta": eta, + # aria2 不记录添加时间;用本地 gid 映射的入库时间近似 + # (UI 按 added_on 排序),无记录时归 0 排到最后。 + "added_on": ( + int(info.created_at.timestamp()) + if info and info.created_at + else 0 + ), } ) return result diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index 3db65aa0..32d1314a 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -40,6 +40,9 @@ class QbDownloader: # requests don't each fire their own login attempt and trip # qBittorrent's IP ban (#1046-adjacent). self._auth_lock = asyncio.Lock() + # 每完成一次真实登录尝试 +1:让排在锁后的并发等待者识别"我等待期间 + # 已有一次登录被凭据拒绝",从而不再补发自己的 POST。 + self._auth_generation = 0 def _url(self, endpoint: str) -> str: return f"{self.host}/api/v2/{endpoint}" @@ -65,6 +68,34 @@ class QbDownloader: # repeated operations don't re-login every cycle (#1039 / #900). if self._client is not None and self._authed: return True + # 单飞:并发的 loop tick 同时 auth() 时只发一次 login POST——失败的 + # 并发登录会各自计入 qB 的 WebUI IP ban 阈值(默认 5 次即封禁)。 + generation = self._auth_generation + async with self._auth_lock: + if self._client is not None and self._authed: + return True + if ( + generation != self._auth_generation + and self.last_auth_error == "credentials" + ): + # 等锁期间已有一次真实登录被凭据拒绝:共享这个否定结果, + # 不再补发自己的 POST。之后的全新 auth() 调用(如 checker + # 主动探测)不受影响,仍可重试并在成功后清除失败原因。 + return False + return await self._locked_auth(retry) + + async def _locked_auth(self, retry=3): + """auth() 的主体;调用方必须已持有 ``_auth_lock``。""" + if self._client is not None and self._authed: + return True + try: + return await self._login_attempt(retry) + finally: + # 尝试结束后才 +1:在本次尝试期间排队的等待者捕获的是旧值, + # 醒来后据此识别"结果已出",共享失败结论而非重复 POST。 + self._auth_generation += 1 + + async def _login_attempt(self, retry=3): times = 0 use_https = self.host.startswith("https://") if self._client is None: @@ -154,7 +185,7 @@ class QbDownloader: # Another waiter may have already refreshed the session while # we were blocked on the lock. if not self._authed: - await self.auth() + await self._locked_auth() if not self._authed: raise ConnectionError( f"Re-authentication to qBittorrent at {self.host} " diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index 5c5d9c92..a99bc412 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -30,10 +30,15 @@ logger = logging.getLogger(__name__) # awaits in ``__aenter__``/``__aexit__``. # --------------------------------------------------------------------------- _client_cache: tuple[tuple, DownloaderClient] | None = None -_stale_client: DownloaderClient | None = None +_stale_clients: list[DownloaderClient] = [] _active_holders: dict[int, int] = {} _pending_close: set[int] = set() _bookkeeping_lock = asyncio.Lock() +# 凭据被服务端明确拒绝后的闩锁:记录失败时的连接设置 key。命中时 enter 直接 +# 失败、不再发 login POST——每个 tick 重试一次登录,约 5 次即触发 qB 的 +# WebUI IP ban。设置变更(key 不同)自然解锁;同值重存经 +# clear_credential_latch()(AppContext.reload_settings)解锁。 +_credential_failed_key: tuple | None = None # Warn at most once per (client type, operation) when a backend cannot perform # an operation, so aria2 users are not spammed every rename cycle. @@ -47,11 +52,18 @@ def _settings_key() -> tuple: def _reset_client_cache() -> None: """Drop the cached/stale concrete clients and refcount state (used by tests).""" - global _client_cache, _stale_client, _active_holders, _pending_close + global _client_cache, _stale_clients, _active_holders, _pending_close _client_cache = None - _stale_client = None + _stale_clients = [] _active_holders = {} _pending_close = set() + clear_credential_latch() + + +def clear_credential_latch() -> None: + """解除凭据失败闩锁(配置保存后调用,允许用户重试同值凭据)。""" + global _credential_failed_key + _credential_failed_key = None async def _close_client(client) -> None: @@ -66,14 +78,12 @@ async def shutdown() -> None: Invoked by the composition root (`AppContext`) on application shutdown. """ - global _client_cache, _stale_client - clients = [] - if _stale_client is not None: - clients.append(_stale_client) + global _client_cache, _stale_clients + clients = list(_stale_clients) if _client_cache is not None: clients.append(_client_cache[1]) _client_cache = None - _stale_client = None + _stale_clients = [] for client in clients: await _close_client(client) @@ -89,15 +99,18 @@ class DownloadClient: """ def __init__(self): - global _client_cache, _stale_client + global _client_cache key = _settings_key() self.client: DownloaderClient + self._cache_key = key if _client_cache is not None and _client_cache[0] == key: self.client = _client_cache[1] else: if _client_cache is not None: # Settings changed: retire the previous client, close it later. - _stale_client = _client_cache[1] + # 用列表累积——连续两次改设置(期间没有 enter)不得把第一个 + # 被撤下的客户端顶掉,否则它的连接池泄漏到进程结束。 + _stale_clients.append(_client_cache[1]) self.client = self.__getClient() _client_cache = (key, self.client) self.authed = False @@ -156,52 +169,69 @@ class DownloadClient: return False async def __aenter__(self): - global _stale_client, _client_cache - stale_to_close = None - async with _bookkeeping_lock: - if _stale_client is not None: - if _active_holders.get(id(_stale_client), 0) > 0: - # Still in use by another overlapping block; the last - # holder's __aexit__ will close it once released. - _pending_close.add(id(_stale_client)) - else: - stale_to_close = _stale_client - _stale_client = None - if stale_to_close is not None: - await _close_client(stale_to_close) - - if not self.authed: - await self.auth() - if not self.authed: - # __aexit__ never runs when we raise here, so close the - # concrete client's connection pool now or it leaks on every - # failed connect (#1043) -- unless another already-entered - # block still holds it, in which case defer to its - # __aexit__ instead of yanking the pool out from under it. - close_now = False - async with _bookkeeping_lock: - if _client_cache is not None and _client_cache[1] is self.client: - _client_cache = None - if _active_holders.get(id(self.client), 0) > 0: - _pending_close.add(id(self.client)) - else: - close_now = True - if close_now: - await _close_client(self.client) - raise ConnectionError("Download client authentication failed") + global _client_cache, _credential_failed_key + if ( + _credential_failed_key is not None + and _credential_failed_key == self._cache_key + ): + # 凭据上次已被服务端明确拒绝且设置未变:不再发 login POST, + # 避免逐 tick 累积到 qB 的 IP ban。checker/等待循环读的是 + # 具体客户端上的失败原因,这里补齐。 + if hasattr(self.client, "last_auth_error"): + self.client.last_auth_error = "credentials" # type: ignore[attr-defined] + raise ConnectionError( + "Download client credentials were rejected previously; " + "update the downloader settings to retry" + ) + stale_to_close: list[DownloaderClient] = [] async with _bookkeeping_lock: + # 先把自己计入引用——auth() 是 await 点,期间一个"设置变更"块 + # 可能进来关闭被撤下的客户端;不先占坑的话,自己正在登录的 + # 客户端会被从脚下抽走。 _active_holders[id(self.client)] = ( _active_holders.get(id(self.client), 0) + 1 ) + while _stale_clients: + stale = _stale_clients.pop() + if _active_holders.get(id(stale), 0) > 0: + # Still in use by another overlapping block; the last + # holder's __aexit__ will close it once released. + _pending_close.add(id(stale)) + else: + stale_to_close.append(stale) + for stale in stale_to_close: + await _close_client(stale) + + if not self.authed: + # __aexit__ never runs when __aenter__ raises, so every exit path + # below must release the holder slot itself -- including + # cancellation mid-login, or the count leaks and a retired client + # stays pending-close (pool open) forever. + try: + await self.auth() + except BaseException: + await self._release_holder() + raise + if not self.authed: + if self.last_auth_error == "credentials": + _credential_failed_key = self._cache_key + # Release our slot and close the concrete client's connection + # pool now or it leaks on every failed connect (#1043) -- + # unless another already-entered block still holds it, in + # which case defer to its __aexit__ instead of yanking the + # pool out from under it. + async with _bookkeeping_lock: + if _client_cache is not None and _client_cache[1] is self.client: + _client_cache = None + _pending_close.add(id(self.client)) + await self._release_holder() + raise ConnectionError("Download client authentication failed") + return self - async def __aexit__(self, exc_type, exc_val, exc_tb): - # The concrete session is reused across operations; do NOT log out - # here unless this was the last holder of a client that a concurrent - # block already marked for (deferred) close. Teardown is otherwise - # deferred to shutdown() (composition root). - self.authed = False + async def _release_holder(self) -> None: + """释放本块对具体客户端的引用;若是最后一个且已标记待关,则关闭。""" client = self.client to_close = False async with _bookkeeping_lock: @@ -216,6 +246,14 @@ class DownloadClient: if to_close: await _close_client(client) + async def __aexit__(self, exc_type, exc_val, exc_tb): + # The concrete session is reused across operations; do NOT log out + # here unless this was the last holder of a client that a concurrent + # block already marked for (deferred) close. Teardown is otherwise + # deferred to shutdown() (composition root). + self.authed = False + await self._release_holder() + async def auth(self): self.authed = await self.client.auth() if self.authed: diff --git a/backend/src/module/update/updater.py b/backend/src/module/update/updater.py index 2388d357..489abb54 100644 --- a/backend/src/module/update/updater.py +++ b/backend/src/module/update/updater.py @@ -622,7 +622,22 @@ class Updater: "refusing rollback" ) - if backup.exists(): + # backup 树必须连同它的已验签 bundle 一起换回:boot_overlay + # 只认 bundle.zip(.sig),缺了它互换会把现有 bundle.zip 换丢, + # 下次启动被判"legacy/unsigned"整体清除——报告回滚成功、 + # 实际落在镜像版本。旧 beta 遗留的 backup 树可能没有留存 + # bundle-backup.zip,此时走"回退镜像版本"分支才是诚实结果。 + has_signed_backup = ( + backup.exists() + and (self.root / "bundle-backup.zip").exists() + and (self.root / "bundle-backup.zip.sig").exists() + ) + if backup.exists() and not has_signed_backup: + logger.warning( + "Backup tree exists but its signed bundle is missing; " + "reverting to the image version instead of swapping." + ) + if has_signed_backup: # current <-> backup 互换,使回滚本身可再次撤销。 swap = self.root / "_swap_tmp" if swap.exists(): @@ -687,9 +702,11 @@ class Updater: ) message = "rolled back to previous version; restarting" else: - # 无备份:删除覆盖层,回退到镜像自带版本。 + # 无(可验签的)备份:删除覆盖层,回退到镜像自带版本。 if current.exists(): shutil.rmtree(current) + if backup.exists(): + shutil.rmtree(backup) if applied.exists(): applied.unlink() for name in ( diff --git a/backend/src/test/test_api_config.py b/backend/src/test/test_api_config.py index cd32dc0e..5311af73 100644 --- a/backend/src/test/test_api_config.py +++ b/backend/src/test/test_api_config.py @@ -487,6 +487,112 @@ class TestRestoreMasked: assert sanitized["downloader"]["host"] == "10.0.0.1" assert sanitized["llm"]["model"] == "gpt-4o-mini" + def test_list_item_deleted_restores_survivor_from_own_entry(self): + """删除列表首项后,幸存项的掩码密钥必须从它自己的旧值恢复, + 不能按下标错拿被删项的密钥。""" + incoming = { + "providers": [ + {"type": "telegram", "chat_id": "2", "token": "********"}, + ] + } + current = { + "providers": [ + {"type": "telegram", "chat_id": "1", "token": "tg-first"}, + {"type": "telegram", "chat_id": "2", "token": "tg-second"}, + ] + } + _restore_masked(incoming, current) + assert incoming["providers"][0]["token"] == "tg-second" + + def test_list_reordered_restores_each_from_matching_entry(self): + """列表重排后,每个掩码项按自身身份(非敏感字段)匹配恢复。""" + incoming = { + "providers": [ + {"type": "bark", "chat_id": "b", "token": "********"}, + {"type": "telegram", "chat_id": "a", "token": "********"}, + ] + } + current = { + "providers": [ + {"type": "telegram", "chat_id": "a", "token": "tg-token"}, + {"type": "bark", "chat_id": "b", "token": "bark-token"}, + ] + } + _restore_masked(incoming, current) + assert incoming["providers"][0]["token"] == "bark-token" + assert incoming["providers"][1]["token"] == "tg-token" + + def test_list_same_length_edited_fields_falls_back_to_index(self): + """长度不变时编辑了非敏感字段(无法按身份匹配)→ 按下标恢复, + 保持旧行为:改 chat_id 不应要求重输 token。""" + incoming = { + "providers": [ + {"type": "telegram", "chat_id": "changed", "token": "********"}, + ] + } + current = { + "providers": [ + {"type": "telegram", "chat_id": "orig", "token": "tg-token"}, + ] + } + _restore_masked(incoming, current) + assert incoming["providers"][0]["token"] == "tg-token" + + def test_list_structural_change_plus_edit_raises(self): + """删项 + 同时编辑幸存项的身份字段 → 无法可靠匹配,必须报错 + 而不是猜错来源静默写坏密钥。""" + from module.api.config import MaskRestoreError + + incoming = { + "providers": [ + {"type": "telegram", "chat_id": "edited", "token": "********"}, + ] + } + current = { + "providers": [ + {"type": "telegram", "chat_id": "1", "token": "tg-first"}, + {"type": "telegram", "chat_id": "2", "token": "tg-second"}, + ] + } + with pytest.raises(MaskRestoreError): + _restore_masked(incoming, current) + + def test_list_item_deleted_among_identical_identities_raises(self): + """两个身份完全相同(只差密钥)的项删掉一个:无法知道幸存的是哪个, + 必须报错而不是按下标把被删项的密钥塞给幸存者。""" + from module.api.config import MaskRestoreError + + incoming = { + "providers": [ + {"type": "telegram", "chat_id": "same", "token": "********"}, + ] + } + current = { + "providers": [ + {"type": "telegram", "chat_id": "same", "token": "tg-first"}, + {"type": "telegram", "chat_id": "same", "token": "tg-second"}, + ] + } + with pytest.raises(MaskRestoreError): + _restore_masked(incoming, current) + + def test_list_item_added_with_new_secret_passes(self): + """新增列表项带明文新密钥:无掩码,不需要匹配,不得报错。""" + incoming = { + "providers": [ + {"type": "telegram", "chat_id": "1", "token": "********"}, + {"type": "bark", "chat_id": "b", "token": "new-bark-token"}, + ] + } + current = { + "providers": [ + {"type": "telegram", "chat_id": "1", "token": "tg-token"}, + ] + } + _restore_masked(incoming, current) + assert incoming["providers"][0]["token"] == "tg-token" + assert incoming["providers"][1]["token"] == "new-bark-token" + def test_update_config_preserves_password_when_masked( self, authed_client, mock_settings ): diff --git a/backend/src/test/test_api_program.py b/backend/src/test/test_api_program.py index 33643705..63fb4f83 100644 --- a/backend/src/test/test_api_program.py +++ b/backend/src/test/test_api_program.py @@ -148,6 +148,30 @@ class TestRestartProgram: assert response.status_code == 500 +# --------------------------------------------------------------------------- +# 3.2 GET compatibility shims +# --------------------------------------------------------------------------- + + +class TestLegacyGetCompat: + """3.2 及更早版本的控制端点是 GET;外部自动化(cron/Home Assistant) + 升级到 3.3 后不得 405 静默失效。""" + + def test_restart_get_compat(self, authed_client): + assert authed_client.get("/api/v1/restart").status_code == 200 + + def test_start_get_compat(self, authed_client): + assert authed_client.get("/api/v1/start").status_code == 200 + + def test_stop_get_compat(self, authed_client): + assert authed_client.get("/api/v1/stop").status_code == 200 + + def test_shutdown_get_route_exists(self, unauthed_client): + # 不真调用(会杀掉测试进程):无鉴权应得 401,说明路由存在; + # 不存在的方法是 405。 + assert unauthed_client.get("/api/v1/shutdown").status_code == 401 + + # --------------------------------------------------------------------------- # GET /status # --------------------------------------------------------------------------- diff --git a/backend/src/test/test_aria2_downloader.py b/backend/src/test/test_aria2_downloader.py index 389a0a0e..8b2b74f5 100644 --- a/backend/src/test/test_aria2_downloader.py +++ b/backend/src/test/test_aria2_downloader.py @@ -585,7 +585,7 @@ class TestTorrentsInfo: assert info["save_path"] == "/downloads/Show/Season 1" assert info["tags"] == "ab:5" assert info["category"] == "Bangumi" - assert info["state"] == "active" + assert info["state"] == "downloading" # active + 未完成 → qB 词汇 assert info["name"] == "gidA.mkv" async def test_status_filter_completed_excludes_active(self): @@ -683,6 +683,75 @@ class TestTorrentsInfo: assert result[0]["progress"] == 0.0 assert result[0]["size"] == 0 + async def test_torrents_info_provides_ui_fields(self): + """WebUI 下载页无条件消费 num_seeds/num_leechs/eta/added_on——aria2 + 载荷缺了它们会渲染成 'undefined / undefined' 和 'NaNhNaNm'。""" + aria2 = _aria2() + download = _download("gidUi", status="active") + download["numSeeders"] = "3" + download["connections"] = "8" + download["downloadSpeed"] = "100" # 剩余 500 字节 → eta 5s + + async def fake_call(method, params=None, timeout=10.0): + if method == "tellActive": + return [download] + return [] + + with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)): + result = await aria2.torrents_info(status_filter=None, category=None) + + info = result[0] + assert info["num_seeds"] == 3 + assert info["num_leechs"] == 5 # connections - seeders + assert info["eta"] == 5 + assert isinstance(info["added_on"], int) + + async def test_torrents_info_eta_infinite_when_stalled(self): + """下载中但速度为 0:eta 用 qB 的"无穷"约定 8640000(UI 显示 '-')。""" + aria2 = _aria2() + download = _download("gidStall", status="active") # dlspeed "0" + + async def fake_call(method, params=None, timeout=10.0): + if method == "tellActive": + return [download] + return [] + + with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)): + result = await aria2.torrents_info(status_filter=None, category=None) + + assert result[0]["eta"] == 8640000 + + async def test_torrents_info_maps_states_to_qb_vocabulary(self): + """状态徽标/筛选只认 qB 词汇:active/waiting/paused/complete/error + 必须映射,不能把 aria2 原词直接透传给 UI。""" + aria2 = _aria2() + cases = [ + ("active", "500", "downloading"), + ("active", "1000", "uploading"), # 下载完成、做种中 + ("waiting", "500", "queuedDL"), + ("paused", "500", "pausedDL"), + ("paused", "1000", "pausedUP"), + ("complete", "1000", "pausedUP"), + ("error", "500", "error"), + ] + downloads = [] + for idx, (status, completed, _) in enumerate(cases): + d = _download(f"gid{idx}", status=status) + d["completedLength"] = completed + downloads.append(d) + + async def fake_call(method, params=None, timeout=10.0): + if method == "tellActive": + return downloads + return [] + + with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)): + result = await aria2.torrents_info(status_filter=None, category=None) + + by_gid = {info["hash"]: info["state"] for info in result} + for idx, (_, _, expected) in enumerate(cases): + assert by_gid[f"gid{idx}"] == expected, f"case {idx}" + async def test_torrents_info_string_numbers_parsed_to_int(self): """aria2 JSON-RPC 的数值字段都是字符串,映射结果必须是 int/float。""" aria2 = _aria2() diff --git a/backend/src/test/test_download_session_reuse.py b/backend/src/test/test_download_session_reuse.py index 3f62d876..e907cdd0 100644 --- a/backend/src/test/test_download_session_reuse.py +++ b/backend/src/test/test_download_session_reuse.py @@ -5,6 +5,7 @@ we log in once, re-authenticate only when the session expires, and rebuild when connection settings change. """ +import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -146,3 +147,249 @@ class TestSessionReuse: assert await qb.auth() is True # cheap, no second login assert len(login_calls) == 1 + + async def test_concurrent_auth_calls_share_one_login(self): + """并发的 auth()(重叠的 loop tick)必须单飞:只发一次 login POST, + 否则失败时并发登录会叠加计入 qB 的 WebUI IP ban 阈值。""" + qb = QbDownloader("localhost:8080", "admin", "admin", ssl=False) + login_calls: list = [] + + class _SlowLogin: + async def post(self, url, data=None): + if url.endswith("auth/login"): + login_calls.append(url) + await asyncio.sleep(0.01) + resp = MagicMock() + resp.status_code = 200 + resp.text = "Ok." + return resp + + async def aclose(self): + pass + + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=_SlowLogin(), + ): + r1, r2 = await asyncio.gather(qb.auth(), qb.auth()) + + assert r1 is True and r2 is True + assert len(login_calls) == 1 + + +class _FakeRejectHTTP: + """登录永远被拒(错误密码)的 httpx 替身。""" + + def __init__(self, login_calls: list): + self._login_calls = login_calls + + async def post(self, url, data=None): + if url.endswith("auth/login"): + self._login_calls.append(url) + resp = MagicMock() + resp.status_code = 200 + resp.text = "Fails." + return resp + + async def aclose(self): + pass + + +class TestCredentialFailureLatch: + """凭据被拒后不得每个 tick 重试登录:约 5 次失败就会触发 qB 的 IP ban。""" + + async def test_bad_credentials_do_not_relogin_every_block(self): + login_calls: list = [] + + with patch("module.downloader.download_client.settings") as mock_settings: + _patch_qb_settings(mock_settings) + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + side_effect=lambda *a, **k: _FakeRejectHTTP(login_calls), + ): + for _ in range(3): # 三个先后到来的 loop tick + with pytest.raises(ConnectionError): + async with DownloadClient(): + pass + + assert len(login_calls) == 1 + + async def test_concurrent_bad_credential_auths_send_one_login(self): + """并发等待者不得在首个失败者确认"凭据被拒"后再各发一次 login POST。""" + login_calls: list = [] + + class _SlowReject: + async def post(self, url, data=None): + if url.endswith("auth/login"): + login_calls.append(url) + await asyncio.sleep(0.01) + resp = MagicMock() + resp.status_code = 200 + resp.text = "Fails." + return resp + + async def aclose(self): + pass + + qb = QbDownloader("localhost:8080", "admin", "wrong", ssl=False) + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=_SlowReject(), + ): + r = await asyncio.gather(qb.auth(), qb.auth(), qb.auth()) + + assert r == [False, False, False] + assert len(login_calls) == 1 + + async def test_latch_cleared_on_explicit_clear(self): + """配置保存(reload_settings)后清除闩锁,允许重试一次登录。""" + from module.downloader.download_client import clear_credential_latch + + login_calls: list = [] + + with patch("module.downloader.download_client.settings") as mock_settings: + _patch_qb_settings(mock_settings) + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + side_effect=lambda *a, **k: _FakeRejectHTTP(login_calls), + ): + with pytest.raises(ConnectionError): + async with DownloadClient(): + pass + clear_credential_latch() + with pytest.raises(ConnectionError): + async with DownloadClient(): + pass + + assert len(login_calls) == 2 + + +class TestRetiredClientLifecycle: + async def test_two_rapid_settings_changes_close_both_retired_clients(self): + """连续两次改设置(期间没有任何 enter)不得丢失第一个被撤下的 + 客户端——它的连接池必须在下次 enter 时被关闭,而不是泄漏到进程结束。""" + fakes: list = [] + + class _TrackedHTTP: + def __init__(self): + self.closed = False + fakes.append(self) + + async def post(self, url, data=None): + resp = MagicMock() + resp.status_code = 200 + resp.text = "Ok." + return resp + + async def aclose(self): + self.closed = True + + with patch("module.downloader.download_client.settings") as mock_settings: + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + side_effect=lambda *a, **k: _TrackedHTTP(), + ): + _patch_qb_settings(mock_settings, host="host-a:8080") + async with DownloadClient(): + pass # A 已认证,连接池已打开 + _patch_qb_settings(mock_settings, host="host-b:8080") + DownloadClient() # 撤下 A(未 enter) + _patch_qb_settings(mock_settings, host="host-c:8080") + DownloadClient() # 撤下 B——不得把 A 顶掉忘掉 + async with DownloadClient(): + pass + + assert fakes[0].closed is True # A 的连接池被关闭,未泄漏 + + async def test_cancelled_auth_releases_holder_bookkeeping(self): + """__aenter__ 的 auth() 被取消/抛异常时必须释放引用计数,否则被撤下 + 的客户端永远挂在 pending-close 上、连接池永不关闭。""" + from module.downloader import download_client as dc + + class _HangingLogin: + async def post(self, url, data=None): + if url.endswith("auth/login"): + await asyncio.Event().wait() # 永不返回,等着被取消 + resp = MagicMock() + resp.status_code = 200 + resp.text = "Ok." + return resp + + async def aclose(self): + pass + + with patch("module.downloader.download_client.settings") as mock_settings: + _patch_qb_settings(mock_settings) + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=_HangingLogin(), + ): + + async def enter_block(): + async with DownloadClient(): + pass + + task = asyncio.create_task(enter_block()) + await asyncio.sleep(0.01) # 进入 auth() 等待 + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert dc._active_holders == {} + + async def test_settings_change_does_not_close_client_mid_login(self): + """设置变更时,另一个块还在 __aenter__ 的 auth() 中途(尚未计入 + 引用计数)——不得把它正在登录的客户端关掉。""" + events: list[str] = [] + login_started = asyncio.Event() + release_login = asyncio.Event() + + class _SlowLoginHTTP: + async def post(self, url, data=None): + if url.endswith("auth/login"): + login_started.set() + await release_login.wait() + resp = MagicMock() + resp.status_code = 200 + resp.text = "Ok." + return resp + + async def aclose(self): + events.append("A-closed") + + class _FastHTTP: + async def post(self, url, data=None): + resp = MagicMock() + resp.status_code = 200 + resp.text = "Ok." + return resp + + async def aclose(self): + pass + + with patch("module.downloader.download_client.settings") as mock_settings: + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + side_effect=[_SlowLoginHTTP(), _FastHTTP()], + ): + _patch_qb_settings(mock_settings, host="host-a:8080") + + async def block1(): + async with DownloadClient(): + events.append("block1-in") + events.append("block1-out") + + task = asyncio.create_task(block1()) + await login_started.wait() # block1 正在 auth() 中途 + + _patch_qb_settings(mock_settings, host="host-b:8080") + async with DownloadClient(): # 撤下 A 并触发关闭决策 + pass + + release_login.set() + await task + + assert "A-closed" in events + # A 只能在 block1 成功 enter 之后关闭(由 block1 的 __aexit__ 收尾), + # 不得在其登录中途被关。 + assert events.index("A-closed") > events.index("block1-in") diff --git a/backend/src/test/test_update.py b/backend/src/test/test_update.py index 8773117b..f6fadf5b 100644 --- a/backend/src/test/test_update.py +++ b/backend/src/test/test_update.py @@ -447,6 +447,17 @@ class TestApplyUpdate: # --------------------------------------------------------------------------- +def _seed_backup_bundles(root): + """正常签名流程下 backup 树总是伴随留存的已验签 bundle 及其签名。""" + for name in ( + "bundle.zip", + "bundle.zip.sig", + "bundle-backup.zip", + "bundle-backup.zip.sig", + ): + (root / name).write_bytes(b"stub-" + name.encode()) + + class TestRollback: @pytest.mark.asyncio async def test_rollback_swaps_backup_into_current(self, tmp_path): @@ -457,6 +468,7 @@ class TestRollback: backup.mkdir(parents=True) (current / "manifest.json").write_text(json.dumps({"version": "3.3.0"})) (backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"})) + _seed_backup_bundles(root) up = Updater(root=root, current_version="3.3.0", image_version="3.1.0") res = await up.rollback() @@ -467,6 +479,34 @@ class TestRollback: assert cur["version"] == "3.2.0" applied = json.loads((root / "applied.json").read_text()) assert applied["version"] == "3.2.0" + # bundle 与 backup-bundle 同步互换,boot_overlay 验签对象跟 current 一致 + assert (root / "bundle.zip").read_bytes() == b"stub-bundle-backup.zip" + assert (root / "bundle-backup.zip").read_bytes() == b"stub-bundle.zip" + + @pytest.mark.asyncio + async def test_rollback_without_backup_bundle_reverts_to_image(self, tmp_path): + """backup 树存在但没有留存的已验签 bundle-backup.zip(旧 beta 遗留): + 直接互换会把 bundle.zip 换丢,boot_overlay 下次启动拒绝并清除覆盖层。 + 必须改走"回退镜像版本"路径,而不是报告回滚成功却落在另一个版本。""" + root = tmp_path / "u" + current = root / "current" + backup = root / "backup" + current.mkdir(parents=True) + backup.mkdir(parents=True) + (current / "manifest.json").write_text(json.dumps({"version": "3.3.0"})) + (backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"})) + (root / "bundle.zip").write_bytes(b"stub") + (root / "bundle.zip.sig").write_bytes(b"stub") + (root / "applied.json").write_text(json.dumps({"version": "3.3.0"})) + + up = Updater(root=root, current_version="3.3.0", image_version="3.1.0") + res = await up.rollback() + assert res.success is True + assert res.version == "3.1.0" # 镜像版本,而非无法验签的 3.2.0 + assert not current.exists() + assert not backup.exists() + assert not (root / "bundle.zip").exists() + assert not (root / "applied.json").exists() @pytest.mark.asyncio async def test_rollback_restores_db_snapshot(self, tmp_path): @@ -477,6 +517,7 @@ class TestRollback: backup.mkdir(parents=True) (current / "manifest.json").write_text(json.dumps({"version": "3.3.0"})) (backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"})) + _seed_backup_bundles(root) db_path = tmp_path / "data" / "data.db" _seed_schema_db(db_path, 9) backup_db = root / "db-backup" / "data.db" @@ -517,6 +558,7 @@ class TestRollback: backup.mkdir(parents=True) (current / "manifest.json").write_text(json.dumps({"version": "3.3.0"})) (backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"})) + _seed_backup_bundles(root) db_path = tmp_path / "data" / "data.db" _seed_schema_db(db_path, 4) backup_db = root / "db-backup" / "data.db" @@ -552,6 +594,7 @@ class TestRollback: backup.mkdir(parents=True) (current / "manifest.json").write_text(json.dumps({"version": "3.3.0"})) (backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"})) + _seed_backup_bundles(root) db_path = tmp_path / "data" / "data.db" _seed_schema_db(db_path, 9) # 上一轮更新遗留的快照目录;本轮 applied.json 没有 db_backup 记录 @@ -588,6 +631,7 @@ class TestRollback: backup.mkdir(parents=True) (current / "manifest.json").write_text(json.dumps({"version": "3.3.0"})) (backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"})) + _seed_backup_bundles(root) db_path = tmp_path / "data" / "data.db" _seed_schema_db(db_path, 999) @@ -793,6 +837,146 @@ class TestBootOverlay: ) assert applied is False + def test_exdev_fallback_restores_old_tree_on_partial_copy( + self, tmp_path, monkeypatch + ): + """EXDEV 就地替换中途失败(磁盘满/IO 错)不得留下残缺的 module 树—— + 必须恢复旧树,让启动继续跑镜像版本而不是 ImportError 崩溃循环。""" + import errno + import os as os_mod + import shutil as shutil_mod + + import boot_overlay + + app, updates, ivp, lock, pubkey = self._seed(tmp_path, "3.4.0") + + real_rename = os_mod.rename + + def fake_rename(a, b): + if str(a).endswith(("module", "dist")): + raise OSError(errno.EXDEV, "Cross-device link") + return real_rename(a, b) + + monkeypatch.setattr(boot_overlay.os, "rename", fake_rename) + + real_copy2 = shutil_mod.copy2 + + def fake_copy2(src, dst, *args, **kwargs): + d = Path(dst) + # 只让"往 /app/module 里灌新文件"这一步失败;快照备份(old.txt) + # 与 staging 复制不受影响。 + if d.parent == app / "module" and d.name == "__marker__.txt": + raise OSError(errno.ENOSPC, "No space left on device") + return real_copy2(src, dst, *args, **kwargs) + + monkeypatch.setattr(boot_overlay.shutil, "copy2", fake_copy2) + + applied = boot_overlay.apply_overlay( + app_root=app, + updates_root=updates, + image_version_path=ivp, + baseline_lock=lock, + pubkey_path=pubkey, + ) + assert applied is False + # 旧树必须完整恢复,不能是"删光了但没灌进去"的空壳 + assert (app / "module" / "old.txt").exists() + assert not (app / "module" / "__marker__.txt").exists() + # 恢复用的快照目录不残留 + assert not (app / "module.ab_bak").exists() + + def test_overlay_chowns_replaced_trees_when_root(self, tmp_path, monkeypatch): + """以 root 落地覆盖层后必须把树交还应用用户:受限 UMASK(如 077)下 + extractall 产物是 600/700 root:root,ab 读不了 → 启动崩溃循环。""" + import pwd + import subprocess + + import boot_overlay + + app, updates, ivp, lock, pubkey = self._seed(tmp_path, "3.4.0") + + monkeypatch.setattr(boot_overlay.os, "geteuid", lambda: 0, raising=False) + monkeypatch.setattr(pwd, "getpwnam", lambda name: object()) + calls: list[list[str]] = [] + + def fake_run(cmd, *args, **kwargs): + calls.append([str(c) for c in cmd]) + return MagicMock(returncode=0) + + monkeypatch.setattr(subprocess, "run", fake_run) + + applied = boot_overlay.apply_overlay( + app_root=app, + updates_root=updates, + image_version_path=ivp, + baseline_lock=lock, + pubkey_path=pubkey, + ) + assert applied is True + chown_targets = { + cmd[-1] for cmd in calls if cmd[:3] == ["chown", "-R", "ab:ab"] + } + assert str(app / "module") in chown_targets + assert str(app / "dist") in chown_targets + assert str(app / ".venv") in chown_targets + + def test_exdev_restore_also_chowns_restored_tree_when_root( + self, tmp_path, monkeypatch + ): + """EXDEV 部分复制失败后恢复的旧树同样要交还 ab:以 root 复制回来的 + 文件在受限 UMASK 下可能 600 root:root,不 chown 则恢复了也读不了。""" + import errno + import os as os_mod + import pwd + import shutil as shutil_mod + import subprocess + + import boot_overlay + + app, updates, ivp, lock, pubkey = self._seed(tmp_path, "3.4.0") + + real_rename = os_mod.rename + + def fake_rename(a, b): + if str(a).endswith(("module", "dist")): + raise OSError(errno.EXDEV, "Cross-device link") + return real_rename(a, b) + + monkeypatch.setattr(boot_overlay.os, "rename", fake_rename) + + real_copy2 = shutil_mod.copy2 + + def fake_copy2(src, dst, *args, **kwargs): + d = Path(dst) + if d.parent == app / "module" and d.name == "__marker__.txt": + raise OSError(errno.ENOSPC, "No space left on device") + return real_copy2(src, dst, *args, **kwargs) + + monkeypatch.setattr(boot_overlay.shutil, "copy2", fake_copy2) + monkeypatch.setattr(boot_overlay.os, "geteuid", lambda: 0, raising=False) + monkeypatch.setattr(pwd, "getpwnam", lambda name: object()) + calls: list[list[str]] = [] + + def fake_run(cmd, *args, **kwargs): + calls.append([str(c) for c in cmd]) + return MagicMock(returncode=0) + + monkeypatch.setattr(subprocess, "run", fake_run) + + applied = boot_overlay.apply_overlay( + app_root=app, + updates_root=updates, + image_version_path=ivp, + baseline_lock=lock, + pubkey_path=pubkey, + ) + assert applied is False + assert (app / "module" / "old.txt").exists() + chown_targets = { + cmd[-1] for cmd in calls if cmd[:3] == ["chown", "-R", "ab:ab"] + } + assert str(app / "module") in chown_targets + # --------------------------------------------------------------------------- # API contract: path + verb + auth-gated diff --git a/entrypoint.sh b/entrypoint.sh index d8ee9718..8b40ba49 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -43,8 +43,12 @@ while true; do if [ "$(cat "${own_marker}" 2>/dev/null)" != "${want}" ] || [ "$(stat -c '%u:%g' /app/data 2>/dev/null)" != "${want}" ] || [ "$(stat -c '%u:%g' /app/config 2>/dev/null)" != "${want}" ]; then - echo "${want}" >"${own_marker}" - chown ab:ab -R /app/data /app/config + # 先 chown、后写 marker:中途被打断(停容器/崩溃)或部分失败 + # (NAS/root-squash 报错)时下次启动必须重跑树遍历,否则半途的 + # 属主状态被记成"已完成"、永不自愈。 + if chown ab:ab -R /app/data /app/config; then + echo "${want}" >"${own_marker}" + fi fi su-exec "${PUID}:${PGID}" python main.py &