diff --git a/cc-catalog-svc/app/git_http/server.py b/cc-catalog-svc/app/git_http/server.py index 62a3bb71e8b..2dc8c834d27 100644 --- a/cc-catalog-svc/app/git_http/server.py +++ b/cc-catalog-svc/app/git_http/server.py @@ -50,12 +50,22 @@ # Only ``info/refs`` and the two smart-HTTP service endpoints reach the # git CGI. Anything else returns 404 so we don't accidentally expose # raw refs / loose objects under unauthenticated HTTP. +# +# ``.git`` is optional in the URL path. GitHub/GitLab accept clone URLs +# with or without the suffix; platform consumers (sobow gitget) often +# normalize to the bare form before ``git ls-remote``. Bare repos on disk +# are still ``.git`` — we rewrite PATH_INFO before http-backend. _PATH_RE = re.compile( - r"^/(?P[A-Za-z0-9][A-Za-z0-9._-]{0,199})\.git" + r"^/(?P[A-Za-z0-9][A-Za-z0-9._-]{0,199})(?:\.git)?" r"(?P/info/refs|/git-upload-pack|/git-receive-pack|/HEAD)$" ) +def _canonical_path_info(slug: str, rest: str) -> str: + """Return PATH_INFO with ``.git`` for ``git http-backend``.""" + return f"/{slug}.git{rest}" + + def is_valid_slug(slug: str) -> bool: """Return True if ``slug`` is safe to join onto ``data_dir``.""" return bool(slug) and bool(_VALID_SLUG_RE.match(slug)) @@ -208,6 +218,11 @@ def app(environ, start_response): return _send_not_found(start_response, "not a smart git HTTP path") slug = match.group("slug") + if slug.endswith(".git"): + slug = slug[:-4] + if not is_valid_slug(slug): + return _send_not_found(start_response, "not a smart git HTTP path") + if allowed is not None and slug not in allowed: logger.info("git HTTP: 404 for unallowed slug %r", slug) return _send_not_found(start_response, "unknown git mirror") @@ -215,7 +230,10 @@ def app(environ, start_response): if not repo_exists(data_dir, slug): return _send_not_found(start_response, "git mirror not found on disk") - cmd_env = _git_http_backend_environ(data_dir, environ) + # git http-backend expects PATH_INFO like /{slug}.git/info/refs. + backend_environ = dict(environ) + backend_environ["PATH_INFO"] = _canonical_path_info(slug, match.group("rest")) + cmd_env = _git_http_backend_environ(data_dir, backend_environ) body_in = b"" if environ.get("REQUEST_METHOD") in ("POST", "PUT", "PATCH"): body_in = environ["wsgi.input"].read() diff --git a/cc-catalog-svc/tests/test_git_http.py b/cc-catalog-svc/tests/test_git_http.py index 62641448924..903e74b6027 100644 --- a/cc-catalog-svc/tests/test_git_http.py +++ b/cc-catalog-svc/tests/test_git_http.py @@ -133,6 +133,48 @@ def test_info_refs_via_a2wsgi_mount(tmp_path): assert b"refs/heads/" in resp.content +def test_info_refs_without_dot_git_suffix(tmp_path): + """Platform gitget calls ls-remote with URLs that omit the ``.git`` suffix.""" + bare = repo_bare_path(str(tmp_path), "demo-cc") + _init_bare_repo(bare) + + api = FastAPI() + api.mount("/git", WSGIMiddleware(make_git_wsgi_app(str(tmp_path)))) + + with TestClient(api) as client: + resp = client.get( + "/git/demo-cc/info/refs", + params={"service": "git-upload-pack"}, + headers={"Accept": "*/*"}, + ) + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("application/x-git-upload-pack-advertisement") + assert b"refs/heads/" in resp.content + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git binary required") +def test_ls_remote_without_dot_git_suffix(tmp_path): + bare = repo_bare_path(str(tmp_path), "demo-cc") + _init_bare_repo(bare) + + _, port = _mount_test_server(str(tmp_path)) + result = subprocess.run( + [ + "git", + "ls-remote", + "--heads", + "--tags", + f"http://127.0.0.1:{port}/git/demo-cc", + ], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "refs/heads/" in result.stdout + + @pytest.mark.skipif(shutil.which("git") is None, reason="git binary required") def test_git_clone_via_a2wsgi_mount(tmp_path): bare = repo_bare_path(str(tmp_path), "many-branches")