test_shell_lifecycle.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import json
  2. import os
  3. import pathlib
  4. import shutil
  5. import subprocess
  6. import sys
  7. import tempfile
  8. import unittest
  9. PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
  10. class TestShellLifecycleP0P1Bugs(unittest.TestCase):
  11. """测试并锁定 Shell 部署与发布脚本中的高危安全及逻辑漏洞"""
  12. def setUp(self):
  13. self.tmp_dir = tempfile.TemporaryDirectory()
  14. self.work_dir = pathlib.Path(self.tmp_dir.name)
  15. def tearDown(self):
  16. self.tmp_dir.cleanup()
  17. def test_p0_uninstall_debian_empty_service_name_deletes_var_lib(self):
  18. """[P0/P2 修复验证] 验证 uninstall_debian.sh 传空服务名立即报错退出,绝不执行 rm -rf /var/lib/"""
  19. mock_bin = self.work_dir / "bin"
  20. mock_bin.mkdir()
  21. # Mock id 绕过 root 检测
  22. (mock_bin / "id").write_text("#!/bin/sh\necho 0\n")
  23. # Mock systemctl
  24. (mock_bin / "systemctl").write_text("#!/bin/sh\nexit 0\n")
  25. # Mock rm 记录所有删除操作
  26. rm_log = self.work_dir / "rm_calls.log"
  27. (mock_bin / "rm").write_text(f"#!/bin/sh\necho \"rm $*\" >> {rm_log}\n")
  28. for p in mock_bin.iterdir():
  29. p.chmod(0o755)
  30. script_path = PROJECT_ROOT / "scripts" / "uninstall_debian.sh"
  31. env = os.environ.copy()
  32. env["PATH"] = f"{mock_bin}:{env.get('PATH', '')}"
  33. # 传入非法空参数 --service-name ""
  34. proc = subprocess.run(
  35. ["bash", str(script_path), "--service-name", ""],
  36. env=env,
  37. capture_output=True,
  38. text=True,
  39. cwd=str(self.work_dir),
  40. )
  41. self.assertNotEqual(proc.returncode, 0, "空服务名必须报错退出")
  42. rm_content = rm_log.read_text() if rm_log.exists() else ""
  43. self.assertNotIn("rm -rf /var/lib/", rm_content, "修复验证确认:绝不执行 rm -rf /var/lib/")
  44. def test_p0_build_router_package_trap_prematurely_deletes_tmp_dir(self):
  45. """[P0/P2 修复验证] 验证 build_router_package.sh 延迟注册 trap,遇到前置报错绝不误删已有目录"""
  46. victim_dir = self.work_dir / "victim_data_dir"
  47. victim_dir.mkdir()
  48. canary_file = victim_dir / "data.txt"
  49. canary_file.write_text("critical data")
  50. script_path = PROJECT_ROOT / "build_router_package.sh"
  51. env = os.environ.copy()
  52. env["TMP_DIR"] = str(victim_dir)
  53. env["ROUTER_CONF"] = "/nonexistent/path/config_router.conf"
  54. # 执行打包脚本,因缺少配置文件而必然报错退出
  55. proc = subprocess.run(
  56. ["sh", str(script_path)],
  57. env=env,
  58. capture_output=True,
  59. text=True,
  60. cwd=str(self.work_dir),
  61. )
  62. self.assertNotEqual(proc.returncode, 0)
  63. # 验证修复成功:victim_dir 完好无损,没有被提前的 trap cleanup 误删
  64. self.assertTrue(victim_dir.exists(), "修复验证确认:build_router_package.sh 未误删 victim_dir")
  65. def test_p1_run_update_and_commit_passes_token_in_env_not_argv(self):
  66. """[P1 修复验证] 验证 run_update_and_commit.sh 采用 GIT_CONFIG_* 环境变量传递 Token,argv 零泄露"""
  67. script_path = PROJECT_ROOT / "scripts" / "run_update_and_commit.sh"
  68. script_content = script_path.read_text()
  69. # 确认彻底移除了命令行 -c "http.extraHeader=..."
  70. self.assertNotIn('git_auth_opts=(-c "http.extraHeader', script_content, "Token 严禁出现在命令行 argv 参数中")
  71. self.assertIn("GIT_CONFIG_COUNT=1", script_content, "使用 Git 原生配置环境变量安全传递")
  72. self.assertIn("GIT_CONFIG_KEY_0=http.extraHeader", script_content)
  73. self.assertIn("GIT_CONFIG_VALUE_0=Authorization: Basic", script_content)
  74. def test_p1_run_update_and_commit_ls_remote_distinguishes_error_code(self):
  75. """[P1 修复验证] 验证 run_update_and_commit.sh 明确区分 ls-remote 退出码 2 与 128"""
  76. script_path = PROJECT_ROOT / "scripts" / "run_update_and_commit.sh"
  77. script_content = script_path.read_text()
  78. self.assertIn("ls_remote_rc", script_content, "已捕获并校验 ls-remote 退出码")
  79. self.assertIn("$ls_remote_rc -eq 2", script_content, "显式匹配 2 作为分支不存在")
  80. self.assertIn("fatal: git ls-remote failed", script_content, "非 0/2 致命错误直接报错退出,决不误建孤儿分支")
  81. def test_p1_run_update_and_commit_fallback_silence(self):
  82. """[P1 修复验证] 验证 run_update_and_commit.sh 在回退状态且值未变时执行回退静默"""
  83. script_path = PROJECT_ROOT / "scripts" / "run_update_and_commit.sh"
  84. script_content = script_path.read_text()
  85. self.assertIn('is_fallback="0"', script_content)
  86. self.assertIn('"error_use_last_good"', script_content)
  87. self.assertIn('is_fallback="1"', script_content)
  88. self.assertIn('"$is_fallback" == "1"', script_content)
  89. def test_mode_a_git_history_rolling_prune(self):
  90. """[方案B 模式A验证] 验证 run_update_and_commit.sh 在达到阈值时自动修剪孤儿分支历史至 max_state_commits"""
  91. repo_dir = self.work_dir / "state_repo"
  92. repo_dir.mkdir()
  93. # 初始化 git repo
  94. subprocess.run(["git", "init", "-b", "main"], cwd=str(repo_dir), check=True, capture_output=True)
  95. subprocess.run(["git", "config", "user.name", "Test Runner"], cwd=str(repo_dir), check=True)
  96. subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=str(repo_dir), check=True)
  97. # 模拟生成 5 个初始历史 commit
  98. target_file = repo_dir / "current.txt"
  99. for i in range(1, 6):
  100. target_file.write_text(f"domain{i}.example.com\n")
  101. subprocess.run(["git", "add", "."], cwd=str(repo_dir), check=True)
  102. subprocess.run(["git", "commit", "-m", f"update {i}"], cwd=str(repo_dir), check=True, capture_output=True)
  103. initial_count = int(subprocess.check_output(["git", "rev-list", "--count", "HEAD"], cwd=str(repo_dir)).decode().strip())
  104. self.assertEqual(initial_count, 5)
  105. # 准备真实 updater 所需的测试配置与数据源
  106. csv_file = self.work_dir / "result.csv"
  107. csv_file.write_text("IP 地址,已用时间,下载速度 (MB/s)\nnew-best.example.com,20 ms,12.50\n")
  108. test_config = {
  109. "source": {"type": "cfst_local"},
  110. "cfst_local": {
  111. "work_dir": str(self.work_dir),
  112. "binary": "echo",
  113. "result_file": str(csv_file),
  114. "skip_run": True,
  115. "run_args": [],
  116. },
  117. "output": {
  118. "runtime_dir": str(repo_dir),
  119. "selected_value_file": "current.txt",
  120. "selected_value_json": "current.json",
  121. "selected_value_json_key": "domain",
  122. "state_file": "state.json",
  123. "state_last_good_key": "last_good",
  124. "export_vars_file": "vars.json",
  125. "substore_value_key": "DOMAIN",
  126. },
  127. }
  128. config_path = self.work_dir / "test_config.json"
  129. config_path.write_text(json.dumps(test_config))
  130. script_path = PROJECT_ROOT / "scripts" / "run_update_and_commit.sh"
  131. env = os.environ.copy()
  132. env["APP_DIR"] = str(repo_dir)
  133. env["GIT_RUNTIME_BRANCH"] = "main"
  134. env["GIT_PUSH_ENABLED"] = "0"
  135. env["GIT_MAX_STATE_COMMITS"] = "3" # 设置阈值为 3
  136. env["PYTHON_BIN"] = sys.executable
  137. # 必须带上 scripts 目录(含 domain_updater.py 与 rotator 子包)在 APP_DIR
  138. app_scripts_dir = repo_dir / "scripts"
  139. shutil.copytree(PROJECT_ROOT / "scripts", app_scripts_dir)
  140. # 运行 run_update_and_commit.sh
  141. proc = subprocess.run(
  142. [
  143. "bash",
  144. str(script_path),
  145. "--force-commit",
  146. str(config_path),
  147. ],
  148. env=env,
  149. capture_output=True,
  150. text=True,
  151. cwd=str(self.work_dir),
  152. )
  153. combined_output = f"{proc.stdout}\n{proc.stderr}"
  154. self.assertIn("trimming older history (retaining last 3)", combined_output)
  155. self.assertIn("history successfully pruned to 3 commits", combined_output)
  156. # 检查 commit 总数是否被精确修剪至 3
  157. pruned_count = int(subprocess.check_output(["git", "rev-list", "--count", "HEAD"], cwd=str(repo_dir)).decode().strip())
  158. self.assertEqual(pruned_count, 3, "历史 Commit 数量必须严格限制在 max_state_commits = 3")
  159. # 检查最新内容是否完整保存
  160. self.assertEqual(target_file.read_text().strip(), "new-best.example.com")
  161. if __name__ == "__main__":
  162. unittest.main()