Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions cc-catalog-svc/app/git_http/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<slug>.git`` — we rewrite PATH_INFO before http-backend.
_PATH_RE = re.compile(
r"^/(?P<slug>[A-Za-z0-9][A-Za-z0-9._-]{0,199})\.git"
r"^/(?P<slug>[A-Za-z0-9][A-Za-z0-9._-]{0,199})(?:\.git)?"
r"(?P<rest>/info/refs|/git-upload-pack|/git-receive-pack|/HEAD)$"
)


def _canonical_path_info(slug: str, rest: str) -> str:
"""Return PATH_INFO with ``<slug>.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))
Expand Down Expand Up @@ -208,14 +218,22 @@ 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")

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()
Expand Down
42 changes: 42 additions & 0 deletions cc-catalog-svc/tests/test_git_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading