steid

@jamesgill /

feat: clone a public repository over HTTP

Milestone 4a is done: `git clone http://host/{handle}/repos/{name}.git` works
for anyone, with no credentials.

Three routes, one per endpoint of the smart protocol, each naming its service as
a literal. That is deliberate rather than incidental — handed any other path,
http-backend serves dumb-protocol object files straight off disk, which is a
read of a repository nothing authorized. The router is the allowlist, and the
verification checks that /…​.git/objects/info/packs answers 404.

The response body is an http_body::Body over the subprocess's stdout, and the
BufReader that read the CGI headers is handed back as that body: it still holds
whatever it read past the blank line, which is the first bytes of the pack.
Parsing headers into a separate buffer would have dropped them silently.

Verified against a real client, since none of this is provable by unit test. An
anonymous clone of a 201-ref repository returns 201 commits and 203 refs with
HEAD matching origin, on protocol v2 and v0 — 201 refs because that is where
clients start gzipping the request body, which is the case a one-ref test repo
never reaches. A private repo answers 404 anonymously and 200 to its owner's
browser session. Push is refused with 403. Unknown repo, unknown handle, missing
service, unknown service, and a missing .git suffix all answer 404, and the
repository page still answers 200.

Also recorded: topcoat-router 0.5.0 has no body_limit layer, so the warning
carried since 0001 is stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 8 days agoparent8ac00bdBrowse filese0856eb257e5c7e6c35d3e59557f763f0c241bfc

8 files changed+324 −99

