diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 27346d82..75ab323d 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -65,6 +65,7 @@ function load_config_from_app_env() { ["GITHUB_TOKEN"]="" ["MOVIEPILOT_AUTO_UPDATE"]="release" ["MOVIEPILOT_DOCKER_KEEPALIVE_ON_FAILURE"]="true" + ["MOVIEPILOT_FORCE_CHOWN"]="false" ["MOVIEPILOT_SAFE_MODE"]="false" ["BROWSER_EMULATION"]="cloakbrowser" @@ -315,6 +316,70 @@ function ensure_backend_runtime_dependencies() { INFO "→ 已自动恢复主程序依赖,继续启动后端。" } +function force_chown_image_paths_if_requested() { + local force="${MOVIEPILOT_FORCE_CHOWN:-false}" + force="${force,,}" + + if [ "${force}" != "true" ] && [ "${force}" != "1" ] && [ "${force}" != "yes" ]; then + return 0 + fi + + WARN "→ MOVIEPILOT_FORCE_CHOWN 已启用,将递归修复 /app、/public 权限,可能显著增加启动耗时。" + + local path + for path in "$@"; do + [ -e "${path}" ] || continue + chown -R moviepilot:moviepilot "${path}" + done +} + +function correct_home_permissions() { + [ -e "${HOME}" ] || return 0 + + local force="${MOVIEPILOT_FORCE_CHOWN:-false}" + force="${force,,}" + + chown moviepilot:moviepilot "${HOME}" + [ -e "${HOME}/.cloakbrowser" ] && chown -h moviepilot:moviepilot "${HOME}/.cloakbrowser" + + if [ "${force}" = "true" ] || [ "${force}" = "1" ] || [ "${force}" = "yes" ]; then + [ -e "${HOME}/.cloakbrowser" ] && chown -R moviepilot:moviepilot "${HOME}/.cloakbrowser" + elif [ -e "${HOME}/.cloakbrowser" ]; then + INFO "→ 默认跳过 ${HOME}/.cloakbrowser 递归权限校正,如遇浏览器缓存权限错误可设置 MOVIEPILOT_FORCE_CHOWN=true 后重启一次。" + fi + + find "${HOME}" -mindepth 1 -maxdepth 1 ! -name ".cloakbrowser" -exec chown -R moviepilot:moviepilot {} + +} + +function chown_plugin_runtime_path() { + local plugin_path="${1:-}" + [ -n "${plugin_path}" ] || return 0 + [ -e "${plugin_path}" ] || return 0 + local current_owner + current_owner="$(stat -c '%u:%g' "${plugin_path}" 2>/dev/null || true)" + [ "${current_owner}" = "${PUID}:${PGID}" ] && return 0 + chown -h moviepilot:moviepilot "${plugin_path}" +} + +function correct_file_permissions() { + local chown_start + local chown_end + chown_start=$(date +%s) + + INFO "→ 正在校正文件权限..." + force_chown_image_paths_if_requested /app /public + chown_plugin_runtime_path /app/app/plugins + correct_home_permissions + chown -R moviepilot:moviepilot \ + "${CONFIG_DIR}" \ + /var/lib/nginx \ + /var/log/nginx + chown moviepilot:moviepilot /etc/hosts /tmp + + chown_end=$(date +%s) + INFO "→ 文件权限校正完成,耗时 $(( chown_end - chown_start )) 秒。" +} + # 使用env配置 load_config_from_app_env apply_package_cache_env @@ -354,14 +419,7 @@ groupmod -o -g "${PGID}" moviepilot usermod -o -u "${PUID}" moviepilot # 更改文件权限 -chown -R moviepilot:moviepilot \ - "${HOME}" \ - /app \ - /public \ - "${CONFIG_DIR}" \ - /var/lib/nginx \ - /var/log/nginx -chown moviepilot:moviepilot /etc/hosts /tmp +correct_file_permissions # 启动前优先确认主运行环境仍然健康,避免插件依赖污染导致服务直接起不来。 ensure_backend_runtime_dependencies diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py new file mode 100644 index 00000000..2ae66557 --- /dev/null +++ b/tests/test_docker_entrypoint_permissions.py @@ -0,0 +1,172 @@ +import os +import subprocess +import textwrap +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _write_entrypoint_functions(tmp_path: Path) -> Path: + content = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + marker = "# 使用env配置" + assert marker in content + functions = tmp_path / "entrypoint-functions.sh" + functions.write_text(content.split(marker, 1)[0], encoding="utf-8") + return functions + + +def _write_fake_chown(tmp_path: Path) -> Path: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + chown = fake_bin / "chown" + chown.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env bash + printf '%s\\n' "$*" >> "${MP_CHOWN_LOG}" + """ + ), + encoding="utf-8", + ) + chown.chmod(0o755) + return fake_bin + + +def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None = None) -> str: + functions = _write_entrypoint_functions(tmp_path) + fake_bin = _write_fake_chown(tmp_path) + chown_log = tmp_path / "chown.log" + app_dir = tmp_path / "app" + public_dir = tmp_path / "public" + home_dir = tmp_path / "home" + (app_dir / "app" / "plugins").mkdir(parents=True) + public_dir.mkdir() + (home_dir / ".cloakbrowser").mkdir(parents=True) + (home_dir / "runtime").mkdir() + (app_dir / "app" / "plugins" / "plugin.py").write_text("# plugin\n", encoding="utf-8") + (public_dir / "index.html").write_text("\n", encoding="utf-8") + (home_dir / ".cloakbrowser" / "chrome").write_text("browser cache\n", encoding="utf-8") + (home_dir / "runtime" / "state").write_text("state\n", encoding="utf-8") + external_target = tmp_path / "external-target" + external_target.write_text("external\n", encoding="utf-8") + (app_dir / "external-link").symlink_to(external_target) + + case_env = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "MP_CHOWN_LOG": str(chown_log), + "ENTRYPOINT_FUNCTIONS": str(functions), + "APP_DIR": str(app_dir), + "PUBLIC_DIR": str(public_dir), + "HOME_DIR": str(home_dir), + "CONFIG_DIR": str(tmp_path / "config"), + "PUID": str(os.getuid()), + "PGID": str(os.getgid()), + } + if env: + case_env.update(env) + + script = textwrap.dedent( + f"""\ + set -euo pipefail + source "${{ENTRYPOINT_FUNCTIONS}}" + {body} + """ + ) + subprocess.run(["bash", "-c", script], check=True, env=case_env) + return chown_log.read_text(encoding="utf-8") if chown_log.exists() else "" + + +def test_image_paths_are_not_chowned_by_default_regardless_of_owner(tmp_path: Path) -> None: + log = _run_permission_case( + tmp_path, + 'force_chown_image_paths_if_requested "${APP_DIR}" "${PUBLIC_DIR}"', + env={"PUID": "999999", "PGID": "999999"}, + ) + + assert log == "" + + +def test_image_paths_force_chown_uses_recursive_repair(tmp_path: Path) -> None: + log = _run_permission_case( + tmp_path, + 'MOVIEPILOT_FORCE_CHOWN=true force_chown_image_paths_if_requested "${APP_DIR}" "${PUBLIC_DIR}"', + ) + + assert log.startswith("-R moviepilot:moviepilot ") + assert "/app" in log + assert "/public" in log + + +def test_image_paths_force_chown_accepts_numeric_and_yes_values(tmp_path: Path) -> None: + for force_value in ("1", "YES"): + case_path = tmp_path / force_value.lower() + case_path.mkdir() + log = _run_permission_case( + case_path, + f'MOVIEPILOT_FORCE_CHOWN={force_value} force_chown_image_paths_if_requested "${{APP_DIR}}" "${{PUBLIC_DIR}}"', + ) + + assert log.startswith("-R moviepilot:moviepilot ") + assert "/app" in log + assert "/public" in log + + +def test_plugin_directory_skips_chown_when_owner_matches(tmp_path: Path) -> None: + log = _run_permission_case( + tmp_path, + 'chown_plugin_runtime_path "${APP_DIR}/app/plugins"', + ) + + assert log == "" + + +def test_plugin_directory_chowns_only_root_directory_when_owner_mismatches(tmp_path: Path) -> None: + log = _run_permission_case( + tmp_path, + 'chown_plugin_runtime_path "${APP_DIR}/app/plugins"', + env={"PUID": "999999", "PGID": "999999"}, + ) + + assert log == f"-h moviepilot:moviepilot {tmp_path}/app/app/plugins\n" + + +def test_home_permissions_skip_cloakbrowser_cache_by_default(tmp_path: Path) -> None: + log = _run_permission_case( + tmp_path, + 'HOME="${HOME_DIR}" correct_home_permissions', + ) + + lines = log.splitlines() + assert f"moviepilot:moviepilot {tmp_path}/home" in lines + assert f"-h moviepilot:moviepilot {tmp_path}/home/.cloakbrowser" in lines + assert f"-R moviepilot:moviepilot {tmp_path}/home/runtime" in lines + assert not any(line.startswith("-R ") and ".cloakbrowser" in line for line in lines) + + +def test_home_permissions_force_chown_repairs_cloakbrowser_cache(tmp_path: Path) -> None: + log = _run_permission_case( + tmp_path, + 'MOVIEPILOT_FORCE_CHOWN=yes HOME="${HOME_DIR}" correct_home_permissions', + ) + + assert f"-R moviepilot:moviepilot {tmp_path}/home/.cloakbrowser" in log + + +def test_runtime_writable_paths_are_still_corrected(tmp_path: Path) -> None: + log = _run_permission_case( + tmp_path, + 'HOME="${HOME_DIR}" correct_file_permissions', + env={"PUID": "999999", "PGID": "999999"}, + ) + + lines = log.splitlines() + assert f"moviepilot:moviepilot {tmp_path}/home" in lines + assert f"-h moviepilot:moviepilot {tmp_path}/home/.cloakbrowser" in lines + assert f"-R moviepilot:moviepilot {tmp_path}/home/runtime" in lines + assert f"-R moviepilot:moviepilot {tmp_path}/config /var/lib/nginx /var/log/nginx" in lines + assert "moviepilot:moviepilot /etc/hosts /tmp" in lines + assert not any(line.startswith("-R ") and ".cloakbrowser" in line for line in lines) + assert not any(f"{tmp_path}/app " in line for line in lines) + assert not any(f"{tmp_path}/public" in line for line in lines)