"""项目级仓库解析:把项目的 repo_url 变成 worker 可派发的本地检出。 零第三方依赖,只用 Python 标准库;不改 host.py 的 worktree 逻辑, 不存任何仓库凭证、不推远端。 - validate_repo_url:API(POST /projects)与 worker 共用的同一套 校验,绝不另写第二份校验逻辑让两者漂移。只接受: https:// 开头(且不含 userinfo,即不含用户名/密码),或 git@ 开头(git@host:path 形)。file://、ssh://、本地路径、 带密码的 URL 一律拒绝。 - checkout_dir:按 project_id 的安全字符命名,防路径穿越。 - resolve_repo:目录不存在时 git clone;已存在时校验它是 git 仓库且 remote origin 与 repo_url 一致(不一致时报错,绝不强制 改 remote),一致时 git fetch origin(失败明确报错,不静默用 旧代码);fetch 成功后不自动 checkout/pull、不 git reset/clean, 工作树脏不脏由 host.py 的 worktree 逻辑处理。 - 私有仓库 clone 失败时错误文本写明:需要仓库访问凭证,控制面 不存仓库凭证——绝不尝试用任何 key 去 clone。 - check_repo_access:建项目/建任务前的快速预检,只跑 `git ls-remote HEAD`,不 clone、不写盘、不改库; 失败时返回 (False, 原因),成功时返回 (True, HEAD 哈希)。 本机 https 到 GitHub 常被出口策略挡、ssh 才通(2026-10-30 实测),预检失败的 https 私有仓应先改走 git@ 形再测, 不要等任务派发后在 worker 里花几分钟超时才发现。 CLI:`python3 -m cloud.repos check `,退出码 0/1, 只打印结果与原因,绝不打印任何凭证。 """ import os import re import subprocess import sys from urllib.parse import urlparse __all__ = [ "RepoError", "validate_repo_url", "checkout_dir", "resolve_repo", "check_repo_access", ] class RepoError(ValueError): """仓库解析失败(校验、clone、remote 不一致、fetch 失败)。""" _GIT_SCP_RE = re.compile(r"^git@[^/:@\s]+:[^@\s]+$") _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]") def validate_repo_url(repo_url): """校验 repo_url,合法时返回原值,否则抛 ValueError。 拒绝:非字符串、空串、含空白/控制字符、非 https:// 与 git@ 开头、 https:// 带 userinfo(username 或 password 任一存在即拒绝, 含 https://user@host/ 这种只有用户名无密码的也拒绝,因为 userinfo 本身就是凭证载体)、https:// 无 host、git@ 形不合规。 """ if not isinstance(repo_url, str): raise ValueError("repo_url 必须是非空字符串") # 含空白/控制字符的 URL 直接拒绝,不静默 strip:strip 会把 # “https://…\n”这种畸形输入洗成合法 URL 存进库,git 调用时 # 再出难以定位的问题;调用方应传干净的 URL。 if not repo_url or any(ch.isspace() for ch in repo_url): raise ValueError("repo_url 必须是非空字符串且不得含空白字符") url = repo_url if url.startswith("https://"): parsed = urlparse(url) if parsed.username is not None or parsed.password is not None: raise ValueError( "repo_url 不得含 userinfo(用户名/密码): %s" % url) if not parsed.hostname: raise ValueError("repo_url 缺少 host: %s" % url) return url if url.startswith("git@"): if not _GIT_SCP_RE.match(url): raise ValueError("git@ 形 repo_url 不合法: %s" % url) return url raise ValueError( "repo_url 只接受 https:// 或 git@ 开头," "不接受 file://、ssh://、本地路径: %s" % url) def checkout_dir(repos_dir, project_id): """按 project_id 的安全字符命名检出目录,防路径穿越。 project_id 本身是 hex,仍做安全字符过滤;过滤后为空时报错。 返回的绝对路径一定在 repos_dir 之下,否则报错(防御性双检)。 """ raw = str(project_id) safe = _SAFE_NAME_RE.sub("-", raw).strip(".") if not safe or safe in (".", ".."): raise RepoError("project_id 无法映射为安全的检出目录名: %r" % raw) base = os.path.abspath(repos_dir) path = os.path.join(base, safe) if os.path.commonpath([base, path]) != base: raise RepoError("检出目录越出 repos_dir,拒绝: %s" % path) return path def _default_runner(cmd, timeout=None): return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) def _run(runner, cmd): """调 runner:优先只传 cmd,假 runner 只接受 cmd 时即走此路; 接受 (cmd, timeout) 的 runner 也兼容(TypeError 时回退)。""" try: return runner(cmd) except TypeError: return runner(cmd, timeout=None) def resolve_repo(repo_url, repos_dir, project_id, runner=None): """把 repo_url 解析为本地检出路径,返回该路径。 目录不存在:git clone repo_url 到该目录;clone 失败抛 RepoError, 文本含“需要仓库访问凭证,控制面不存仓库凭证”的提示(私有仓 库、仓库不存在、无权访问都走同一文案,不猜测、不尝试凭证)。 目录已存在:须是 git 仓库(.git 目录或文件存在)且 remote origin 的 URL 与 repo_url 一致;不是 git 仓库、取不到 origin、 origin 不一致时都抛 RepoError,绝不强制改 remote、不覆盖。 一致时 git fetch origin:失败抛 RepoError,不静默用旧代码; 成功后不 checkout/pull/reset/clean,工作树由 host.py 处理。 """ url = validate_repo_url(repo_url) run = runner if runner is not None else _default_runner target = checkout_dir(repos_dir, project_id) os.makedirs(repos_dir, exist_ok=True) if not os.path.exists(target): result = _run(run, ["git", "clone", url, target]) if getattr(result, "returncode", 1) != 0: err = (getattr(result, "stderr", "") or "").strip() raise RepoError( "git clone 失败: %s;若为私有仓库,需要仓库访问凭证," "控制面不存仓库凭证,请先在可访问该仓库的机器上完成" "检出或改用公开仓库" % (err or url)) return target git_path = os.path.join(target, ".git") if not (os.path.isdir(git_path) or os.path.isfile(git_path)): raise RepoError( "检出目录已存在但不是 git 仓库,拒绝覆盖: %s" % target) result = _run(run, ["git", "-C", target, "remote", "get-url", "origin"]) if getattr(result, "returncode", 1) != 0: raise RepoError( "检出目录 %s 取不到 remote origin,拒绝派发" % target) origin = (getattr(result, "stdout", "") or "").strip() if origin != url: raise RepoError( "检出目录 %s 的 remote origin(%s)与项目 repo_url(%s)" "不一致,拒绝派发(绝不强制改 remote)" % (target, origin, url)) fetched = _run(run, ["git", "-C", target, "fetch", "origin"]) if getattr(fetched, "returncode", 1) != 0: err = (getattr(fetched, "stderr", "") or "").strip() raise RepoError( "git fetch origin 失败,拒绝用旧代码派发: %s" % (err or target)) return target def check_repo_access(repo_url, runner=None, timeout=30): """预检仓库是否可读:只跑 `git ls-remote HEAD`。 返回 (ok, detail):ok 时 detail 为 HEAD 哈希;失败时 detail 为原因(不含凭证)。runner 缺省走真实 git,超时 由 subprocess 的 timeout 控(假 runner 不接受 timeout 时 _run 已回退);runner 抛 TimeoutExpired 时同样返回失败, 不抛异常——预检失败绝不能炸掉调用方。 https 形在主机上失败时 detail 末尾追加 ssh 形提示, 因为本机 https 到 GitHub 常被出口策略挡、ssh 才通。 """ try: url = validate_repo_url(repo_url) except ValueError as exc: return (False, str(exc)) run = runner if runner is not None else _default_runner def _ls_remote(cmd): try: return run(cmd, timeout=timeout) except TypeError: return run(cmd) try: result = _ls_remote(["git", "ls-remote", url, "HEAD"]) except Exception as exc: # noqa: BLE001 - 超时、缺 git 等一律失败 detail = "git ls-remote 失败: %s" % (exc,) if url.startswith("https://"): detail += ";本机 https 到 GitHub 常被出口策略挡," detail += "请改走 git@ 形再测" return (False, detail) if getattr(result, "returncode", 1) != 0: err = (getattr(result, "stderr", "") or "").strip() detail = "git ls-remote 失败: %s" % (err or url) if url.startswith("https://"): detail += ";私有仓库请改走 git@ 形并给 codexsvc 配" detail += "只读 deploy key,控制面不存仓库凭证" return (False, detail) head = (getattr(result, "stdout", "") or "").split() return (True, head[0] if head else "") def _main(argv): if len(argv) != 3 or argv[1] != "check": sys.stderr.write( "用法: python3 -m cloud.repos check \n") return 2 ok, detail = check_repo_access(argv[2]) if ok: print("ok HEAD=%s" % detail) return 0 print("fail: %s" % detail) return 1 if __name__ == "__main__": raise SystemExit(_main(sys.argv))