| | @@ -4,110 +4,66 @@ |
| 4 | 4 | > [progress.md](progress.md). If this file starts reading like a changelog, it has |
| 5 | 5 | > drifted — that's exactly what went wrong last time. |
| 6 | 6 | |
| 7 | | −## Active: Milestone 4a — Clone over HTTP |
| 7 | +## Active: Milestone 4b — Push and tokens |
| 8 | 8 | |
| 9 | | −**Goal:** `git clone https://host/{handle}/repos/{name}.git` works against a public |
| 10 | | −repository, for anyone, with no credentials. The protocol is delegated to `git |
| 11 | | −http-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). |
| 12 | 12 | |
| 13 | | −**Out of scope:** personal access tokens, HTTP Basic, push, cloning a private repo — |
| 14 | | −all of that is 4b. Also out: browsing a tree in the UI (Milestone 5), and any repo |
| 15 | | −statistic 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). |
| 16 | 15 | |
| 17 | 16 | ### Steps |
| 18 | 17 | |
| 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 |
| 31 | 30 | |
| 32 | 31 | ### Done when |
| 33 | 32 | |
| 34 | | −`git clone http://127.0.0.1:3000/{handle}/repos/{name}.git` produces a working |
| 35 | | −checkout of a public repository, with no credentials, and the cloned history matches |
| 36 | | −the 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. |
| 84 | 37 | |
| 85 | 38 | ### Open |
| 86 | 39 | |
| 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. |
| 93 | 57 | |
| 94 | | −### Watch for |
| 58 | +### Carried over — small, unblocked |
| 95 | 59 | |
| 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`. |
| 99 | 60 | - **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. |
| 101 | 62 | 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. |
| 111 | 67 | - **An orphaned repo directory is possible** if the process dies between the record |
| 112 | 68 | write and the filesystem write, and it then blocks re-creating that name. The durable |
| 113 | 69 | 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 |
| 130 | 86 | |
| 131 | 87 | Ordered. Pull from the top. |
| 132 | 88 | |
| 133 | | −1. **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. |
| 137 | | −2. **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 |
| 138 | 90 | objects** — `ObjectId`, `RefName`, `TreeEntry` — before any adapter. A query port |
| 139 | 91 | returning `String`s is an anaemic pass-through that pushes validation into the page. |
| 140 | 92 | Also the point to measure fork/exec cost per page view, and to reconsider `gix` for |
| 141 | 93 | the read path ([0006](decisions/0006-git-binary-behind-narrow-ports.md)). |
| 142 | | −3. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open |
| 94 | +2. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open |
| 143 | 95 | whether writing or projects/showcases is the better first portfolio feature. |
| 144 | 96 | |
| 145 | 97 | ## Open questions |