Cargo.lock+4 −0View file
@@ -1889,14 +1889,18 @@ name = "steid"
18891889 version = "0.1.0"
18901890 dependencies = [
18911891 "argon2",
1892+ "bytes",
18921893 "dotenvy",
18931894 "envy",
1895+ "futures-util",
1896+ "http-body",
18941897 "rand 0.10.2",
18951898 "serde",
18961899 "sqlx",
18971900 "subtle",
18981901 "tempfile",
18991902 "tokio",
1903+ "tokio-util",
19001904 "topcoat",
19011905 "uuid",
19021906 ]
Cargo.toml+4 −0View file
@@ -5,13 +5,17 @@ edition = "2024"
55
66 [dependencies]
77 argon2 = "0.5.3"
8+bytes = "1"
89 dotenvy = "0.15.7"
910 envy = "0.4.2"
11+futures-util = { version = "0.3", default-features = false, features = ["std"] }
12+http-body = "1"
1013 rand = "0.10.2"
1114 serde = { version = "1.0.229", features = ["derive"] }
1215 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
1316 subtle = "2.6.1"
1417 tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "process", "fs", "io-util"] }
18+tokio-util = { version = "0.7", features = ["io"] }
1519 topcoat = { version = "0.5.0", features = ["icon-iconify", "tailwind", "ui"] }
1620 uuid = { version = "1.24.0", features = ["v4"] }
1721
plans/ROADMAP.md+2 −2View file
@@ -55,8 +55,8 @@ a baseline.
5555 | 1 | **Identity, thin** — claim on first run, login, session | done |
5656 | 2 | **Profile page** — `/{handle}` as the real profile | done |
5757 | 3 | **Repo model** — records + bare repos on disk | done |
58| 4a | **Clone over HTTP** — `git http-backend`, public repos, no auth | active |
59| 4b | **Push and tokens** — PATs over HTTP Basic, push, private clone | not started |
58+| 4a | **Clone over HTTP** — `git http-backend`, public repos, no auth | done |
59+| 4b | **Push and tokens** — PATs over HTTP Basic, push, private clone | active |
6060 | 5 | **Repo browsing** — tree, blob, commit log | not started |
6161 | 6 | **Writing** — posts, markdown | not started |
6262 | 7 | **Identity, full** — multi-user, orgs, invites, registration policy | not started |
plans/current.md+47 −95View file
@@ -4,110 +4,66 @@
44 > [progress.md](progress.md). If this file starts reading like a changelog, it has
55 > drifted — that's exactly what went wrong last time.
66
7## Active: Milestone 4a — Clone over HTTP
7+## Active: Milestone 4b — Push and tokens
88
9**Goal:** `git clone https://host/{handle}/repos/{name}.git` works against a public
10repository, for anyone, with no credentials. The protocol is delegated to `git
11http-backend` per [0001](decisions/0001-git-over-http-not-ssh.md).
9+**Goal:** `git push` works over HTTP for someone who may write, and a private repository
10+is clonable by someone who may read it. Authentication is personal access tokens over
11+HTTP Basic, per [0001](decisions/0001-git-over-http-not-ssh.md).
1212
13**Out of scope:** personal access tokens, HTTP Basic, push, cloning a private repo —
14all of that is 4b. Also out: browsing a tree in the UI (Milestone 5), and any repo
15statistic the clone path could tempt us into computing.
13+**Out of scope:** SSH, token scopes beyond whatever the Open questions settle, OAuth,
14+and anything to do with browsing a tree (Milestone 5).
1615
1716 ### Steps
1817
19- [ ] Probe `git http-backend`'s actual contract — which CGI variables it reads, how it
20 reports failure, what it does with an unauthorised path. Findings to
21 `progress.md`; no application code in this step.
22- [x] Application: `GitProtocolServer` port — a CGI-shaped request/response pair, plus
23 `GitOperation` (Read/Write) as the thing authorization is decided on
24- [x] Infrastructure: `GitHttpBackend` adapter, spawning through the existing `run_git`
25 invoker; streams stdin in and stdout out, parsing CGI headers off the front
26- [x] Application: `serve_git` use case — resolves the repository, enforces visibility,
27 refuses writes outright, and only then delegates
28- [ ] Web: the three git routes under `/{handle}/repos/{name}.git/`
29- [ ] Verify with a real `git clone` of a repo with enough refs to trigger a gzipped
30 request body
18+Provisional below the first two — the rest depend on the Open decisions.
19+
20+- [ ] Domain: `PersonalAccessToken`, `TokenId`, `TokenHash`, and the repository port
21+- [ ] Infrastructure: in-memory + SQLite implementations, migration
22+- [ ] Application: `issue_token`, `list_tokens`, `revoke_token`
23+- [ ] Application: `authenticate_token` — resolves a Basic credential into an `Actor`
24+- [ ] Web: HTTP Basic on the git routes, and the 401 challenge that makes a client
25+ send credentials at all
26+- [ ] Application: let `serve_git` authorize writes rather than refusing them
27+- [ ] Web: token management UI under `/{handle}/settings`
28+- [ ] Verify: push to a public repo, clone a private one, and check a revoked token
29+ stops working
3130
3231 ### Done when
3332
34`git clone http://127.0.0.1:3000/{handle}/repos/{name}.git` produces a working
35checkout of a public repository, with no credentials, and the cloned history matches
36the origin. A private repository is not clonable by anyone yet — not even its owner.
37`git push` is refused.
38
39### Settled
40
41- **`git http-backend`, not direct `--stateless-rpc`.** Both put identical bytes on the
42 wire for a modern clone of a small repo; the difference is entirely in the tail, which
43 is what wide adoption means. Measured before choosing: a client cloning a repo with
44 201 refs **gzip-compresses the POST body** (5KB here), on protocol v0 *and* v2. A
45 direct implementation must therefore inflate request bodies and forward
46 `Git-Protocol` itself, and gets neither the dumb-protocol fallback nor the header set.
47 The failure mode decided it — a direct implementation passes against a one-ref test
48 repo and breaks on the first real one.
49- **The clone URL is `/{handle}/repos/{name}.git`**, matching the page at
50 `/{handle}/repos/{name}`. Scoped rather than root-level, per
51 [0003](decisions/0003-scoped-urls.md); the `.git` suffix separates protocol from page.
52- **Only the three known endpoints are routed** — `info/refs`, `git-upload-pack`,
53 `git-receive-pack`. `http-backend` will otherwise serve dumb-protocol object files
54 under any path handed to it, which would be a read of a repository nothing
55 authorized. The router is the allowlist.
56- **Authorization is decided before the subprocess is spawned**, from the service name
57 in the request, not from anything `http-backend` reports back. By the time git is
58 running it is too late to refuse.
59- **Bodies stream in both directions, rather than buffering.** A pack is arbitrarily
60 large, and buffering would bound a clone by RAM instead of by disk;
61 [0001](decisions/0001-git-over-http-not-ssh.md) named streaming as the thing that made
62 this transport viable in the first place. The port therefore carries
63 `Pin<Box<dyn AsyncRead + Send>>` in both directions. **No new crate**: this needs only
64 tokio's `io-util` feature, and the `bytes` / `http-body-util` / `tokio-util` the web
65 layer will use to bridge Topcoat's `Body` are already in `Cargo.lock` as transitive
66 dependencies, so nothing new enters the build.
67- **`run_git` was split into `git_command`.** The old invoker buffers with `output()`,
68 which the protocol cannot use. Rather than a second recipe — the thing
69 [0006](decisions/0006-git-binary-behind-narrow-ports.md) exists to prevent — the
70 isolation moved into a shared builder both call sites start from.
71- **`body_limit` turns out not to exist** in `topcoat-router` 0.5.0. Bodies are read by
72 the handler via `to_bytes(body, limit)` with a caller-chosen limit, so there is no
73 layer to raise — and the git routes take the body unbuffered anyway. The warning
74 carried from [0001](decisions/0001-git-over-http-not-ssh.md) is stale.
75- **The endpoint is named by the route, not parsed from the path.** Three routes, three
76 literal `GitEndpoint` values. The use case then builds `path_info` itself from the
77 validated handle and name, so the string that decides authorization and the string
78 handed to git are the same string, and a URL cannot be coaxed into meaning a different
79 operation than the one that was checked.
80- **Existence is settled before permission.** A push to a repository the actor cannot
81 see answers `None` (404), not `Forbidden` — a 403 would confirm a private repository
82 by that name exists.
83- **Milestone 4 was split.** See [ROADMAP.md](ROADMAP.md#why-this-order).
33+A token issued through the UI lets `git push` succeed against a repository its owner may
34+write, and lets `git clone` succeed against a private repository its owner may read.
35+Revoking the token stops both. An anonymous clone of a public repository still works
36+exactly as it does today.
8437
8538 ### Open
8639
87- **What a private repository answers to an anonymous clone.** 4a has no credentials at
88 all, so 404 is the only honest answer and matches `view_repo`'s "absent, not
89 forbidden" rule. But git only sends credentials *after* a 401, so 4b will need a 401
90 with `WWW-Authenticate` on exactly the case that 404s today — which leaks that the
91 repository exists. Gitea and GitHub both accept that leak. Decide it in 4b, with the
92 tension recorded rather than rediscovered.
40+These are decisions, not unknowns — each needs an answer before the step that depends on
41+it.
42+
43+- **How tokens are hashed.** Sessions already hash their token with SHA-256
44+ (`SessionTokenHash`), which suits a high-entropy random value; Argon2 would be the
45+ password answer and is far too slow for something presented on every git request, of
46+ which a single clone makes several. Recommendation: copy the session approach, store a
47+ display prefix alongside so the UI can name a token without holding it.
48+- **Whether tokens carry scopes.** Personal-first says no: a token acts as its user.
49+ Scopes are the kind of thing that is cheap to add later behind an unchanged port and
50+ expensive to design against no requirement.
51+- **401 versus 404 for a private repository.** Carried from 4a and now decidable. A git
52+ client only sends credentials *after* a 401, so answering 404 to an anonymous request
53+ for a private repository — which is what 4a does, and what `view_repo` does — makes
54+ authenticated private clone impossible. Answering 401 leaks that the repository
55+ exists. Gitea and GitHub both accept that leak. This is the one with a real cost
56+ either way.
9357
94### Watch for
58+### Carried over — small, unblocked
9559
96- **CGI header parsing sits in front of a stream.** `http-backend` writes headers, a
97 blank line, then the body. Reading the headers must not buffer the body — that is the
98 whole reason this transport was judged viable on `Body::into_data_stream`.
9960 - **A client that disappears mid-request leaves the body-copy task waiting.** The copy
100 into git's stdin runs in its own task; nothing cancels it if the connection drops.
61+ into git's stdin runs in its own task and nothing cancels it if the connection drops.
10162 Bounded by the backend exiting and closing the pipe, but not by anything deliberate.
102- **A subprocess per request**, unlike Milestone 3's once-per-creation. Fork/exec cost
103 now sits on a hot path; measure before assuming it is fine.
104- **`http-backend` reports failure through CGI status lines**, not exit codes alone. A
105 non-zero exit and a `404 Not Found` on stdout mean different things.
106- **The advertisement must not be cached.** `Cache-Control: no-cache` on `info/refs`, or
107 clients fetch a stale ref list and fail to find commits that exist.
108
109### Carried over — small, unblocked
110
63+- **A subprocess per git request.** Unlike Milestone 3's once-per-creation, this is on a
64+ hot path and has not been measured. Milestone 5 is where that bill comes due.
65+- **Streaming is by construction, not by measurement.** The response body is never
66+ collected, but no clone large enough to prove it has been run.
11167 - **An orphaned repo directory is possible** if the process dies between the record
11268 write and the filesystem write, and it then blocks re-creating that name. The durable
11369 fix is a reconciliation sweep on boot
@@ -130,16 +86,12 @@ the origin. A private repository is not clonable by anyone yet — not even its
13086
13187 Ordered. Pull from the top.
13288
1331. **Milestone 4b — Push and tokens.** Personal access tokens over HTTP Basic, `git
134 push`, private clone. Open decisions when it starts: how tokens are hashed (session
135 token hashing already exists to copy), whether tokens carry scopes, and the 401-vs-404
136 tension above.
1372. **Milestone 5 — Repo browsing.** Tree, blob, commit log. **Start with domain value
89+1. **Milestone 5 — Repo browsing.** Tree, blob, commit log. **Start with domain value
13890 objects** — `ObjectId`, `RefName`, `TreeEntry` — before any adapter. A query port
13991 returning `String`s is an anaemic pass-through that pushes validation into the page.
14092 Also the point to measure fork/exec cost per page view, and to reconsider `gix` for
14193 the read path ([0006](decisions/0006-git-binary-behind-narrow-ports.md)).
1423. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
94+2. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
14395 whether writing or projects/showcases is the better first portfolio feature.
14496
14597 ## Open questions
plans/progress.md+40 −1View file
@@ -195,7 +195,19 @@ handle 404s rather than answering `[]`. `git clone` does not work yet — Milest
195195 database, so removing the column would have been a free in-place edit rather than a
196196 second migration.
197197
198### Milestone 4a — Clone over HTTP · in progress
198+### Milestone 4a — Clone over HTTP · done
199+
200+`git clone` works against a public repository, for anyone, with no credentials.
201+`GitProtocolServer` (CGI-shaped) with `GitHttpBackend` behind it, the `serve_git` use
202+case, and three routes under `/{handle}/repos/{name}.git/`. Bodies stream both
203+directions.
204+
205+**Verified against a real client**, not only by unit test: an anonymous `git clone` of a
206+201-ref repository returns 201 commits and 203 refs with `HEAD` matching the origin, on
207+protocol v2 and on v0; a private repository answers 404 to an anonymous clone but 200 to
208+its owner's browser session; `git push` is refused with 403; and unknown repo, unknown
209+handle, missing `service`, an unknown service, a missing `.git` suffix, and a
210+dumb-protocol object path all answer 404 while the repository page still answers 200.
199211
200212 `git http-backend`'s contract, probed against git 2.50.1 by driving the CGI from a
201213 throwaway server and cloning through it. Everything below is measured, not read.
@@ -244,6 +256,33 @@ throwaway server and cloning through it. Everything below is measured, not read.
244256 the log.
245257 - **A missing repository is `Status: 404` with exit 0.** Failure is reported in the
246258 CGI stream, not the exit code, and the two disagree in both directions.
259+- **The router is the allowlist.** Only `info/refs`, `git-upload-pack` and
260+ `git-receive-pack` are routed. Handed any other path, `http-backend` serves
261+ dumb-protocol object files straight off disk — a read of a repository nothing
262+ authorized. Verified: `/…​.git/objects/info/packs` answers 404.
263+- **The endpoint is named by the route, not parsed from the path.** Three routes, three
264+ literal `GitEndpoint` values, and `serve_git` rebuilds `path_info` from the validated
265+ handle and name. The string that decides authorization and the string handed to git
266+ are therefore the same string.
267+- **Existence is settled before permission.** A push to a repository the actor cannot
268+ see answers 404, not 403 — a 403 would confirm a private repository by that name
269+ exists. Costs nothing to get right at the start and is invisible to test later.
270+- **`BufReader` is what makes the header/body split safe.** The reader keeps whatever it
271+ read past the blank line, so handing the reader itself back as the response body
272+ carries the already-buffered first bytes of the pack with it. Parsing headers into a
273+ separate buffer and then streaming the rest would silently drop them.
274+- **The child's exit code cannot gate the response.** A protocol failure exits non-zero
275+ *after* a complete, successful-looking header block has been written. By the time the
276+ status is known it has been sent, so the exit code goes to the log and nowhere else.
277+- **Stderr must be drained, not merely piped.** An unread pipe fills and blocks the
278+ backend mid-transfer. It is read in the same task that reaps the child.
279+- **`body_limit` does not exist** in `topcoat-router` 0.5.0 — the warning carried from
280+ [0001](decisions/0001-git-over-http-not-ssh.md) is stale. Bodies are read by the
281+ handler with a caller-chosen limit via `to_bytes`, and the git routes take `Body`
282+ unbuffered so no limit applies at all.
283+- **`impl<B> IntoResponse for http::Response<B>`** means a handler can return its own
284+ `http_body::Body` and Topcoat re-bodies it. That is what lets the pack stream without
285+ a framework-specific body type.
247286
248287 ---
249288
src/infrastructure/web/context.rs+6 −1View file
@@ -18,7 +18,7 @@ use crate::{
1818 application::{AppConfig, Identity, describe_identity, is_claimed, resolve_actor},
1919 domain::{Actor, SessionTokenHash},
2020 infrastructure::{
21 git::DiskGitStorage,
21+ git::{DiskGitStorage, GitHttpBackend},
2222 repository::{
2323 SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo, SqliteUserRepo,
2424 },
@@ -61,6 +61,11 @@ pub fn storage(cx: &Cx) -> DiskGitStorage {
6161 DiskGitStorage::new(app_context::<AppConfig>(cx).data_dir.clone())
6262 }
6363
64+/// The git smart-HTTP protocol, rooted at the same data directory as [`storage`].
65+pub fn protocol(cx: &Cx) -> GitHttpBackend {
66+ GitHttpBackend::new(app_context::<AppConfig>(cx).data_dir.clone())
67+}
68+
6469 /// Who is making this request.
6570 ///
6671 /// Resolves to [`Actor::Anonymous`] when there is no session, the session is unknown,
src/infrastructure/web/git.rs+220 −0View file
@@ -0,0 +1,220 @@
1+//! The git smart-HTTP transport — `/{handle}/repos/{name}.git/…`.
2+//!
3+//! Three routes, one per endpoint of the protocol, each naming the service it serves as
4+//! a literal. Nothing here parses an operation out of a path: the route *is* the
5+//! operation, and [`serve_git`] rebuilds the path it hands to git from validated values.
6+//! That matters because `git http-backend` will happily serve dumb-protocol object files
7+//! under any path given to it — the router is the allowlist.
8+
9+use std::{
10+ io,
11+ pin::Pin,
12+ task::{Context, Poll},
13+};
14+
15+use bytes::Bytes;
16+use futures_util::TryStreamExt;
17+use http_body::Frame;
18+use serde::Deserialize;
19+use tokio::io::{AsyncRead, ReadBuf};
20+use tokio_util::io::StreamReader;
21+use topcoat::{
22+ Result,
23+ context::Cx,
24+ router::{
25+ Body, Response,
26+ error::{RouterErrorExt, bad_request, forbidden, not_found},
27+ parse_query_params, path_param, route,
28+ },
29+};
30+
31+use crate::{
32+ application::{Error, GitClientHeaders, GitEndpoint, GitService, port::ByteStream, serve_git},
33+ domain::{DomainError, RepoName},
34+};
35+
36+use super::{
37+ context::{current_actor, memberships, orgs, protocol, repos, server_error},
38+ profile::handle_param,
39+};
40+
41+/// How much of the response is read from git in one go.
42+///
43+/// A clone streams as fast as the client takes it, so this bounds the memory a transfer
44+/// holds rather than its speed.
45+const CHUNK: usize = 16 * 1024;
46+
47+/// `{repo}` from the path — the repository name *with* its `.git` suffix.
48+#[path_param]
49+struct Repo(str);
50+
51+#[derive(Debug, Deserialize)]
52+struct ServiceQuery {
53+ service: Option<String>,
54+}
55+
56+/// The repository named by `{repo}`, or 404.
57+///
58+/// The `.git` suffix is required rather than optional: it is what separates the protocol
59+/// from the page at `/{handle}/repos/{name}`, and `RepoName` rejects a name ending in
60+/// `.git` so the two can never collide.
61+fn repo_param(cx: &Cx) -> Result<RepoName> {
62+ let raw = path_param::<Repo>(cx);
63+ let name = raw.strip_suffix(".git").ok_or_else(not_found)?;
64+
65+ Ok(RepoName::new(name).map_err(|_| not_found())?)
66+}
67+
68+/// The four request headers that change what git does.
69+fn client_headers(cx: &Cx) -> GitClientHeaders {
70+ let headers = topcoat::router::headers(cx);
71+ let value = |name: &str| {
72+ headers
73+ .get(name)
74+ .and_then(|value| value.to_str().ok())
75+ .map(str::to_owned)
76+ };
77+
78+ GitClientHeaders {
79+ content_type: value("content-type"),
80+ content_encoding: value("content-encoding"),
81+ content_length: value("content-length"),
82+ git_protocol: value("git-protocol"),
83+ }
84+}
85+
86+/// Runs one protocol request and turns the result into an HTTP response.
87+///
88+/// A repository that does not exist and one the viewer may not see are the same 404,
89+/// deliberately — see [`serve_git`].
90+async fn serve(cx: &Cx, endpoint: GitEndpoint, body: ByteStream) -> Result<Response<GitBody>> {
91+ let handle = handle_param(cx)?;
92+ let name = repo_param(cx)?;
93+ let actor = current_actor(cx).await?;
94+
95+ let served = serve_git(
96+ &handle,
97+ &name,
98+ endpoint,
99+ client_headers(cx),
100+ body,
101+ &actor,
102+ &orgs(cx),
103+ &memberships(cx),
104+ &repos(cx),
105+ &protocol(cx),
106+ )
107+ .await
108+ .map_err(|error| match error {
109+ // Push, until Milestone 4b. Everything else the visitor cannot act on.
110+ Error::Domain(DomainError::Forbidden) => forbidden().into(),
111+ other => server_error(other),
112+ })?
113+ .ok_or_not_found()?;
114+
115+ let mut response = Response::builder().status(served.status);
116+ for (name, value) in served.headers {
117+ response = response.header(name, value);
118+ }
119+
120+ // git sets its own content type and cache headers, and they are forwarded rather
121+ // than reinvented: a cached advertisement makes a client fetch a stale ref list and
122+ // then fail to find commits that do exist.
123+ response
124+ .body(GitBody::new(served.body))
125+ .map_err(server_error)
126+}
127+
128+/// The ref advertisement that opens every exchange.
129+///
130+/// The service names the operation, so `service=git-receive-pack` is a write and is
131+/// refused here, before a client has been told a single ref exists.
132+#[route(GET "/{handle}/repos/{repo}/info/refs")]
133+async fn info_refs(cx: &Cx) -> Result<Response<GitBody>> {
134+ let query =
135+ parse_query_params::<ServiceQuery>(cx).map_err(|error| bad_request(error.to_string()))?;
136+
137+ // No service means the dumb protocol, which Steid does not serve. Refusing beats
138+ // guessing: the dumb protocol reads object files straight off disk.
139+ let service = query.service.ok_or_else(not_found)?;
140+ let service: GitService = service.parse().map_err(|_| not_found())?;
141+
142+ serve(
143+ cx,
144+ GitEndpoint::Advertisement(service),
145+ Box::pin(tokio::io::empty()),
146+ )
147+ .await
148+}
149+
150+#[route(POST "/{handle}/repos/{repo}/git-upload-pack")]
151+async fn upload_pack(cx: &Cx, body: Body) -> Result<Response<GitBody>> {
152+ serve(
153+ cx,
154+ GitEndpoint::Rpc(GitService::UploadPack),
155+ into_reader(body),
156+ )
157+ .await
158+}
159+
160+#[route(POST "/{handle}/repos/{repo}/git-receive-pack")]
161+async fn receive_pack(cx: &Cx, body: Body) -> Result<Response<GitBody>> {
162+ serve(
163+ cx,
164+ GitEndpoint::Rpc(GitService::ReceivePack),
165+ into_reader(body),
166+ )
167+ .await
168+}
169+
170+/// Adapts the request body into the byte stream the port takes.
171+///
172+/// Unbuffered on purpose: a push is arbitrarily large, and `Body` is taken as itself
173+/// rather than as `Bytes` precisely so nothing collects it.
174+fn into_reader(body: Body) -> ByteStream {
175+ Box::pin(StreamReader::new(
176+ body.into_data_stream().map_err(io::Error::other),
177+ ))
178+}
179+
180+/// The response body, streaming out of git.
181+///
182+/// Hand-written rather than assembled from stream combinators because the port speaks
183+/// `AsyncRead` and `http_body` wants frames; this is the whole of the translation.
184+pub struct GitBody {
185+ reader: ByteStream,
186+}
187+
188+impl GitBody {
189+ fn new(reader: ByteStream) -> Self {
190+ Self { reader }
191+ }
192+}
193+
194+impl http_body::Body for GitBody {
195+ type Data = Bytes;
196+ type Error = io::Error;
197+
198+ fn poll_frame(
199+ mut self: Pin<&mut Self>,
200+ cx: &mut Context<'_>,
201+ ) -> Poll<Option<std::result::Result<Frame<Bytes>, io::Error>>> {
202+ let mut buffer = [0u8; CHUNK];
203+ let mut read = ReadBuf::new(&mut buffer);
204+
205+ match Pin::new(&mut self.reader).poll_read(cx, &mut read) {
206+ Poll::Pending => Poll::Pending,
207+ Poll::Ready(Err(error)) => Poll::Ready(Some(Err(error))),
208+ Poll::Ready(Ok(())) => {
209+ let filled = read.filled();
210+
211+ // An empty read is EOF: the pack is complete and the body ends.
212+ if filled.is_empty() {
213+ Poll::Ready(None)
214+ } else {
215+ Poll::Ready(Some(Ok(Frame::data(Bytes::copy_from_slice(filled)))))
216+ }
217+ }
218+ }
219+ }
220+}
src/infrastructure/web/mod.rs+1 −0View file
@@ -2,6 +2,7 @@
22
33 pub mod api;
44 pub mod context;
5+pub mod git;
56 pub mod layout;
67 pub mod pages;
78 pub mod profile;