From 1227efb7f36380681dd821ca3438bdecb754e939 Mon Sep 17 00:00:00 2001 From: JavisZhangSir-BYR <84219624+JavisZhangSir-BYR@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:51:51 +0800 Subject: [PATCH 1/2] Fix macOS Git system proxy discovery --- README.md | 27 +++++ gitdrop/git_sync.py | 101 ++++++++++++++++++- tests/test_git_sync_proxy.py | 188 +++++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 tests/test_git_sync_proxy.py diff --git a/README.md b/README.md index 5de97ec..5231a94 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,33 @@ inbox/ - 清空远端内容:在“仓库设置”中点击“清空仓库内容”,确认后删除当前分支中的文件。仓库和提交历史不会被删除。 - 不保存 Token:取消勾选“使用 Git 凭据管理器记住 Token”。 +### macOS 代理和公司网络 + +GitDrop 会按以下顺序查找可供 Git 子进程使用的代理: + +1. `GITDROP_HTTPS_PROXY` +2. `HTTPS_PROXY` / `https_proxy` +3. `ALL_PROXY` / `all_proxy` +4. `HTTP_PROXY` / `http_proxy` +5. macOS 系统 HTTP/HTTPS 代理 + +例如,可以从终端为本次启动显式指定代理: + +```bash +GITDROP_HTTPS_PROXY=http://127.0.0.1:1082 \ + "/Applications/GitDrop.app/Contents/MacOS/GitDrop" +``` + +如果使用 Shadowrocket、Clash、Surge 或其他 Fake-IP/TUN 代理,并遇到 +`SSL_ERROR_SYSCALL`、`Connection reset by peer` 或 `Recv failure`,请确认: + +- 本地代理端口正在监听; +- `github.com` 命中代理而不是 `DIRECT`; +- 当前代理节点可访问 GitHub; +- 未将 `198.18.0.0/15` 中的 Fake-IP 当作真实 GitHub 地址直接访问。 + +请勿通过关闭 Git 的 `http.sslVerify` 解决连接问题。GitDrop 会保持 TLS 证书验证开启。 + ## 从源码运行 仓库中提供 `start_gitdrop.bat`(Windows)和 `start_gitdrop.command`(macOS)。启动器会在首次运行时自动安装拖放组件,电脑需要 Python 3 和 Git。 diff --git a/gitdrop/git_sync.py b/gitdrop/git_sync.py index ad74ead..5957d5a 100644 --- a/gitdrop/git_sync.py +++ b/gitdrop/git_sync.py @@ -7,6 +7,8 @@ import subprocess import sys import time +import urllib.parse +import urllib.request from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -24,6 +26,65 @@ CACHE_DIR = APP_DIR / ".gitdrop-cache" +def _normalise_proxy_url(proxy: str | None) -> str | None: + if not proxy: + return None + + proxy = proxy.strip() + if not proxy: + return None + + if "://" not in proxy: + proxy = f"http://{proxy}" + + return proxy + + +def _detect_proxy() -> str | None: + """Return the proxy GitDrop should pass to Git.""" + environment_names = ( + "GITDROP_HTTPS_PROXY", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", + ) + + for name in environment_names: + proxy = _normalise_proxy_url(os.environ.get(name)) + if proxy: + return proxy + + if platform.system() != "Darwin": + return None + + try: + if urllib.request.proxy_bypass("github.com"): + return None + proxies = urllib.request.getproxies() + except (OSError, ValueError): + return None + + return _normalise_proxy_url( + proxies.get("https") or proxies.get("http") or proxies.get("all") + ) + + +def _proxy_display_name(proxy: str) -> str: + """Return a diagnostic-safe proxy URL without credentials or path data.""" + try: + parsed = urllib.parse.urlsplit(proxy) + if not parsed.hostname: + return "已配置代理" + host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + port = f":{parsed.port}" if parsed.port is not None else "" + return f"{parsed.scheme}://{host}{port}" + except ValueError: + return "已配置代理" + + class GitSyncError(RuntimeError): pass @@ -42,12 +103,18 @@ def __init__( repository: str, branch: str = "main", cache_dir: Path | None = None, + proxy_url: str | None = None, ): self.token = token.strip() self.owner = owner.strip() self.repository = repository.strip() self.branch = branch.strip() or "main" self.cache_dir = cache_dir or CACHE_DIR + self.proxy_url = ( + _normalise_proxy_url(proxy_url) + if proxy_url is not None + else _detect_proxy() + ) @property def repository_url(self) -> str: @@ -66,6 +133,15 @@ def _environment(self) -> dict[str, str]: "GITDROP_TOKEN": self.token, } ) + if self.proxy_url: + environment.update( + { + "HTTPS_PROXY": self.proxy_url, + "https_proxy": self.proxy_url, + "HTTP_PROXY": self.proxy_url, + "http_proxy": self.proxy_url, + } + ) return environment def _askpass_path(self) -> Path: @@ -99,7 +175,10 @@ def remove_readonly(function, path, _error) -> None: time.sleep(0.15) def _run(self, arguments: list[str], cwd: Path | None = None, timeout: int = 180) -> str: - command = ["git", *arguments] + command = ["git"] + if self.proxy_url: + command.extend(["-c", f"http.proxy={self.proxy_url}"]) + command.extend(arguments) try: result = subprocess.run( command, @@ -117,8 +196,28 @@ def _run(self, arguments: list[str], cwd: Path | None = None, timeout: int = 180 raise GitSyncError("连接 GitHub 超时,请检查网络后重试") from exc if result.returncode != 0: detail = (result.stderr or result.stdout).strip() + if self.token: + detail = detail.replace(self.token, "[REDACTED]") if "Authentication failed" in detail or "could not read Password" in detail: detail = "Token 无效或没有仓库写入权限" + network_markers = ( + "SSL_ERROR_SYSCALL", + "Connection reset by peer", + "Recv failure", + "Could not resolve host", + "Failed to connect", + "Connection timed out", + ) + if any(marker.lower() in detail.lower() for marker in network_markers): + proxy_hint = ( + f"当前检测到代理:{_proxy_display_name(self.proxy_url)}" + if self.proxy_url + else "未检测到可供 Git 使用的代理" + ) + detail = ( + f"{detail}\n\n{proxy_hint}。\n" + "请检查系统代理、VPN/代理节点,以及 github.com 是否命中代理规则。" + ) raise GitSyncError(detail or f"Git 命令执行失败:{' '.join(arguments[:2])}") return result.stdout.strip() diff --git a/tests/test_git_sync_proxy.py b/tests/test_git_sync_proxy.py new file mode 100644 index 0000000..9394032 --- /dev/null +++ b/tests/test_git_sync_proxy.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import os +import subprocess +import unittest +from unittest.mock import patch + +from gitdrop.git_sync import ( + GitSyncError, + LocalGitTransport, + _detect_proxy, + _normalise_proxy_url, +) + + +class ProxyDetectionTests(unittest.TestCase): + def test_normalises_proxy_without_scheme(self): + self.assertEqual( + _normalise_proxy_url(" 127.0.0.1:1082 "), + "http://127.0.0.1:1082", + ) + + def test_explicit_gitdrop_proxy_has_highest_priority(self): + environment = { + "GITDROP_HTTPS_PROXY": "http://127.0.0.1:1082", + "HTTPS_PROXY": "http://127.0.0.1:7890", + } + with patch.dict(os.environ, environment, clear=True): + self.assertEqual(_detect_proxy(), "http://127.0.0.1:1082") + + def test_standard_proxy_precedence(self): + environment = { + "ALL_PROXY": "socks5h://127.0.0.1:7891", + "HTTP_PROXY": "http://127.0.0.1:7890", + } + with patch.dict(os.environ, environment, clear=True): + self.assertEqual(_detect_proxy(), "socks5h://127.0.0.1:7891") + + @patch("gitdrop.git_sync.platform.system", return_value="Darwin") + @patch("gitdrop.git_sync.urllib.request.proxy_bypass", return_value=False) + @patch( + "gitdrop.git_sync.urllib.request.getproxies", + return_value={"https": "http://127.0.0.1:1082"}, + ) + def test_macos_system_proxy_is_detected( + self, + _getproxies, + _proxy_bypass, + _system, + ): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(_detect_proxy(), "http://127.0.0.1:1082") + + @patch("gitdrop.git_sync.platform.system", return_value="Darwin") + @patch("gitdrop.git_sync.urllib.request.proxy_bypass", return_value=False) + @patch( + "gitdrop.git_sync.urllib.request.getproxies", + return_value={"http": "127.0.0.1:1082"}, + ) + def test_macos_http_proxy_is_fallback( + self, + _getproxies, + _proxy_bypass, + _system, + ): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(_detect_proxy(), "http://127.0.0.1:1082") + + @patch("gitdrop.git_sync.platform.system", return_value="Darwin") + @patch("gitdrop.git_sync.urllib.request.proxy_bypass", return_value=True) + def test_proxy_bypass_is_respected(self, _proxy_bypass, _system): + with patch.dict(os.environ, {}, clear=True): + self.assertIsNone(_detect_proxy()) + + @patch("gitdrop.git_sync.platform.system", return_value="Linux") + @patch("gitdrop.git_sync.urllib.request.getproxies") + def test_non_macos_does_not_read_system_proxy(self, getproxies, _system): + with patch.dict(os.environ, {}, clear=True): + self.assertIsNone(_detect_proxy()) + getproxies.assert_not_called() + + +class GitProxyPropagationTests(unittest.TestCase): + def test_no_proxy_preserves_original_command_shape(self): + transport = LocalGitTransport( + "secret-token", + "owner", + "repository", + proxy_url="", + ) + completed = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="ok\n", + stderr="", + ) + + with ( + patch.dict(os.environ, {}, clear=True), + patch("gitdrop.git_sync.subprocess.run", return_value=completed) as run, + ): + self.assertEqual(transport._run(["status"]), "ok") + + command = run.call_args.args[0] + environment = run.call_args.kwargs["env"] + self.assertEqual(command, ["git", "status"]) + self.assertNotIn("secret-token", command) + self.assertNotIn("HTTPS_PROXY", environment) + self.assertEqual(environment["GITDROP_TOKEN"], "secret-token") + + def test_proxy_is_passed_to_command_and_environment(self): + proxy = "http://127.0.0.1:1082" + transport = LocalGitTransport( + "secret-token", + "owner", + "repository", + proxy_url=proxy, + ) + completed = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="ok\n", + stderr="", + ) + + with patch("gitdrop.git_sync.subprocess.run", return_value=completed) as run: + transport._run(["clone", "remote", "local"]) + + command = run.call_args.args[0] + environment = run.call_args.kwargs["env"] + self.assertEqual( + command, + ["git", "-c", f"http.proxy={proxy}", "clone", "remote", "local"], + ) + for name in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"): + self.assertEqual(environment[name], proxy) + self.assertNotIn("secret-token", command) + + def test_network_error_has_proxy_hint_without_credentials(self): + proxy = "http://proxy-user:proxy-password@127.0.0.1:1082/private" + transport = LocalGitTransport( + "secret-token", + "owner", + "repository", + proxy_url=proxy, + ) + completed = subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + stderr="Recv failure: Connection reset by peer", + ) + + with patch("gitdrop.git_sync.subprocess.run", return_value=completed): + with self.assertRaises(GitSyncError) as raised: + transport._run(["push"]) + + detail = str(raised.exception) + self.assertIn("当前检测到代理:http://127.0.0.1:1082", detail) + self.assertNotIn("proxy-user", detail) + self.assertNotIn("proxy-password", detail) + self.assertNotIn("secret-token", detail) + + def test_token_is_redacted_from_git_errors(self): + transport = LocalGitTransport( + "secret-token", + "owner", + "repository", + proxy_url="", + ) + completed = subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + stderr="unexpected output containing secret-token", + ) + + with patch("gitdrop.git_sync.subprocess.run", return_value=completed): + with self.assertRaises(GitSyncError) as raised: + transport._run(["status"]) + + detail = str(raised.exception) + self.assertIn("[REDACTED]", detail) + self.assertNotIn("secret-token", detail) + + +if __name__ == "__main__": + unittest.main() From 48d5220ef96207eaecd9555d2b72276b8b2fbe03 Mon Sep 17 00:00:00 2001 From: JavisZhangSir-BYR <84219624+JavisZhangSir-BYR@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:15:46 +0800 Subject: [PATCH 2/2] Address proxy credential review feedback --- .github/workflows/release.yml | 3 ++ .github/workflows/tests.yml | 23 ++++++++++++++ gitdrop/git_sync.py | 47 ++++++++++++++++++++++++++++- tests/__init__.py | 1 + tests/test_git_sync_proxy.py | 56 ++++++++++++++++++++++++++++++++++- 5 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/__init__.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15d6136..418b0d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,9 @@ jobs: with: python-version: "3.12" + - name: Run tests + run: python -m unittest discover -v + - name: Install build dependencies run: python -m pip install -r requirements.txt pyinstaller diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e6d5b0f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run tests + run: python -m unittest discover -v diff --git a/gitdrop/git_sync.py b/gitdrop/git_sync.py index 5957d5a..2fe5f93 100644 --- a/gitdrop/git_sync.py +++ b/gitdrop/git_sync.py @@ -85,6 +85,50 @@ def _proxy_display_name(proxy: str) -> str: return "已配置代理" +def _proxy_has_credentials(proxy: str) -> bool: + """Return whether a proxy URL contains user information.""" + try: + parsed = urllib.parse.urlsplit(proxy) + return parsed.username is not None or parsed.password is not None + except ValueError: + return "@" in proxy + + +def _redact_proxy_credentials(detail: str, proxy: str | None) -> str: + """Remove proxy credentials and their encoded forms from Git output.""" + if not proxy or not _proxy_has_credentials(proxy): + return detail + + try: + parsed = urllib.parse.urlsplit(proxy) + except ValueError: + parsed = None + + sensitive_values = {proxy, urllib.parse.unquote(proxy)} + if parsed is not None: + if "@" in parsed.netloc: + user_info = parsed.netloc.rsplit("@", 1)[0] + sensitive_values.update((user_info, urllib.parse.unquote(user_info))) + for credential in (parsed.username, parsed.password): + if credential: + sensitive_values.update( + (credential, urllib.parse.unquote(credential)) + ) + + for value in tuple(sensitive_values): + sensitive_values.update( + ( + urllib.parse.quote(value, safe=""), + urllib.parse.quote_plus(value, safe=""), + ) + ) + + for value in sorted(filter(None, sensitive_values), key=len, reverse=True): + detail = detail.replace(value, "[REDACTED_PROXY_CREDENTIALS]") + + return detail + + class GitSyncError(RuntimeError): pass @@ -176,7 +220,7 @@ def remove_readonly(function, path, _error) -> None: def _run(self, arguments: list[str], cwd: Path | None = None, timeout: int = 180) -> str: command = ["git"] - if self.proxy_url: + if self.proxy_url and not _proxy_has_credentials(self.proxy_url): command.extend(["-c", f"http.proxy={self.proxy_url}"]) command.extend(arguments) try: @@ -196,6 +240,7 @@ def _run(self, arguments: list[str], cwd: Path | None = None, timeout: int = 180 raise GitSyncError("连接 GitHub 超时,请检查网络后重试") from exc if result.returncode != 0: detail = (result.stderr or result.stdout).strip() + detail = _redact_proxy_credentials(detail, self.proxy_url) if self.token: detail = detail.replace(self.token, "[REDACTED]") if "Authentication failed" in detail or "could not read Password" in detail: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..4451bb8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""GitDrop test suite.""" diff --git a/tests/test_git_sync_proxy.py b/tests/test_git_sync_proxy.py index 9394032..8716c87 100644 --- a/tests/test_git_sync_proxy.py +++ b/tests/test_git_sync_proxy.py @@ -3,6 +3,7 @@ import os import subprocess import unittest +import urllib.parse from unittest.mock import patch from gitdrop.git_sync import ( @@ -136,6 +137,31 @@ def test_proxy_is_passed_to_command_and_environment(self): self.assertEqual(environment[name], proxy) self.assertNotIn("secret-token", command) + def test_credentialed_proxy_is_only_passed_in_environment(self): + proxy = "http://proxy-user:proxy-password@127.0.0.1:1082" + transport = LocalGitTransport( + "secret-token", + "owner", + "repository", + proxy_url=proxy, + ) + completed = subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="ok\n", + stderr="", + ) + + with patch("gitdrop.git_sync.subprocess.run", return_value=completed) as run: + transport._run(["clone", "remote", "local"]) + + command = run.call_args.args[0] + environment = run.call_args.kwargs["env"] + self.assertEqual(command, ["git", "clone", "remote", "local"]) + self.assertNotIn(proxy, command) + for name in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"): + self.assertEqual(environment[name], proxy) + def test_network_error_has_proxy_hint_without_credentials(self): proxy = "http://proxy-user:proxy-password@127.0.0.1:1082/private" transport = LocalGitTransport( @@ -148,7 +174,7 @@ def test_network_error_has_proxy_hint_without_credentials(self): args=[], returncode=1, stdout="", - stderr="Recv failure: Connection reset by peer", + stderr=f"Recv failure through {proxy}: Connection reset by peer", ) with patch("gitdrop.git_sync.subprocess.run", return_value=completed): @@ -157,10 +183,38 @@ def test_network_error_has_proxy_hint_without_credentials(self): detail = str(raised.exception) self.assertIn("当前检测到代理:http://127.0.0.1:1082", detail) + self.assertNotIn(proxy, detail) self.assertNotIn("proxy-user", detail) self.assertNotIn("proxy-password", detail) self.assertNotIn("secret-token", detail) + def test_url_encoded_proxy_credentials_are_redacted_from_git_errors(self): + proxy = "http://proxy-user:p%40ssword@127.0.0.1:1082" + encoded_proxy = urllib.parse.quote(proxy, safe="") + transport = LocalGitTransport( + "secret-token", + "owner", + "repository", + proxy_url=proxy, + ) + completed = subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="", + stderr=f"Failed to connect using {encoded_proxy}", + ) + + with patch("gitdrop.git_sync.subprocess.run", return_value=completed): + with self.assertRaises(GitSyncError) as raised: + transport._run(["push"]) + + detail = str(raised.exception) + self.assertIn("[REDACTED_PROXY_CREDENTIALS]", detail) + self.assertNotIn(encoded_proxy, detail) + self.assertNotIn("proxy-user", detail) + self.assertNotIn("p%40ssword", detail) + self.assertNotIn("p@ssword", detail) + def test_token_is_redacted_from_git_errors(self): transport = LocalGitTransport( "secret-token",