fix: address stable-review findings across config, update, downloader

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
Estrella Pan
2026-07-06 16:37:28 +02:00
parent f751d1c6e7
commit f14ab65574
15 changed files with 992 additions and 71 deletions

View File

@@ -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:

View File

@@ -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")

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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()

View File

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

View File

@@ -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} "

View File

@@ -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:

View File

@@ -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 (

View File

@@ -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
):

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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):
"""下载中但速度为 0eta 用 qB 的"无穷"约定 8640000UI 显示 '-')。"""
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()

View File

@@ -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")

View File

@@ -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:rootab 读不了 → 启动崩溃循环。"""
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

View File

@@ -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 &