steid

@jamesgill /

feat: repository settings, README rendering, branch switcher, raw files

Built in parallel while the deployment milestone waits on a domain transfer.
Three agents on disjoint files; the integration, the layering fix and the
security verification are mine.

The one that was a hole rather than a feature: repositories were create-only,
so anything published by accident could not be un-published. Description and
visibility are now editable and a repository can be deleted behind a
type-the-name confirmation. Delete writes the row first and the directory
best-effort, because an orphaned directory only blocks reusing a name — already
create_repo's documented failure mode — while an orphaned row is a repository
that lists on the profile and 404s when clicked.

Markdown arrives early, as Milestone 6's pipeline, because a repository page
needed it. Two things are worth knowing. pulldown-cmark does NOT sanitise URLs —
escape_href only percent-escapes, so a javascript: link would have reached the
page; the renderer allowlists schemes and strips control characters before the
check, since java	script: is otherwise a live bypass. And adding the crate
with default-features = false turned off its html feature, so push_html does not
exist and the renderer writes every tag itself. That accident produced the
better design: raw HTML is not disabled by a flag, it is impossible by
construction, and <script> renders as visible inert text rather than silently
vanishing.

Raw files are served as application/octet-stream always — never the file's own
type, never guessed from an extension — with nosniff, attachment disposition and
a sandbox CSP. A repository-supplied .html served as its real type on this origin
is stored XSS against the viewer's session.

GitRef and RefKind were moved into the domain, where TreeEntry and EntryKind
already live; they landed in port.rs only because domain/ was off-limits to the
agent that needed them.

Verified against a running instance with a hostile README: zero real script or
img tags, the markup present once as escaped inert text, the javascript: link
stripped of its href while a real link survived, raw bytes SHA-256 identical
with the intended headers, and the Settings link owner-only.

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

24 files changed+3558 −50

Cargo.lock+18 −0View file
@@ -1322,6 +1322,17 @@ dependencies = [
13221322 "unicode-ident",
13231323 ]
13241324
1325+[[package]]
1326+name = "pulldown-cmark"
1327+version = "0.13.4"
1328+source = "registry+https://github.com/rust-lang/crates.io-index"
1329+checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
1330+dependencies = [
1331+ "bitflags",
1332+ "memchr",
1333+ "unicase",
1334+]
1335+
13251336 [[package]]
13261337 name = "quote"
13271338 version = "1.0.47"
@@ -1895,6 +1906,7 @@ dependencies = [
18951906 "envy",
18961907 "futures-util",
18971908 "http-body",
1909+ "pulldown-cmark",
18981910 "rand 0.10.2",
18991911 "serde",
19001912 "sha2 0.10.9",
@@ -2638,6 +2650,12 @@ version = "1.20.1"
26382650 source = "registry+https://github.com/rust-lang/crates.io-index"
26392651 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
26402652
2653+[[package]]
2654+name = "unicase"
2655+version = "2.9.0"
2656+source = "registry+https://github.com/rust-lang/crates.io-index"
2657+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
2658+
26412659 [[package]]
26422660 name = "unicode-bidi"
26432661 version = "0.3.18"
Cargo.toml+1 −0View file
@@ -11,6 +11,7 @@ dotenvy = "0.15.7"
1111 envy = "0.4.2"
1212 futures-util = { version = "0.3", default-features = false, features = ["std"] }
1313 http-body = "1"
14+pulldown-cmark = { version = "0.13", default-features = false }
1415 rand = "0.10.2"
1516 serde = { version = "1.0.229", features = ["derive"] }
1617 sha2 = "0.10"
deploy/Caddyfile+36 −0View file
@@ -28,3 +28,39 @@ git.example.com {
2828 # client sets its own Accept-Encoding; re-compressing them costs CPU on the
2929 # hot path and buys nothing.
3030 }
31+
32+# --- OPTIONAL: only on the instance that DISTRIBUTES Steid -------------------
33+#
34+# Most people running Steid do not want this. It belongs on the one instance that
35+# publishes releases for everyone else — the host baked into install.sh as
36+# RELEASE_BASE_URL. Uncomment the two `handle` blocks below and move the
37+# `reverse_proxy` into a trailing `handle { … }` so the static paths win first.
38+#
39+# handle /jamesgill/repos/steid/releases/* {
40+# root * /var/lib/steid/dist
41+# file_server
42+# }
43+# handle /install.sh {
44+# root * /var/lib/steid/dist
45+# file_server
46+# }
47+# handle {
48+# reverse_proxy 127.0.0.1:3000 { flush_interval -1 }
49+# }
50+#
51+# Two things to know, and the first one bites later:
52+#
53+# 1. THESE PATHS SHADOW STEID. Caddy answers them before the application sees
54+# them. Nothing lives at /{handle}/repos/{name}/releases today, so nothing
55+# breaks — but the day Steid grows a real release feature at that URL, Caddy
56+# silently keeps winning and the feature will look broken. Delete these blocks
57+# then. The URL is deliberately the one that feature will use, so links
58+# published now survive the change.
59+#
60+# 2. /install.sh at the root is safe permanently, not by luck: OrgName allows
61+# only [a-z0-9-], so no handle can ever contain a dot and none can collide
62+# with it. A root-level /releases would NOT be safe for the same reason — it
63+# is a valid handle shape — which is why the releases path is scoped.
64+#
65+# Artefacts are rsync'd into /var/lib/steid/dist/v<version>/ to match the URL
66+# install.sh builds: ${RELEASE_BASE_URL}/v${VERSION}/steid-<version>-<target>.tar.gz
deploy/steid.service+1 −1View file
@@ -12,7 +12,7 @@
1212
1313 [Unit]
1414 Description=Steid
15Documentation=https://github.com/JamesPatrickGill/steid
15+Documentation=https://git.jpgilldev.com/jamesgill/repos/steid
1616 After=network-online.target
1717 Wants=network-online.target
1818
install.sh+67 −18View file
@@ -25,17 +25,27 @@ set -eu
2525
2626 # --- PLACEHOLDER ------------------------------------------------------------
2727 #
28# !! There is no published release yet. This URL does not resolve. !!
28+# !! Nothing is published there yet — the host does not resolve until the first
29+# !! instance is up. That is the bootstrap `--tarball` exists for.
2930 #
30# Set it to the base URL under which release directories live. The layout the
31# script expects, and which release.sh produces, is:
31+# The layout expected here, and produced by release.sh, is:
3232 #
3333 # ${RELEASE_BASE_URL}/v${VERSION}/steid-${VERSION}-${TARGET}.tar.gz
3434 # ${RELEASE_BASE_URL}/v${VERSION}/steid-${VERSION}-${TARGET}.tar.gz.sha256
3535 #
36# For GitHub releases that is:
37# https://github.com/JamesPatrickGill/steid/releases/download
38RELEASE_BASE_URL="${STEID_RELEASE_BASE_URL:-https://REPLACE-ME.example.com/steid/releases/download}"
36+# Steid is distributed from a Steid instance rather than from a code-hosting
37+# service, which is the point of the project rather than a flourish. The cost is
38+# discovery: nobody stumbles across it. That is a marketing problem, not a
39+# technical dependency, and a mirror can solve it later without this URL moving.
40+# The *project's* distribution host — a constant, and NOT the `--domain` the
41+# person running this installs onto. Everyone downloads Steid from here; each
42+# installer then runs their own instance on their own hostname.
43+#
44+# Scoped under the repository rather than a root-level /releases: Steid serves
45+# profiles at /{handle}, so a root path would squat its own namespace. This is
46+# also exactly where a real release feature would put these files, so published
47+# links survive that feature landing.
48+RELEASE_BASE_URL="${STEID_RELEASE_BASE_URL:-https://git.jpgilldev.com/jamesgill/repos/steid/releases}"
3949
4050 # The version to install. Pinned rather than "latest" because there is no
4151 # redirect to resolve "latest" against, and a pinned default makes the upgrade
@@ -47,9 +57,20 @@ VERSION="${STEID_VERSION:-0.1.0}"
4757 # care which glibc the host has — but whether Steid builds against musl at all is
4858 # still being established, so the choice is a variable rather than a fact.
4959 # Override with --flavour gnu if the published artefacts are glibc.
50FLAVOUR="musl"
60+# gnu, matching what release.sh builds. musl was tried and rejected: it fails on
61+# `ring` with Debian's musl-gcc wrapper, and — the decisive part — musl buys a
62+# binary with no runtime dependencies while Steid hard-requires `git` on PATH, so
63+# the portability cannot be used. THIS MUST AGREE WITH release.sh: they were
64+# briefly out of step and the symptom was a confusing "checksum mismatch",
65+# because the installer was looking for a musl tarball that was never built.
66+FLAVOUR="gnu"
5167
5268 DOMAIN=""
69+# A local artefact to install instead of downloading one. This exists because of
70+# a bootstrap: the very first instance is what will *serve* the releases, so at
71+# that moment there is nowhere to download from. It doubles as the offline and
72+# air-gapped path.
73+TARBALL=""
5374 PORT="3000"
5475 INSTALL_DIR="/opt/steid"
5576 STATE_DIR="/var/lib/steid"
@@ -64,6 +85,9 @@ usage() {
6485 cat >&2 <<'USAGE'
6586 Usage: install.sh --domain <hostname> [options]
6687
88+ --tarball <path> install from a local tarball instead of downloading.
89+ Needed for the first install, which has nowhere to
90+ download from yet, and for offline installs.
6791 --domain <hostname> the public hostname, e.g. git.example.com. Its DNS must
6892 already point at this machine or the certificate cannot
6993 be issued. Required.
@@ -84,6 +108,7 @@ USAGE
84108 while [ $# -gt 0 ]; do
85109 case "$1" in
86110 --domain) DOMAIN="${2:-}"; [ -n "$DOMAIN" ] || die "--domain needs a hostname"; shift 2 ;;
111+ --tarball) TARBALL="${2:-}"; [ -n "$TARBALL" ] || die "--tarball needs a path"; shift 2 ;;
87112 --version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;;
88113 --flavour) FLAVOUR="${2:-}"; [ -n "$FLAVOUR" ] || die "--flavour needs a value"; shift 2 ;;
89114 --port) PORT="${2:-}"; [ -n "$PORT" ] || die "--port needs a number"; shift 2 ;;
@@ -118,9 +143,11 @@ case "$FLAVOUR" in
118143 *) die "--flavour must be 'musl' or 'gnu'" ;;
119144 esac
120145
121case "$RELEASE_BASE_URL" in
146+case "${TARBALL:+local}${RELEASE_BASE_URL}" in
147+ local*) : ;; # installing from a file; the download URL is irrelevant
122148 *REPLACE-ME*) die "RELEASE_BASE_URL is still the placeholder. Edit the top of
123 this script (or set STEID_RELEASE_BASE_URL) to point at real release artefacts." ;;
149+ this script (or set STEID_RELEASE_BASE_URL) to point at real release artefacts,
150+ or pass --tarball to install from a local file." ;;
124151 esac
125152
126153 command -v systemctl >/dev/null 2>&1 || die "no systemd here; follow the manual path in README.md"
@@ -155,15 +182,37 @@ TMP="$(mktemp -d)"
155182 # shellcheck disable=SC2064 # $TMP is expanded now on purpose: it never changes.
156183 trap "rm -rf '$TMP'" EXIT INT TERM
157184
158say "downloading ${URL}"
159curl -fsSL "$URL" -o "${TMP}/${NAME}.tar.gz" \
160 || die "download failed. Is version ${VERSION} published for ${TARGET}?"
161curl -fsSL "${URL}.sha256" -o "${TMP}/${NAME}.tar.gz.sha256" \
162 || die "checksum file missing next to the tarball; refusing to install unverified"
163
164say "verifying checksum"
165( cd "$TMP" && sha256sum -c "${NAME}.tar.gz.sha256" >/dev/null ) \
166 || die "checksum mismatch — the download is corrupt or tampered with"
185+if [ -n "$TARBALL" ]; then
186+ [ -f "$TARBALL" ] || die "--tarball: no such file: ${TARBALL}"
187+ say "installing from ${TARBALL}"
188+ cp "$TARBALL" "${TMP}/${NAME}.tar.gz"
189+
190+ # A checksum beside a local file is verified when present, but not demanded:
191+ # whoever passes --tarball already chose the bytes, so refusing to proceed
192+ # without a .sha256 would block the bootstrap this option exists for.
193+ if [ -f "${TARBALL}.sha256" ]; then
194+ say "verifying checksum"
195+ # Compared by value, not with `sha256sum -c`: that matches on the filename
196+ # recorded inside the .sha256, which need not be what the file is called
197+ # by the time someone passes it here.
198+ EXPECTED="$(cut -d" " -f1 < "${TARBALL}.sha256")"
199+ ACTUAL="$(sha256sum < "${TMP}/${NAME}.tar.gz" | cut -d" " -f1)"
200+ [ "$EXPECTED" = "$ACTUAL" ] \
201+ || die "checksum mismatch — ${TARBALL} does not match its .sha256"
202+ else
203+ say "no ${TARBALL}.sha256 beside it; installing unverified"
204+ fi
205+else
206+ say "downloading ${URL}"
207+ curl -fsSL "$URL" -o "${TMP}/${NAME}.tar.gz" \
208+ || die "download failed. Is version ${VERSION} published for ${TARGET}?"
209+ curl -fsSL "${URL}.sha256" -o "${TMP}/${NAME}.tar.gz.sha256" \
210+ || die "checksum file missing next to the tarball; refusing to install unverified"
211+
212+ say "verifying checksum"
213+ ( cd "$TMP" && sha256sum -c "${NAME}.tar.gz.sha256" >/dev/null ) \
214+ || die "checksum mismatch — the download is corrupt or tampered with"
215+fi
167216
168217 tar -xzf "${TMP}/${NAME}.tar.gz" -C "$TMP"
169218 [ -x "${TMP}/${NAME}/steid" ] || die "tarball has no steid binary at ${NAME}/steid"
plans/current.md+15 −3View file
@@ -69,8 +69,18 @@ instance, and Steid's own source is pushed to it and browsable there.
6969 a caller can vary the header for a fresh budget, leaving only the global cap. An
7070 explicit "trust forwarded headers" flag defaulting to off would close it, at the cost
7171 of one more thing an operator must get right. Not picked.
72+- **`MAX_RAW_BYTES` is 10 MiB**, chosen rather than derived. It bounds what one raw
73+ request can hold in memory, because `GitQuery` reads bytes rather than streaming them.
74+ Raising it means a handful of concurrent requests can hold that much each.
75+- **Renaming a repository** is still impossible, and now needs its own use case plus an
76+ answer for moving the directory under every existing clone.
7277 - **A licence.** A public portfolio repository probably wants one, and the README
7378 deliberately says nothing about licensing rather than guessing.
79+- **Blocked on a domain transfer** (noted 2026-08-29). `git.jpgilldev.com` is the
80+ intended host for both the instance and the release downloads; the transfer is in
81+ flight. Phases 1–6 of the deployment runbook cannot start until DNS resolves, because
82+ Caddy requests a certificate on startup. Nothing else is blocked by it — the artifact
83+ is built and verified, and `install.sh` still needs its container dry-run.
7484 - **A domain.** Caddy needs a real hostname to obtain a certificate. This is the one
7585 blocker that is DNS rather than code.
7686
@@ -104,9 +114,11 @@ instance, and Steid's own source is pushed to it and browsable there.
104114 on — the file tree, the blob view and the log all shipped without anyone looking at
105115 them in light mode.
106116 - **Submodule rendering was never seen**, only compiled: no fixture contained one.
107- **The `/log` page shows no branch indicator** when no revision is given, because
108 `repo_log` does not return the revision it resolved. A second query or a small
109 application change, neither urgent.
117+- **The `/log` page's switcher opens with nothing marked current** when no revision is
118+ in the URL, because `repo_log` still does not report the revision it resolved. Now more
119+ visible than before, since there is a switcher to look wrong.
120+- **Task-list items keep their bullet** and footnotes render in place rather than
121+ collected at the end. Both cosmetic.
110122 - **A per-file last-commit column is still absent**, deliberately — see
111123 [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).
112124 Wanting it is the trigger to move to a kept-alive `cat-file --batch`, not to reopen
plans/progress.md+71 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5339 tests. Active milestone in [current.md](current.md).
5+421 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -453,6 +453,76 @@ operability the service needs: `/healthz`, `STEID_SETUP_TOKEN`, and rate limitin
453453 already copying out of a log line. It is validated for strength, never printed, and
454454 ignored entirely once claimed.
455455
456+### Gap-filling while 5b was blocked on DNS · done
457+
458+Built while the domain transfer was in flight, so none of it belongs to a milestone.
459+Three things: repository settings, browse usability, and README rendering — the last of
460+which is Milestone 6's markdown pipeline arriving early because a repository page needed
461+it.
462+
463+#### Repository settings — a hole, not a feature
464+
465+Repositories were **create-only**. No edit, no delete, and therefore **no way to
466+un-publish something published by accident**. Now: description and visibility are
467+editable, and a repository can be deleted behind a type-the-name confirmation.
468+
469+- **Delete writes the row first, then the directory best-effort.** An orphaned directory
470+ only blocks reusing that name and is already `create_repo`'s documented failure mode;
471+ an orphaned row is a repository that lists on the profile and 404s when clicked. The
472+ visible failure is the worse one, so the ordering avoids it.
473+- **Two different refusals, deliberately.** A repository the actor may not *see* is
474+ `NotFound` (a 403 would confirm a private repo by that name exists); one they can see
475+ but do not own is `Forbidden`, matching `create_repo`, because the resource is public
476+ anyway. The page collapses both to 404.
477+- **Renaming is still impossible, now with a comment saying why.** The bare repo lives at
478+ `{data_dir}/{handle}/{name}.git`, so a rename is a directory move that breaks every
479+ existing clone — and doing the row half only breaks them silently.
480+
481+#### Markdown — raw HTML is structurally impossible, not merely disabled
482+
483+- **`pulldown-cmark` does not sanitise URLs.** Its `escape_href` only percent-escapes, so
484+ `javascript:` would have survived into a rendered README. The renderer uses a scheme
485+ **allowlist** (`http`, `https`, `mailto`, `ftp`, `ftps`, `tel`) and strips ASCII control
486+ characters *before* the check as well as on output, because browsers strip them too and
487+ `java&#9;script:` is otherwise a live bypass.
488+- **Adding the crate with `default-features = false` turned off its `html` feature**, so
489+ `push_html` does not exist in this build. The renderer therefore walks the event stream
490+ and writes every tag itself. Forced rather than chosen, and better: the emittable tag
491+ set is exactly what the writer spells out, so raw HTML cannot pass through by
492+ construction. `Event::Html` is written as *text*, so `<script>` is visible and inert
493+ rather than silently vanishing.
494+- **Escaping goes through `topcoat::view::HtmlContext`**, the same escaper `view!` uses —
495+ not a hand-rolled one.
496+- **Smart punctuation is off**: it rewrites `--flag` to an en dash, quietly corrupting CLI
497+ flags in README prose.
498+- Relative *links* are rewritten into tree URLs; relative *images* are deliberately left
499+ alone, because a tree URL serves a page and rewriting would swap a 404 for a broken
500+ image. Pointing them at `/raw/` is the obvious follow-up now that route exists.
501+
502+#### Browse — the switcher and raw files
503+
504+- **`for-each-ref` asks only for `%(refname)`.** `%(objecttype)` is `commit` for both a
505+ branch and a lightweight tag, so the *namespace* is the only thing that answers
506+ branch-versus-tag.
507+- **Raw files are served as `application/octet-stream`, always** — never the file's own
508+ type and never guessed from an extension — with `nosniff`, `Content-Disposition:
509+ attachment` and `default-src 'none'; sandbox`. A repository-supplied `.html` or `.svg`
510+ served as its real type on this origin is stored XSS against the viewer's session.
511+ `text/plain` was rejected because browsers render it and have been talked into sniffing
512+ it as HTML. The filename is repository content arriving in a header, so it is reduced to
513+ `[A-Za-z0-9._-]` against header injection.
514+- **The switcher costs one more fork (~13ms) on tree and log pages**, measured
515+ interleaved against `ls-tree` to cancel out load — same band, confirming again that the
516+ fork is the cost. It is not called on the repository page or an empty repository.
517+
518+#### Verified
519+
520+Against a running instance with a deliberately hostile README: **zero real `<script>` or
521+`<img>` tags in the output**, the markup present once as escaped inert text, the
522+`javascript:` link stripped of its `href` while `https://example.com` survived, the table
523+rendered, raw bytes SHA-256 identical with the headers above, the switcher listing a
524+branch and a tag, and the Settings link visible to the owner and absent for anonymous.
525+
456526 ---
457527
458528 ## Reference: what attempt #2 proved
plans/runbook.md+63 −0View file
@@ -248,3 +248,66 @@ Applied: `/target`, `/data`, `*.db*`, `.env`, `.env.prod`.
248248
249249 **Do not add `/plans`.** Attempt #2 did, and that is why these docs had to be
250250 hand-carried between repos.
251+
252+## Deploying this instance
253+
254+The operator's path, as opposed to `README.md`, which is written for a stranger
255+installing their own. `git.jpgilldev.com` serves two roles from one box: this instance,
256+and the place everyone else downloads Steid from.
257+
258+### Order matters
259+
260+1. **Provision** — Debian 12, x86_64. Open 22, 80 and 443. Port 80 is not optional:
261+ Let's Encrypt validates over it.
262+2. **DNS first, then install.** `dig +short git.jpgilldev.com` must return the box's IP
263+ *before* `install.sh` runs. Caddy requests a certificate on startup; if DNS has not
264+ propagated it fails and backs off, and the resulting error points nowhere useful.
265+3. **First install uses `--tarball`.** There is a bootstrap: this instance is what will
266+ serve the releases, so at that moment there is nowhere to download from.
267+
268+```sh
269+scp dist/steid-0.1.0-x86_64-unknown-linux-gnu.tar.gz install.sh root@<ip>:/root/
270+ssh root@<ip> './install.sh --domain git.jpgilldev.com \
271+ --tarball ./steid-0.1.0-x86_64-unknown-linux-gnu.tar.gz'
272+```
273+
274+Then `curl https://git.jpgilldev.com/healthz` — over https, with a real certificate.
275+
276+### Becoming the distribution host
277+
278+Only this instance does this. Uncomment the two `handle` blocks in
279+[deploy/Caddyfile](../deploy/Caddyfile) and rsync the artefacts into a **versioned**
280+directory, matching the URL `install.sh` builds
281+(`${RELEASE_BASE_URL}/v${VERSION}/…`):
282+
283+```sh
284+rsync dist/*.tar.gz dist/*.sha256 root@<ip>:/var/lib/steid/dist/v0.1.0/
285+rsync install.sh root@<ip>:/var/lib/steid/dist/
286+```
287+
288+**Those Caddy paths shadow Steid.** Nothing lives at
289+`/{handle}/repos/{name}/releases` today, so nothing breaks — but when Steid grows a real
290+release feature at that URL, Caddy will keep winning and the feature will look broken.
291+Delete the blocks then. The URL is deliberately the one that feature will use, so links
292+published now survive it.
293+
294+`/install.sh` at the root is safe permanently rather than by luck: `OrgName` allows only
295+`[a-z0-9-]`, so no handle can contain a dot and none can ever collide with it. A
296+root-level `/releases` would **not** be safe — it is a valid handle shape.
297+
298+### What is verified, and what is not
299+
300+Verified in a Debian 12 container with `systemctl` stubbed: prerequisites install, the
301+tarball extracts, `/opt/steid` and `/var/lib/steid` get the right owners and modes, the
302+`steid` user is created with `nologin`, the env file and unit and Caddyfile are written,
303+and **the binary starts as the `steid` user**. The artifact itself was booted on Debian
304+11 (glibc 2.31) and served pages.
305+
306+**Not verified anywhere but a real box:** the systemd unit lifecycle, and Caddy's ACME
307+certificate issuance. Expect the first real run to need a fix or two.
308+
309+### One bug already found this way
310+
311+`install.sh` defaulted to a `musl` target while `release.sh` had moved to `gnu`. The
312+symptom was `checksum mismatch` — because the installer was looking for a tarball that
313+was never built. The two defaults must agree; both now say so in a comment.
src/application/browse.rs+489 −1View file
@@ -5,7 +5,7 @@
55 //! invisible in its file tree, by construction rather than by remembering to check.
66
77 use crate::domain::{
8 Actor, CommitSummary, ObjectId, OrgName, RefName, RepoName, RepoPath,
8+ Actor, CommitSummary, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
99 repository::{MembershipRepository, OrgRepository, RepoRepository},
1010 };
1111
@@ -25,6 +25,16 @@ pub const MAX_BLOB_BYTES: u64 = 1024 * 1024;
2525 /// How many commits a log shows. No paging in v1; this is the whole of it.
2626 pub const LOG_LIMIT: usize = 50;
2727
28+/// The largest file Steid will hand back raw.
29+///
30+/// Much larger than [`MAX_BLOB_BYTES`], because nobody is reading a raw response — it is
31+/// being saved or piped, and the megabyte cap exists to protect a *browser*. It is still
32+/// capped, and capped well below what a repository can hold, because [`GitQuery`] reads
33+/// bytes rather than streaming them: this number is the memory one request may cost, so
34+/// it bounds what a handful of concurrent requests can do to a small VPS. Anything
35+/// larger is what `git clone` is for.
36+pub const MAX_RAW_BYTES: u64 = 10 * 1024 * 1024;
37+
2838 /// A file, as far as it can be displayed.
2939 #[derive(Debug, Clone, PartialEq, Eq)]
3040 pub struct FileView {
@@ -150,6 +160,152 @@ pub async fn repo_log(
150160 Ok(Some(queries.log(handle, name, &rev, LOG_LIMIT).await?))
151161 }
152162
163+/// The branches and tags a repository has, ready for a switcher.
164+///
165+/// Two lists rather than one tagged list, because that is how they are shown: a
166+/// visitor picking a revision is picking from branches *or* from tags, and the same
167+/// name can legitimately appear in both.
168+#[derive(Debug, Clone, Default, PartialEq, Eq)]
169+pub struct RefList {
170+ pub branches: Vec<RefName>,
171+ pub tags: Vec<RefName>,
172+}
173+
174+impl RefList {
175+ pub fn is_empty(&self) -> bool {
176+ self.branches.is_empty() && self.tags.is_empty()
177+ }
178+
179+ /// Whether a revision names one of these refs.
180+ ///
181+ /// What a switcher uses to decide whether the current revision is a ref it can
182+ /// highlight or an object id it has to show as itself.
183+ pub fn contains(&self, rev: &RefName) -> bool {
184+ self.branches
185+ .iter()
186+ .chain(&self.tags)
187+ .any(|name| name == rev)
188+ }
189+}
190+
191+/// Every branch and tag, for the revision switcher.
192+///
193+/// `Ok(None)` on the same terms as [`browse_repo`]: invisible and absent are one answer.
194+///
195+/// **This costs one extra `git` process (~14ms) on top of whatever the page already
196+/// spends**, so it is called by the pages that show a switcher and by nothing else. See
197+/// the port's note on [`list_refs`](super::port::GitQuery::list_refs).
198+///
199+/// Ordering is decided here rather than in an adapter: branches then tags, each
200+/// case-insensitively by name, with ties broken by the name itself so the order is
201+/// total. The default branch is not floated to the top — it is usually first
202+/// alphabetically anyway, and a list that reorders itself is harder to scan than one
203+/// that does not.
204+pub async fn list_refs(
205+ handle: &OrgName,
206+ name: &RepoName,
207+ actor: &Actor,
208+ orgs: &impl OrgRepository,
209+ memberships: &impl MembershipRepository,
210+ repos: &impl RepoRepository,
211+ queries: &impl GitQuery,
212+) -> Result<Option<RefList>> {
213+ if view_repo(handle, name, actor, orgs, memberships, repos)
214+ .await?
215+ .is_none()
216+ {
217+ return Ok(None);
218+ }
219+
220+ let mut list = RefList::default();
221+
222+ for git_ref in queries.list_refs(handle, name).await? {
223+ match git_ref.kind {
224+ RefKind::Branch => list.branches.push(git_ref.name),
225+ RefKind::Tag => list.tags.push(git_ref.name),
226+ }
227+ }
228+
229+ for names in [&mut list.branches, &mut list.tags] {
230+ names.sort_by(|left, right| {
231+ left.as_str()
232+ .to_lowercase()
233+ .cmp(&right.as_str().to_lowercase())
234+ .then_with(|| left.as_str().cmp(right.as_str()))
235+ });
236+ }
237+
238+ Ok(Some(list))
239+}
240+
241+/// A file as it is served rather than rendered.
242+#[derive(Debug, Clone, PartialEq, Eq)]
243+pub enum RawFile {
244+ Ready {
245+ /// The file's own name, for the download it becomes.
246+ name: String,
247+ content: Vec<u8>,
248+ },
249+ /// Bigger than [`MAX_RAW_BYTES`]. Reported rather than served, because the port
250+ /// reads bytes into memory; the size is carried so the refusal can say why.
251+ TooLarge { size: u64 },
252+}
253+
254+/// Reads a file for serving verbatim.
255+///
256+/// Authorized exactly as [`browse_repo`] is, through [`view_repo`], so a repository
257+/// invisible on its page is invisible here too — a raw URL is not a side door.
258+///
259+/// `Ok(None)` for a repository that is invisible or absent, a revision that is not
260+/// there, a path that is not there, and a path that is a directory. All of them are one
261+/// answer for the reason [`view_repo`] gives, and a directory is included because there
262+/// is no such thing as raw bytes for one.
263+#[allow(clippy::too_many_arguments)]
264+pub async fn read_raw_file(
265+ handle: &OrgName,
266+ name: &RepoName,
267+ rev: Option<&RefName>,
268+ path: &RepoPath,
269+ actor: &Actor,
270+ orgs: &impl OrgRepository,
271+ memberships: &impl MembershipRepository,
272+ repos: &impl RepoRepository,
273+ queries: &impl GitQuery,
274+) -> Result<Option<RawFile>> {
275+ if view_repo(handle, name, actor, orgs, memberships, repos)
276+ .await?
277+ .is_none()
278+ {
279+ return Ok(None);
280+ }
281+
282+ let Some(rev) = resolve_revision(handle, name, rev, queries).await? else {
283+ return Ok(None);
284+ };
285+
286+ // Straight to the blob: unlike a browse, there is no directory case to serve, so
287+ // asking `list_tree` first would spend a whole process learning that a path is not
288+ // something this endpoint can answer for. `read_blob` already says `None` for a
289+ // tree.
290+ let Some(blob) = queries
291+ .read_blob(handle, name, &rev, path, MAX_RAW_BYTES)
292+ .await?
293+ else {
294+ return Ok(None);
295+ };
296+
297+ let Some(content) = blob.content else {
298+ return Ok(Some(RawFile::TooLarge { size: blob.size }));
299+ };
300+
301+ Ok(Some(RawFile::Ready {
302+ // A path that resolved to a blob has a last component by construction: the root
303+ // is a tree, and `read_blob` refuses it.
304+ name: path.file_name().unwrap_or_default().to_owned(),
305+ content,
306+ }))
307+}
308+
153309 /// Settles which revision is being asked about.
154310 ///
155311 /// `None` out means the repository has no commits at all — not that the revision was
@@ -181,3 +337,335 @@ fn view_of(blob: Blob) -> FileView {
181337 too_large,
182338 }
183339 }
340+
341+#[cfg(test)]
342+mod tests {
343+ use super::*;
344+ use crate::{
345+ domain::{
346+ Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
347+ },
348+ infrastructure::{
349+ git::InMemoryGitQuery,
350+ repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
351+ },
352+ };
353+
354+ struct Fixture {
355+ orgs: InMemoryOrgRepo,
356+ memberships: InMemoryMembershipRepo,
357+ repos: InMemoryRepoRepo,
358+ handle: OrgName,
359+ owner: Actor,
360+ stranger: Actor,
361+ }
362+
363+ /// One organisation with an owner, and a `steid` repository of the given visibility.
364+ async fn fixture(visibility: Visibility) -> Fixture {
365+ let orgs = InMemoryOrgRepo::new();
366+ let memberships = InMemoryMembershipRepo::new();
367+ let repos = InMemoryRepoRepo::new();
368+
369+ let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
370+ orgs.save(&org).await.expect("save org");
371+
372+ let owner = UserId::generate();
373+ memberships
374+ .save(&Membership::new(
375+ MembershipId::generate(),
376+ org.id.clone(),
377+ owner.clone(),
378+ crate::domain::Role::Owner,
379+ ))
380+ .await
381+ .expect("save membership");
382+
383+ repos
384+ .save(
385+ &Repository::new(
386+ RepoId::generate(),
387+ org.id.clone(),
388+ "steid",
389+ None,
390+ visibility,
391+ )
392+ .expect("valid repository"),
393+ )
394+ .await
395+ .expect("save repo");
396+
397+ Fixture {
398+ orgs,
399+ memberships,
400+ repos,
401+ handle: org.name,
402+ owner: Actor::User(owner),
403+ stranger: Actor::Anonymous,
404+ }
405+ }
406+
407+ fn repo_name() -> RepoName {
408+ RepoName::new("steid").expect("valid repository name")
409+ }
410+
411+ fn rev(value: &str) -> RefName {
412+ RefName::new(value).expect("valid revision")
413+ }
414+
415+ fn path(value: &str) -> RepoPath {
416+ RepoPath::new(value).expect("valid path")
417+ }
418+
419+ impl Fixture {
420+ async fn refs(&self, actor: &Actor, queries: &InMemoryGitQuery) -> Result<Option<RefList>> {
421+ list_refs(
422+ &self.handle,
423+ &repo_name(),
424+ actor,
425+ &self.orgs,
426+ &self.memberships,
427+ &self.repos,
428+ queries,
429+ )
430+ .await
431+ }
432+
433+ async fn raw(
434+ &self,
435+ actor: &Actor,
436+ path: &RepoPath,
437+ queries: &InMemoryGitQuery,
438+ ) -> Result<Option<RawFile>> {
439+ read_raw_file(
440+ &self.handle,
441+ &repo_name(),
442+ Some(&rev("main")),
443+ path,
444+ actor,
445+ &self.orgs,
446+ &self.memberships,
447+ &self.repos,
448+ queries,
449+ )
450+ .await
451+ }
452+ }
453+
454+ // --- list_refs ----------------------------------------------------------------
455+
456+ #[tokio::test]
457+ async fn branches_and_tags_come_back_separated_and_ordered() {
458+ let f = fixture(Visibility::Public).await;
459+ let queries = InMemoryGitQuery::new()
460+ .with_branch("main")
461+ .with_branch("Feature")
462+ .with_tag("v2.0")
463+ .with_tag("v1.0");
464+
465+ let refs = f
466+ .refs(&f.owner, &queries)
467+ .await
468+ .expect("should read")
469+ .expect("visible");
470+
471+ // Case-insensitively, so `Feature` sorts next to `feature` rather than before
472+ // every lowercase name.
473+ assert_eq!(
474+ refs.branches
475+ .iter()
476+ .map(RefName::as_str)
477+ .collect::<Vec<_>>(),
478+ vec!["Feature", "main"]
479+ );
480+ assert_eq!(
481+ refs.tags.iter().map(RefName::as_str).collect::<Vec<_>>(),
482+ vec!["v1.0", "v2.0"]
483+ );
484+ }
485+
486+ #[tokio::test]
487+ async fn an_empty_repository_has_no_refs_to_switch_between() {
488+ let f = fixture(Visibility::Public).await;
489+
490+ let refs = f
491+ .refs(&f.owner, &InMemoryGitQuery::empty())
492+ .await
493+ .expect("should read")
494+ .expect("visible");
495+
496+ assert!(refs.is_empty());
497+ }
498+
499+ #[tokio::test]
500+ async fn a_private_repositorys_refs_are_invisible_to_a_stranger() {
501+ // The ref list names branches, which are content. Same answer as the page.
502+ let f = fixture(Visibility::Private).await;
503+ let queries = InMemoryGitQuery::new().with_branch("secret-work");
504+
505+ assert!(
506+ f.refs(&f.stranger, &queries)
507+ .await
508+ .expect("should read")
509+ .is_none()
510+ );
511+ assert!(
512+ f.refs(&f.owner, &queries)
513+ .await
514+ .expect("should read")
515+ .is_some()
516+ );
517+ }
518+
519+ #[test]
520+ fn a_ref_list_knows_the_revision_it_is_showing() {
521+ let refs = RefList {
522+ branches: vec![RefName::from_trusted("main")],
523+ tags: vec![RefName::from_trusted("v1.0")],
524+ };
525+
526+ assert!(refs.contains(&rev("main")));
527+ assert!(refs.contains(&rev("v1.0")));
528+ // An object id is not a ref, which is what a switcher needs to know before it
529+ // tries to highlight one.
530+ assert!(!refs.contains(&rev("0123456789abcdef0123456789abcdef01234567")));
531+ }
532+
533+ // --- read_raw_file ------------------------------------------------------------
534+
535+ #[tokio::test]
536+ async fn a_text_file_comes_back_with_its_own_name() {
537+ let f = fixture(Visibility::Public).await;
538+ let queries = InMemoryGitQuery::new().with_blob("main", "src/main.rs", b"fn main() {}\n");
539+
540+ let raw = f
541+ .raw(&f.owner, &path("src/main.rs"), &queries)
542+ .await
543+ .expect("should read")
544+ .expect("found");
545+
546+ assert_eq!(
547+ raw,
548+ RawFile::Ready {
549+ name: "main.rs".to_owned(),
550+ content: b"fn main() {}\n".to_vec(),
551+ }
552+ );
553+ }
554+
555+ #[tokio::test]
556+ async fn a_binary_file_comes_back_byte_for_byte() {
557+ // The whole point of the endpoint: no decoding, no lossy UTF-8, no truncation.
558+ let f = fixture(Visibility::Public).await;
559+ let bytes: Vec<u8> = (0..=255u8).chain(0..=255u8).collect();
560+ let queries = InMemoryGitQuery::new().with_blob("main", "logo.png", bytes.clone());
561+
562+ let raw = f
563+ .raw(&f.owner, &path("logo.png"), &queries)
564+ .await
565+ .expect("should read")
566+ .expect("found");
567+
568+ match raw {
569+ RawFile::Ready { content, .. } => assert_eq!(content, bytes),
570+ other => panic!("expected the bytes, got {other:?}"),
571+ }
572+ }
573+
574+ #[tokio::test]
575+ async fn a_file_too_large_to_hold_in_memory_is_refused_by_size() {
576+ let f = fixture(Visibility::Public).await;
577+ let size = MAX_RAW_BYTES as usize + 1;
578+ let queries = InMemoryGitQuery::new().with_blob("main", "huge.bin", vec![0u8; size]);
579+
580+ let raw = f
581+ .raw(&f.owner, &path("huge.bin"), &queries)
582+ .await
583+ .expect("should read")
584+ .expect("found");
585+
586+ assert_eq!(raw, RawFile::TooLarge { size: size as u64 });
587+ }
588+
589+ #[tokio::test]
590+ async fn a_file_larger_than_a_page_will_render_is_still_served_raw() {
591+ // The raw cap is deliberately far above `MAX_BLOB_BYTES`: nobody is reading
592+ // these bytes in a browser, so the reason for the page's limit does not apply.
593+ let f = fixture(Visibility::Public).await;
594+ let size = MAX_BLOB_BYTES as usize + 1;
595+ let queries = InMemoryGitQuery::new().with_blob("main", "big.txt", vec![b'x'; size]);
596+
597+ let raw = f
598+ .raw(&f.owner, &path("big.txt"), &queries)
599+ .await
600+ .expect("should read")
601+ .expect("found");
602+
603+ match raw {
604+ RawFile::Ready { content, .. } => assert_eq!(content.len(), size),
605+ other => panic!("expected the bytes, got {other:?}"),
606+ }
607+ }
608+
609+ #[tokio::test]
610+ async fn a_directory_has_no_raw_bytes() {
611+ // The fake answers `read_blob` only for blobs, exactly as git does — a tree is
612+ // not a file, and there is nothing to serve.
613+ let f = fixture(Visibility::Public).await;
614+ let queries = InMemoryGitQuery::new()
615+ .with_tree("main", "src", Vec::new())
616+ .with_blob("main", "src/main.rs", b"fn main() {}\n");
617+
618+ assert!(
619+ f.raw(&f.owner, &path("src"), &queries)
620+ .await
621+ .expect("should read")
622+ .is_none()
623+ );
624+ }
625+
626+ #[tokio::test]
627+ async fn a_path_that_is_not_there_is_not_found() {
628+ let f = fixture(Visibility::Public).await;
629+ let queries = InMemoryGitQuery::new();
630+
631+ assert!(
632+ f.raw(&f.owner, &path("nope.txt"), &queries)
633+ .await
634+ .expect("should read")
635+ .is_none()
636+ );
637+ }
638+
639+ #[tokio::test]
640+ async fn an_empty_repository_serves_nothing_raw() {
641+ let f = fixture(Visibility::Public).await;
642+
643+ assert!(
644+ f.raw(&f.owner, &path("README.md"), &InMemoryGitQuery::empty())
645+ .await
646+ .expect("should read")
647+ .is_none()
648+ );
649+ }
650+
651+ #[tokio::test]
652+ async fn a_private_repositorys_files_are_invisible_to_a_stranger() {
653+ // The point of the endpoint's authorization: a raw URL is not a way around the
654+ // page's answer.
655+ let f = fixture(Visibility::Private).await;
656+ let queries = InMemoryGitQuery::new().with_blob("main", "secret.txt", b"shh\n");
657+
658+ assert!(
659+ f.raw(&f.stranger, &path("secret.txt"), &queries)
660+ .await
661+ .expect("should read")
662+ .is_none()
663+ );
664+ assert!(
665+ f.raw(&f.owner, &path("secret.txt"), &queries)
666+ .await
667+ .expect("should read")
668+ .is_some()
669+ );
670+ }
671+}
src/application/mod.rs+8 −2View file
@@ -17,7 +17,10 @@ pub mod repo;
1717 pub mod session;
1818 pub mod token;
1919
20pub use browse::{Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, browse_repo, repo_log};
20+pub use browse::{
21+ Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, MAX_RAW_BYTES, RawFile, RefList, browse_repo,
22+ list_refs, read_raw_file, repo_log,
23+};
2124 pub use claim::{OwnerSpec, claim_instance, is_claimed};
2225 pub use config::{AppConfig, Secret};
2326 pub use error::{Error, Result};
@@ -25,7 +28,10 @@ pub use git::{GitClientHeaders, GitEndpoint, GitOperation, GitService, serve_git
2528 pub use identity::{Identity, describe_identity};
2629 pub use login::login;
2730 pub use profile::{PublicProfile, update_profile, view_profile};
28pub use repo::{NewRepo, RepoSummary, RepoView, create_repo, list_repos, view_repo};
31+pub use repo::{
32+ NewRepo, RepoEdit, RepoSummary, RepoView, create_repo, delete_repo, list_repos, update_repo,
33+ view_repo,
34+};
2935 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
3036 pub use token::{
3137 IssuedToken, TokenSummary, authenticate_token, issue_token, list_tokens, revoke_token,
src/application/port.rs+21 −1View file
@@ -8,7 +8,7 @@ use std::{path::PathBuf, pin::Pin};
88 use tokio::io::AsyncRead;
99
1010 use crate::domain::{
11 CommitSummary, ObjectId, OrgName, PasswordHash, RefName, RepoName, RepoPath, TreeEntry,
11+ CommitSummary, GitRef, ObjectId, OrgName, PasswordHash, RefName, RepoName, RepoPath, TreeEntry,
1212 };
1313
1414 /// Hashes and verifies passwords.
@@ -339,6 +339,26 @@ pub trait GitQuery: Send + Sync {
339339 rev: &RefName,
340340 limit: usize,
341341 ) -> impl Future<Output = Result<Vec<CommitSummary>, GitQueryError>> + Send;
342+
343+ /// Every branch and every tag, unordered.
344+ ///
345+ /// Ordering is the use case's decision, the same way it is for
346+ /// [`list_tree`](Self::list_tree) — an adapter that sorted would have to be
347+ /// re-taught the order every time it changed.
348+ ///
349+ /// **This costs a whole `git` process** — ~14ms, the most expensive of the read
350+ /// commands measured for the Milestone 5 amendment to
351+ /// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md), because
352+ /// starting the process is the cost. A page that does not show a ref switcher must
353+ /// not call it.
354+ ///
355+ /// An empty repository has no refs and answers with an empty list rather than an
356+ /// error: nothing pushed yet is not a failure.
357+ fn list_refs(
358+ &self,
359+ handle: &OrgName,
360+ name: &RepoName,
361+ ) -> impl Future<Output = Result<Vec<GitRef>, GitQueryError>> + Send;
342362 }
343363
344364 /// A repository could not be read.
src/application/repo.rs+607 −0View file
@@ -201,6 +201,143 @@ pub async fn list_repos(
201201 ))
202202 }
203203
204+/// Resolves a repository the actor is allowed to **change**.
205+///
206+/// Two different answers, on purpose, and the split matters:
207+///
208+/// - A repository the actor may not *see* is [`DomainError::NotFound`], exactly as
209+/// [`view_repo`] returns `None` for it. Saying "forbidden" instead would confirm that
210+/// a private repository by that name exists, which is the thing being protected.
211+/// - A repository the actor *can* see but does not own is [`DomainError::Forbidden`],
212+/// matching [`create_repo`]: the resource is public anyway, so pretending it is
213+/// missing would be theatre.
214+///
215+/// A caller that wants to collapse both into a 404 — the settings page does — can; a
216+/// caller that wants to explain the difference has the information to.
217+async fn changeable_repo(
218+ actor: &Actor,
219+ handle: &OrgName,
220+ name: &RepoName,
221+ orgs: &impl OrgRepository,
222+ memberships: &impl MembershipRepository,
223+ repos: &impl RepoRepository,
224+) -> Result<Repository> {
225+ let missing = || {
226+ Error::Domain(DomainError::NotFound {
227+ entity: "repository",
228+ })
229+ };
230+
231+ let Some(org) = orgs.find_by_name(handle).await? else {
232+ return Err(missing());
233+ };
234+
235+ let Some(repo) = repos.find_by_org_and_name(&org.id, name).await? else {
236+ return Err(missing());
237+ };
238+
239+ if !repo.visibility.is_public() && !is_org_member(&org, actor, memberships).await? {
240+ return Err(missing());
241+ }
242+
243+ if !is_org_owner(&org, actor, memberships).await? {
244+ return Err(DomainError::Forbidden.into());
245+ }
246+
247+ Ok(repo)
248+}
249+
250+/// The changes an owner is asking to make to a repository.
251+///
252+/// Grouped like [`NewRepo`] rather than passed loose, so that adding a settable field
253+/// later does not change every call site — and so the argument list stays readable.
254+///
255+/// **No `name`.** See [`update_repo`].
256+#[derive(Debug, Clone, PartialEq, Eq)]
257+pub struct RepoEdit {
258+ pub description: Option<String>,
259+ pub visibility: Visibility,
260+}
261+
262+/// Changes a repository's description and visibility.
263+///
264+/// Owner only. Seeing a private repository is not permission to change it.
265+///
266+/// **The name is deliberately not changeable here.** A rename is not a column update:
267+/// the bare repo lives at `{data_dir}/{handle}/{name}.git`, so renaming means moving a
268+/// directory while clones, pushes and in-flight requests point at the old path, and it
269+/// needs its own use case with its own answer for the two-writes problem in
270+/// `plans/architecture.md#db-plus-filesystem-writes`. Half-doing it — updating the row
271+/// and leaving the directory — would break every existing clone silently.
272+///
273+/// Returns the saved [`Repository`] so a caller can re-render from what was actually
274+/// stored rather than from what was submitted; the description normalises on the way in.
275+pub async fn update_repo(
276+ actor: &Actor,
277+ handle: &OrgName,
278+ name: &RepoName,
279+ edit: &RepoEdit,
280+ orgs: &impl OrgRepository,
281+ memberships: &impl MembershipRepository,
282+ repos: &impl RepoRepository,
283+) -> Result<Repository> {
284+ let existing = changeable_repo(actor, handle, name, orgs, memberships, repos).await?;
285+
286+ // Rebuilt through `new` rather than assigned field by field, so the description
287+ // length rule lives in exactly one place. The stored name goes back through
288+ // validation as a side effect — acceptable because it was validated on the way in
289+ // and has not changed, and the alternative is a second copy of the rule here.
290+ let updated = Repository::new(
291+ existing.id,
292+ existing.org_id,
293+ existing.name.as_str(),
294+ edit.description.clone(),
295+ edit.visibility,
296+ )?;
297+
298+ repos.save(&updated).await?;
299+
300+ Ok(updated)
301+}
302+
303+/// Deletes a repository: its record, and the bare repo on disk.
304+///
305+/// Owner only, and irreversible — the git history goes with it.
306+///
307+/// **The row goes first, then the directory.** The two writes cannot share a
308+/// transaction, so one of the two orphans is possible, and this picks the less harmful
309+/// one deliberately. An orphaned *directory* only blocks reusing that name, and is
310+/// already the documented failure mode of [`create_repo`]'s compensation path. An
311+/// orphaned *row* is worse and visible: a repository that still lists on the profile and
312+/// 404s the moment anyone clicks it.
313+///
314+/// If the directory cannot be removed this still reports success, because as far as
315+/// Steid is concerned the repository genuinely is gone — there is nothing the caller
316+/// could usefully do about it, and failing here would leave the visitor thinking the
317+/// delete had not happened when the record is already destroyed. The failure is logged.
318+pub async fn delete_repo(
319+ actor: &Actor,
320+ handle: &OrgName,
321+ name: &RepoName,
322+ orgs: &impl OrgRepository,
323+ memberships: &impl MembershipRepository,
324+ repos: &impl RepoRepository,
325+ storage: &impl GitStorage,
326+) -> Result<()> {
327+ let repo = changeable_repo(actor, handle, name, orgs, memberships, repos).await?;
328+
329+ repos.delete(&repo.id).await?;
330+
331+ if let Err(error) = storage.remove(handle, &repo.name).await {
332+ eprintln!(
333+ "steid: repository {handle}/{} deleted, but its directory could not be removed: {error}",
334+ repo.name
335+ );
336+ }
337+
338+ Ok(())
339+}
340+
204341 fn taken() -> Error {
205342 DomainError::AlreadyExists {
206343 entity: "repository",
@@ -249,6 +386,10 @@ mod tests {
249386 async fn save(&self, _repo: &Repository) -> RepositoryResult<()> {
250387 Err(RepositoryError::backend("save failed on purpose"))
251388 }
389+
390+ async fn delete(&self, _id: &RepoId) -> RepositoryResult<()> {
391+ Ok(())
392+ }
252393 }
253394
254395 struct Fixture {
@@ -931,4 +1072,470 @@ mod tests {
9311072 vec!["mine"]
9321073 );
9331074 }
1075+ // --- update_repo -----------------------------------------------------------
1076+
1077+ impl Fixture {
1078+ async fn update(
1079+ &self,
1080+ actor: &Actor,
1081+ name: &str,
1082+ description: Option<&str>,
1083+ visibility: Visibility,
1084+ ) -> Result<Repository> {
1085+ update_repo(
1086+ actor,
1087+ &self.handle,
1088+ &RepoName::new(name).expect("valid name"),
1089+ &RepoEdit {
1090+ description: description.map(str::to_owned),
1091+ visibility,
1092+ },
1093+ &self.orgs,
1094+ &self.memberships,
1095+ &self.repos,
1096+ )
1097+ .await
1098+ }
1099+
1100+ async fn stored(&self, name: &str) -> Option<Repository> {
1101+ let org = self
1102+ .orgs
1103+ .find_by_name(&self.handle)
1104+ .await
1105+ .expect("lookup")
1106+ .expect("the handle exists");
1107+
1108+ self.repos
1109+ .find_by_org_and_name(&org.id, &RepoName::new(name).expect("valid name"))
1110+ .await
1111+ .expect("lookup")
1112+ }
1113+ }
1114+
1115+ #[tokio::test]
1116+ async fn the_owner_changes_the_description_and_the_visibility() {
1117+ let f = fixture().await;
1118+ f.create_with(Visibility::Public, "steid").await;
1119+
1120+ let updated = f
1121+ .update(
1122+ &f.owner,
1123+ "steid",
1124+ Some(" A gitforge. "),
1125+ Visibility::Private,
1126+ )
1127+ .await
1128+ .expect("should update");
1129+
1130+ assert_eq!(updated.description.as_deref(), Some("A gitforge."));
1131+ assert_eq!(updated.visibility, Visibility::Private);
1132+
1133+ let stored = f.stored("steid").await.expect("still there");
1134+ assert_eq!(stored.description.as_deref(), Some("A gitforge."));
1135+ assert_eq!(stored.visibility, Visibility::Private);
1136+ }
1137+
1138+ #[tokio::test]
1139+ async fn a_description_can_be_cleared() {
1140+ let f = fixture().await;
1141+ f.create(
1142+ &f.owner,
1143+ &NewRepo {
1144+ name: "steid".to_owned(),
1145+ description: Some("A gitforge.".to_owned()),
1146+ visibility: Visibility::Public,
1147+ },
1148+ )
1149+ .await
1150+ .expect("should create");
1151+
1152+ f.update(&f.owner, "steid", None, Visibility::Public)
1153+ .await
1154+ .expect("should update");
1155+
1156+ assert_eq!(
1157+ f.stored("steid").await.expect("still there").description,
1158+ None
1159+ );
1160+ }
1161+
1162+ #[tokio::test]
1163+ async fn an_update_never_touches_the_name_or_the_directory() {
1164+ // Renaming is a directory move, not a column update, so it is deliberately not
1165+ // offered here — and an update must not disturb what is on disk.
1166+ let f = fixture().await;
1167+ let created = f.create_with(Visibility::Public, "steid").await;
1168+
1169+ let updated = f
1170+ .update(&f.owner, "steid", Some("changed"), Visibility::Private)
1171+ .await
1172+ .expect("should update");
1173+
1174+ assert_eq!(updated.name, created.name);
1175+ assert_eq!(updated.id, created.id);
1176+ assert!(f.storage.contains(&f.handle, &created.name));
1177+ assert_eq!(f.storage.len(), 1);
1178+ }
1179+
1180+ #[tokio::test]
1181+ async fn a_member_who_is_not_the_owner_cannot_change_a_repository() {
1182+ // The repository is public, so the member can see it; seeing is not changing.
1183+ let f = fixture().await;
1184+ f.create_with(Visibility::Public, "steid").await;
1185+
1186+ let error = f
1187+ .update(&f.member, "steid", Some("mine now"), Visibility::Private)
1188+ .await
1189+ .expect_err("should refuse");
1190+
1191+ assert!(matches!(error, Error::Domain(DomainError::Forbidden)));
1192+ assert_eq!(
1193+ f.stored("steid").await.expect("untouched").visibility,
1194+ Visibility::Public
1195+ );
1196+ }
1197+
1198+ #[tokio::test]
1199+ async fn a_stranger_and_an_anonymous_visitor_cannot_change_a_repository() {
1200+ let f = fixture().await;
1201+ f.create_with(Visibility::Public, "steid").await;
1202+
1203+ for actor in [&Actor::Anonymous, &f.stranger] {
1204+ let error = f
1205+ .update(actor, "steid", Some("mine now"), Visibility::Private)
1206+ .await
1207+ .expect_err("should refuse");
1208+
1209+ assert!(
1210+ matches!(error, Error::Domain(DomainError::Forbidden)),
1211+ "{actor:?} should be refused"
1212+ );
1213+ }
1214+
1215+ assert_eq!(
1216+ f.stored("steid").await.expect("untouched").description,
1217+ None
1218+ );
1219+ }
1220+
1221+ #[tokio::test]
1222+ async fn a_private_repository_is_not_found_rather_than_forbidden_for_an_outsider() {
1223+ // The established rule: "forbidden" would confirm that a private repository by
1224+ // that name exists, which is exactly what private is protecting.
1225+ let f = fixture().await;
1226+ f.create_with(Visibility::Private, "secret").await;
1227+
1228+ for actor in [&Actor::Anonymous, &f.stranger] {
1229+ let error = f
1230+ .update(actor, "secret", None, Visibility::Public)
1231+ .await
1232+ .expect_err("should refuse");
1233+
1234+ assert!(
1235+ matches!(
1236+ error,
1237+ Error::Domain(DomainError::NotFound {
1238+ entity: "repository"
1239+ })
1240+ ),
1241+ "{actor:?} should be told it does not exist"
1242+ );
1243+ }
1244+
1245+ assert_eq!(
1246+ f.stored("secret").await.expect("untouched").visibility,
1247+ Visibility::Private
1248+ );
1249+ }
1250+
1251+ #[tokio::test]
1252+ async fn an_unknown_repository_or_handle_is_not_found() {
1253+ let f = fixture().await;
1254+
1255+ let error = f
1256+ .update(&f.owner, "nothing-here", None, Visibility::Public)
1257+ .await
1258+ .expect_err("should refuse");
1259+
1260+ assert!(matches!(
1261+ error,
1262+ Error::Domain(DomainError::NotFound {
1263+ entity: "repository"
1264+ })
1265+ ));
1266+
1267+ let missing = OrgName::new("nobody").expect("valid handle");
1268+ let error = update_repo(
1269+ &f.owner,
1270+ &missing,
1271+ &RepoName::new("steid").expect("valid"),
1272+ &RepoEdit {
1273+ description: None,
1274+ visibility: Visibility::Public,
1275+ },
1276+ &f.orgs,
1277+ &f.memberships,
1278+ &f.repos,
1279+ )
1280+ .await
1281+ .expect_err("should refuse");
1282+
1283+ assert!(matches!(
1284+ error,
1285+ Error::Domain(DomainError::NotFound {
1286+ entity: "repository"
1287+ })
1288+ ));
1289+ }
1290+
1291+ #[tokio::test]
1292+ async fn an_over_long_description_is_rejected_and_changes_nothing() {
1293+ let f = fixture().await;
1294+ f.create_with(Visibility::Public, "steid").await;
1295+ let long = "a".repeat(Repository::MAX_DESCRIPTION_LEN + 1);
1296+
1297+ let error = f
1298+ .update(&f.owner, "steid", Some(&long), Visibility::Private)
1299+ .await
1300+ .expect_err("should reject");
1301+
1302+ assert!(matches!(
1303+ error,
1304+ Error::Domain(DomainError::Validation { .. })
1305+ ));
1306+ let stored = f.stored("steid").await.expect("untouched");
1307+ assert_eq!(stored.description, None);
1308+ assert_eq!(stored.visibility, Visibility::Public);
1309+ }
1310+
1311+ #[tokio::test]
1312+ async fn making_a_public_repository_private_hides_it_from_outsiders() {
1313+ // The hole this closes: someone who published by accident can un-publish.
1314+ let f = fixture().await;
1315+ f.create_with(Visibility::Public, "oops").await;
1316+ assert!(f.view(&Actor::Anonymous, "oops").await.is_some());
1317+
1318+ f.update(&f.owner, "oops", None, Visibility::Private)
1319+ .await
1320+ .expect("should update");
1321+
1322+ assert!(
1323+ f.view(&Actor::Anonymous, "oops").await.is_none(),
1324+ "it should be absent, not merely unlinked"
1325+ );
1326+ assert!(f.list(&Actor::Anonymous).await.is_empty());
1327+ assert!(f.view(&f.owner, "oops").await.is_some());
1328+ }
1329+
1330+ #[tokio::test]
1331+ async fn making_a_private_repository_public_reveals_it() {
1332+ let f = fixture().await;
1333+ f.create_with(Visibility::Private, "secret").await;
1334+
1335+ f.update(&f.owner, "secret", None, Visibility::Public)
1336+ .await
1337+ .expect("should update");
1338+
1339+ assert!(f.view(&Actor::Anonymous, "secret").await.is_some());
1340+ assert_eq!(
1341+ Fixture::names(&f.list(&Actor::Anonymous).await),
1342+ vec!["secret"]
1343+ );
1344+ }
1345+
1346+ // --- delete_repo -----------------------------------------------------------
1347+
1348+ /// Git storage whose `remove` always fails, for the best-effort path.
1349+ #[derive(Debug, Default)]
1350+ struct FailingRemoveStorage;
1351+
1352+ impl GitStorage for FailingRemoveStorage {
1353+ async fn init_bare(
1354+ &self,
1355+ _handle: &OrgName,
1356+ _name: &RepoName,
1357+ ) -> std::result::Result<(), GitStorageError> {
1358+ Ok(())
1359+ }
1360+
1361+ async fn remove(
1362+ &self,
1363+ _handle: &OrgName,
1364+ _name: &RepoName,
1365+ ) -> std::result::Result<(), GitStorageError> {
1366+ Err(GitStorageError::backend("remove failed on purpose"))
1367+ }
1368+
1369+ fn repo_path(&self, handle: &OrgName, name: &RepoName) -> std::path::PathBuf {
1370+ std::path::PathBuf::from(format!("{handle}/{name}.git"))
1371+ }
1372+ }
1373+
1374+ impl Fixture {
1375+ async fn delete(&self, actor: &Actor, name: &str) -> Result<()> {
1376+ delete_repo(
1377+ actor,
1378+ &self.handle,
1379+ &RepoName::new(name).expect("valid name"),
1380+ &self.orgs,
1381+ &self.memberships,
1382+ &self.repos,
1383+ &self.storage,
1384+ )
1385+ .await
1386+ }
1387+ }
1388+
1389+ #[tokio::test]
1390+ async fn the_owner_deletes_the_record_and_the_bare_repo() {
1391+ let f = fixture().await;
1392+ f.create_with(Visibility::Public, "steid").await;
1393+
1394+ f.delete(&f.owner, "steid").await.expect("should delete");
1395+
1396+ assert!(f.stored("steid").await.is_none());
1397+ assert!(f.storage.is_empty(), "the directory should be gone too");
1398+ assert!(f.view(&f.owner, "steid").await.is_none());
1399+ assert!(f.list(&f.owner).await.is_empty());
1400+ }
1401+
1402+ #[tokio::test]
1403+ async fn a_non_owner_deletes_neither_the_record_nor_the_directory() {
1404+ let f = fixture().await;
1405+ let repo = f.create_with(Visibility::Public, "steid").await;
1406+
1407+ for actor in [&Actor::Anonymous, &f.stranger, &f.member] {
1408+ let error = f.delete(actor, "steid").await.expect_err("should refuse");
1409+
1410+ assert!(
1411+ matches!(error, Error::Domain(DomainError::Forbidden)),
1412+ "{actor:?} should be refused"
1413+ );
1414+ }
1415+
1416+ assert!(f.stored("steid").await.is_some());
1417+ assert!(f.storage.contains(&f.handle, &repo.name));
1418+ }
1419+
1420+ #[tokio::test]
1421+ async fn a_private_repository_is_not_found_for_an_outsider_asking_to_delete_it() {
1422+ let f = fixture().await;
1423+ let repo = f.create_with(Visibility::Private, "secret").await;
1424+
1425+ let error = f
1426+ .delete(&f.stranger, "secret")
1427+ .await
1428+ .expect_err("should refuse");
1429+
1430+ assert!(matches!(
1431+ error,
1432+ Error::Domain(DomainError::NotFound {
1433+ entity: "repository"
1434+ })
1435+ ));
1436+ assert!(f.stored("secret").await.is_some());
1437+ assert!(f.storage.contains(&f.handle, &repo.name));
1438+ }
1439+
1440+ #[tokio::test]
1441+ async fn deleting_a_repository_that_does_not_exist_is_not_found() {
1442+ let f = fixture().await;
1443+
1444+ let error = f
1445+ .delete(&f.owner, "nothing-here")
1446+ .await
1447+ .expect_err("should refuse");
1448+
1449+ assert!(matches!(
1450+ error,
1451+ Error::Domain(DomainError::NotFound {
1452+ entity: "repository"
1453+ })
1454+ ));
1455+ }
1456+
1457+ #[tokio::test]
1458+ async fn deleting_one_repository_leaves_the_others_alone() {
1459+ let f = fixture().await;
1460+ f.create_with(Visibility::Public, "keep").await;
1461+ f.create_with(Visibility::Public, "drop").await;
1462+
1463+ f.delete(&f.owner, "drop").await.expect("should delete");
1464+
1465+ assert_eq!(Fixture::names(&f.list(&f.owner).await), vec!["keep"]);
1466+ assert_eq!(f.storage.len(), 1);
1467+ }
1468+
1469+ #[tokio::test]
1470+ async fn a_name_freed_by_deletion_can_be_created_again() {
1471+ let f = fixture().await;
1472+ f.create_with(Visibility::Public, "steid").await;
1473+ f.delete(&f.owner, "steid").await.expect("should delete");
1474+
1475+ let recreated = f
1476+ .create(&f.owner, &spec("steid"))
1477+ .await
1478+ .expect("the name should be free again");
1479+
1480+ assert!(f.storage.contains(&f.handle, &recreated.name));
1481+ }
1482+
1483+ #[tokio::test]
1484+ async fn a_directory_that_will_not_delete_still_reports_success() {
1485+ // The row goes first and is already gone; as far as Steid is concerned the
1486+ // repository is deleted, and there is nothing the caller could do about the
1487+ // leftover directory. The failure is logged, not returned.
1488+ let f = fixture().await;
1489+ f.create_with(Visibility::Public, "steid").await;
1490+
1491+ delete_repo(
1492+ &f.owner,
1493+ &f.handle,
1494+ &RepoName::new("steid").expect("valid"),
1495+ &f.orgs,
1496+ &f.memberships,
1497+ &f.repos,
1498+ &FailingRemoveStorage,
1499+ )
1500+ .await
1501+ .expect("should still report success");
1502+
1503+ assert!(f.stored("steid").await.is_none());
1504+ }
1505+
1506+ /// The real adapter, so that "the directory is gone" is more than a fake's opinion.
1507+ #[tokio::test]
1508+ async fn against_real_disk_storage_delete_removes_the_directory() {
1509+ let f = fixture().await;
1510+ let dir = tempfile::TempDir::new().expect("temp dir");
1511+ let storage = DiskGitStorage::new(dir.path());
1512+
1513+ create_repo(
1514+ &f.owner,
1515+ &f.handle,
1516+ &spec("steid"),
1517+ &f.orgs,
1518+ &f.memberships,
1519+ &f.repos,
1520+ &storage,
1521+ )
1522+ .await
1523+ .expect("should create");
1524+ assert!(dir.path().join("acme").join("steid.git").is_dir());
1525+
1526+ delete_repo(
1527+ &f.owner,
1528+ &f.handle,
1529+ &RepoName::new("steid").expect("valid"),
1530+ &f.orgs,
1531+ &f.memberships,
1532+ &f.repos,
1533+ &storage,
1534+ )
1535+ .await
1536+ .expect("should delete");
1537+
1538+ assert!(!dir.path().join("acme").join("steid.git").exists());
1539+ assert!(f.stored("steid").await.is_none());
1540+ }
9341541 }
src/domain/mod.rs+1 −1View file
@@ -24,7 +24,7 @@ pub use email::Email;
2424 pub use error::DomainError;
2525 pub use id::{MembershipId, OrgId, RepoId, TokenId, UserId};
2626 pub use membership::{Membership, Role};
27pub use object::{CommitSummary, EntryKind, ObjectId, TreeEntry};
27+pub use object::{CommitSummary, EntryKind, GitRef, ObjectId, RefKind, TreeEntry};
2828 pub use org::{OrgName, Organization};
2929 pub use password::PasswordHash;
3030 pub use reference::{RefName, RepoPath};
src/domain/object.rs+23 −1View file
@@ -7,7 +7,7 @@
77
88 use std::{fmt, time::SystemTime};
99
10use super::DomainError;
10+use super::{DomainError, RefName};
1111
1212 /// The id of a git object, hex-encoded.
1313 ///
@@ -147,6 +147,28 @@ impl TreeEntry {
147147 }
148148 }
149149
150+/// Whether a ref is a branch or a tag.
151+///
152+/// The two are told apart by which namespace the ref lives in, not by what it points
153+/// at: a lightweight tag and a branch both point straight at a commit, so the object
154+/// says nothing about which one a visitor asked for.
155+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156+pub enum RefKind {
157+ Branch,
158+ Tag,
159+}
160+
161+/// A branch or a tag, short-named as a switcher shows it.
162+///
163+/// Short (`main`, not `refs/heads/main`) because that is what the browse URLs take and
164+/// what a person recognises. The ambiguity a full name would resolve — a branch and a
165+/// tag sharing a name — is carried by [`kind`](Self::kind) instead.
166+#[derive(Debug, Clone, PartialEq, Eq)]
167+pub struct GitRef {
168+ pub name: RefName,
169+ pub kind: RefKind,
170+}
171+
150172 /// A commit, reduced to what a log entry shows.
151173 #[derive(Debug, Clone, PartialEq, Eq)]
152174 pub struct CommitSummary {
src/domain/repository/repo_repo.rs+10 −0View file
@@ -29,4 +29,14 @@ pub trait RepoRepository: Send + Sync {
2929 ///
3030 /// The owning organisation must already exist; the foreign key runs that direction.
3131 fn save(&self, repo: &Repository) -> impl Future<Output = RepositoryResult<()>> + Send;
32+
33+ /// Deletes a repository, succeeding if there was nothing to delete.
34+ ///
35+ /// A delete rather than a flag, for the same reason token revocation is: a row that
36+ /// lingers is a repository that stops appearing only as long as every read remembers
37+ /// to check. The bare repo on disk is a separate write and not this port's business.
38+ ///
39+ /// Succeeding on a missing row keeps the caller idempotent — two clicks on a delete
40+ /// button must not turn the second one into an error.
41+ fn delete(&self, id: &RepoId) -> impl Future<Output = RepositoryResult<()>> + Send;
3242 }
src/infrastructure/git.rs+30 −1View file
@@ -23,7 +23,9 @@ use crate::{
2323 Blob, GitMethod, GitProtocolError, GitProtocolServer, GitQuery, GitQueryError, GitRequest,
2424 GitResponse, GitStorage, GitStorageError,
2525 },
26 domain::{CommitSummary, ObjectId, OrgName, RefName, RepoName, RepoPath, TreeEntry},
26+ domain::{
27+ CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath, TreeEntry,
28+ },
2729 };
2830
2931 /// The most CGI headers `git http-backend` will ever emit, with room to spare.
@@ -480,6 +482,9 @@ pub struct InMemoryGitQuery {
480482 trees: HashMap<String, Vec<TreeEntry>>,
481483 blobs: HashMap<String, Vec<u8>>,
482484 commits: Vec<CommitSummary>,
485+ /// Branches and tags, in whatever order a test seeded them — the real adapter makes
486+ /// no ordering promise either.
487+ refs: Vec<GitRef>,
483488 }
484489
485490 impl InMemoryGitQuery {
@@ -520,6 +525,22 @@ impl InMemoryGitQuery {
520525 self.commits = commits;
521526 self
522527 }
528+
529+ pub fn with_branch(self, name: &str) -> Self {
530+ self.with_ref(name, RefKind::Branch)
531+ }
532+
533+ pub fn with_tag(self, name: &str) -> Self {
534+ self.with_ref(name, RefKind::Tag)
535+ }
536+
537+ fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
538+ self.refs.push(GitRef {
539+ name: RefName::from_trusted(name),
540+ kind,
541+ });
542+ self
543+ }
523544 }
524545
525546 impl GitQuery for InMemoryGitQuery {
@@ -583,6 +604,14 @@ impl GitQuery for InMemoryGitQuery {
583604 ) -> Result<Vec<CommitSummary>, GitQueryError> {
584605 Ok(self.commits.iter().take(limit).cloned().collect())
585606 }
607+
608+ async fn list_refs(
609+ &self,
610+ _handle: &OrgName,
611+ _name: &RepoName,
612+ ) -> Result<Vec<GitRef>, GitQueryError> {
613+ Ok(self.refs.clone())
614+ }
586615 }
587616
588617 #[cfg(test)]
src/infrastructure/git_query.rs+209 −1View file
@@ -39,7 +39,10 @@ use tokio::io::AsyncWriteExt;
3939
4040 use crate::{
4141 application::port::{Blob, GitQuery, GitQueryError},
42 domain::{CommitSummary, EntryKind, ObjectId, OrgName, RefName, RepoName, RepoPath, TreeEntry},
42+ domain::{
43+ CommitSummary, EntryKind, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
44+ TreeEntry,
45+ },
4346 infrastructure::git::git_command,
4447 };
4548
@@ -254,6 +257,35 @@ impl GitQuery for DiskGitQuery {
254257
255258 parse_log(&output.stdout)
256259 }
260+
261+ async fn list_refs(
262+ &self,
263+ handle: &OrgName,
264+ name: &RepoName,
265+ ) -> Result<Vec<GitRef>, GitQueryError> {
266+ let repo = self.repo_path(handle, name);
267+
268+ // One fork, ~14ms — see the port's note. `for-each-ref` is asked for both
269+ // namespaces at once rather than once each, because the cost here is the
270+ // process, not the question.
271+ //
272+ // Every field separator is a NUL, for the same reason `log` uses one: a tag name
273+ // is close to arbitrary text once git's own restrictions are met, and splitting
274+ // on whitespace would misread a real name. The patterns are literals, so unlike
275+ // a revision from a URL there is nothing here that could be read as a flag.
276+ let output = run(
277+ &repo,
278+ [
279+ OsStr::new("for-each-ref"),
280+ OsStr::new(REF_FORMAT),
281+ OsStr::new("refs/heads/"),
282+ OsStr::new("refs/tags/"),
283+ ],
284+ )
285+ .await?;
286+
287+ Ok(parse_refs(&output.stdout))
288+ }
257289 }
258290
259291 /// What `cat-file --batch-check` said about one object.
@@ -487,6 +519,61 @@ fn unix_time(seconds: i64) -> SystemTime {
487519 }
488520 }
489521
522+/// What `for-each-ref` prints per ref: the full name and the object it names,
523+/// NUL-separated and NUL-terminated.
524+///
525+/// The kind is *not* asked for. `%(objecttype)` says `commit` for both a branch and a
526+/// lightweight tag, so the namespace in the name is the only thing that answers which
527+/// one a visitor asked for.
528+const REF_FORMAT: &str = "--format=%(refname)%00";
529+
530+/// Parses `for-each-ref`'s NUL-separated output into branches and tags.
531+///
532+/// Each record is `<full refname> NUL`, and git ends every record with a newline of its
533+/// own that the format cannot suppress — so the newline arrives at the *front* of the
534+/// next record's first field and is trimmed off. A ref name can contain neither a
535+/// newline nor a space, so trimming cannot eat part of a name.
536+fn parse_refs(stdout: &[u8]) -> Vec<GitRef> {
537+ let mut refs = Vec::new();
538+
539+ for record in stdout.split(|byte| *byte == 0) {
540+ let record = record.trim_ascii();
541+
542+ if record.is_empty() {
543+ continue;
544+ }
545+
546+ // Lossy would be wrong here: a name that is not UTF-8 cannot be put in a URL,
547+ // and offering a link that cannot work is worse than leaving the ref out of the
548+ // switcher. It is still browsable by object id.
549+ let Ok(full) = std::str::from_utf8(record) else {
550+ continue;
551+ };
552+
553+ let (kind, short) = if let Some(short) = full.strip_prefix("refs/heads/") {
554+ (RefKind::Branch, short)
555+ } else if let Some(short) = full.strip_prefix("refs/tags/") {
556+ (RefKind::Tag, short)
557+ } else {
558+ // Only the two namespaces were asked for, so this cannot happen — and if a
559+ // future pattern is added and this is forgotten, skipping is the safe half
560+ // of the mistake.
561+ continue;
562+ };
563+
564+ // Validated rather than trusted: this name is about to become a URL, and
565+ // `RefName` is what decides a name is safe to hand back to git. A ref git
566+ // accepts but Steid's rules do not is left out rather than linked to.
567+ let Ok(name) = RefName::new(short) else {
568+ continue;
569+ };
570+
571+ refs.push(GitRef { name, kind });
572+ }
573+
574+ refs
575+}
576+
490577 /// Runs a git command inside a repository and fails on a non-zero exit.
491578 ///
492579 /// Only ever used for commands whose subject has already been confirmed to exist, so a
@@ -1301,6 +1388,127 @@ mod tests {
13011388 assert_eq!(from_second[0].id, all[1].id);
13021389 }
13031390
1391+ // --- list_refs ---------------------------------------------------------------
1392+
1393+ /// The populated repository with a second branch and two tags pushed into it — one
1394+ /// lightweight, one annotated, because they are different objects and the switcher
1395+ /// must not care.
1396+ fn with_refs() -> (TempDir, DiskGitQuery) {
1397+ let (dir, query) = populated();
1398+ let repo = query.repo_path(&handle(), &repo_name());
1399+ let work = dir.path().join("work");
1400+ let target = repo.to_str().expect("utf-8 fixture path").to_owned();
1401+
1402+ // A slash in the name, because that is what makes a ref name interesting: it is
1403+ // the case the `/-/` separator in the URL exists for.
1404+ git(&work, THIRD_COMMIT, &["branch", "feature/login"]);
1405+ git(&work, THIRD_COMMIT, &["tag", "v1.0"]);
1406+ git(
1407+ &work,
1408+ THIRD_COMMIT,
1409+ &["tag", "-a", "v2.0", "-m", "second release"],
1410+ );
1411+ git(
1412+ &work,
1413+ THIRD_COMMIT,
1414+ &["push", "--quiet", &target, "feature/login"],
1415+ );
1416+ git(&work, THIRD_COMMIT, &["push", "--quiet", &target, "--tags"]);
1417+
1418+ (dir, query)
1419+ }
1420+
1421+ fn named(refs: &[GitRef], kind: RefKind) -> Vec<String> {
1422+ let mut names: Vec<String> = refs
1423+ .iter()
1424+ .filter(|git_ref| git_ref.kind == kind)
1425+ .map(|git_ref| git_ref.name.to_string())
1426+ .collect();
1427+
1428+ // The port promises no order, so a test that asserted one would be asserting
1429+ // something the adapter is free to change.
1430+ names.sort();
1431+ names
1432+ }
1433+
1434+ #[tokio::test]
1435+ async fn branches_and_tags_are_listed_and_told_apart() {
1436+ let (_dir, query) = with_refs();
1437+
1438+ let refs = query
1439+ .list_refs(&handle(), &repo_name())
1440+ .await
1441+ .expect("should read");
1442+
1443+ assert_eq!(
1444+ named(&refs, RefKind::Branch),
1445+ vec!["feature/login".to_owned(), "main".to_owned()]
1446+ );
1447+ // An annotated tag points at a tag object rather than a commit, and a
1448+ // lightweight one points straight at the commit. Both are tags.
1449+ assert_eq!(
1450+ named(&refs, RefKind::Tag),
1451+ vec!["v1.0".to_owned(), "v2.0".to_owned()]
1452+ );
1453+ }
1454+
1455+ #[tokio::test]
1456+ async fn a_repository_with_one_branch_lists_just_it() {
1457+ let (_dir, query) = populated();
1458+
1459+ let refs = query
1460+ .list_refs(&handle(), &repo_name())
1461+ .await
1462+ .expect("should read");
1463+
1464+ assert_eq!(refs.len(), 1);
1465+ assert_eq!(refs[0].name.as_str(), "main");
1466+ assert_eq!(refs[0].kind, RefKind::Branch);
1467+ }
1468+
1469+ #[tokio::test]
1470+ async fn an_empty_repository_lists_no_refs() {
1471+ // HEAD names `main`, but no ref exists, so there is nothing to switch to. An
1472+ // empty list rather than an error: nothing pushed yet is not a failure.
1473+ let (_dir, query) = empty();
1474+
1475+ assert_eq!(
1476+ query
1477+ .list_refs(&handle(), &repo_name())
1478+ .await
1479+ .expect("should read"),
1480+ Vec::new()
1481+ );
1482+ }
1483+
1484+ #[tokio::test]
1485+ async fn listing_refs_of_a_repository_that_is_not_on_disk_is_an_error() {
1486+ let (_dir, query) = empty();
1487+ let missing = RepoName::new("never-created").expect("valid repository name");
1488+
1489+ assert!(query.list_refs(&handle(), &missing).await.is_err());
1490+ }
1491+
1492+ #[test]
1493+ fn refs_are_parsed_from_nul_terminated_records() {
1494+ // git ends each record with a newline the format cannot suppress, so it arrives
1495+ // in front of the next record's name. Anything outside the two namespaces is
1496+ // dropped rather than guessed at.
1497+ let stdout = b"refs/heads/main\0\nrefs/tags/v1.0\0\nrefs/pull/7/head\0\n";
1498+ let refs = parse_refs(stdout);
1499+
1500+ assert_eq!(refs.len(), 2);
1501+ assert_eq!(refs[0].name.as_str(), "main");
1502+ assert_eq!(refs[0].kind, RefKind::Branch);
1503+ assert_eq!(refs[1].name.as_str(), "v1.0");
1504+ assert_eq!(refs[1].kind, RefKind::Tag);
1505+ }
1506+
1507+ #[test]
1508+ fn nothing_is_parsed_from_an_empty_listing() {
1509+ assert!(parse_refs(b"").is_empty());
1510+ }
1511+
13041512 // --- helpers ------------------------------------------------------------------
13051513
13061514 #[tokio::test]
src/infrastructure/repository/in_memory.rs+71 −0View file
@@ -425,6 +425,71 @@ mod tests {
425425 );
426426 }
427427
428+ // --- InMemoryRepoRepo --------------------------------------------------------
429+
430+ #[tokio::test]
431+ async fn the_fake_forgets_a_deleted_repository() {
432+ let repos = InMemoryRepoRepo::new();
433+ let org_id = OrgId::generate();
434+ let repo = Repository::new(
435+ RepoId::generate(),
436+ org_id.clone(),
437+ "steid",
438+ None,
439+ crate::domain::Visibility::Public,
440+ )
441+ .expect("valid repo");
442+ repos.save(&repo).await.expect("save");
443+
444+ repos.delete(&repo.id).await.expect("delete");
445+
446+ assert_eq!(repos.find_by_id(&repo.id).await.expect("lookup"), None);
447+ assert!(
448+ repos
449+ .find_by_org_and_name(&org_id, &repo.name)
450+ .await
451+ .expect("lookup")
452+ .is_none(),
453+ "the name should be free again"
454+ );
455+ assert!(repos.list_by_org(&org_id).await.expect("list").is_empty());
456+ }
457+
458+ #[tokio::test]
459+ async fn deleting_a_repository_that_is_not_there_succeeds() {
460+ // Idempotent: a second click on a delete button must not become an error.
461+ let repos = InMemoryRepoRepo::new();
462+
463+ assert!(repos.delete(&RepoId::generate()).await.is_ok());
464+ }
465+
466+ #[tokio::test]
467+ async fn deleting_one_repository_leaves_the_others() {
468+ let repos = InMemoryRepoRepo::new();
469+ let org_id = OrgId::generate();
470+ let mut saved = Vec::new();
471+ for name in ["alpha", "zebra"] {
472+ let repo = Repository::new(
473+ RepoId::generate(),
474+ org_id.clone(),
475+ name,
476+ None,
477+ crate::domain::Visibility::Public,
478+ )
479+ .expect("valid repo");
480+ repos.save(&repo).await.expect("save");
481+ saved.push(repo);
482+ }
483+
484+ repos.delete(&saved[0].id).await.expect("delete");
485+
486+ let listed = repos.list_by_org(&org_id).await.expect("list");
487+ assert_eq!(
488+ listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
489+ vec!["zebra"]
490+ );
491+ }
492+
428493 #[tokio::test]
429494 async fn the_fake_forgets_a_deleted_token() {
430495 let tokens = InMemoryTokenRepo::new();
@@ -528,4 +593,10 @@ impl RepoRepository for InMemoryRepoRepo {
528593 repos.insert(repo.id.as_str().to_owned(), repo.clone());
529594 Ok(())
530595 }
596+
597+ async fn delete(&self, id: &RepoId) -> RepositoryResult<()> {
598+ let mut repos = self.repos.lock().expect("lock poisoned");
599+ repos.remove(id.as_str());
600+ Ok(())
601+ }
531602 }
src/infrastructure/repository/sqlite.rs+52 −0View file
@@ -388,6 +388,16 @@ impl RepoRepository for SqliteRepoRepo {
388388
389389 Ok(())
390390 }
391+
392+ async fn delete(&self, id: &RepoId) -> RepositoryResult<()> {
393+ sqlx::query("delete from repositories where id = ?")
394+ .bind(id.as_str())
395+ .execute(&self.pool)
396+ .await
397+ .map_err(backend)?;
398+
399+ Ok(())
400+ }
391401 }
392402
393403 #[derive(Debug, Clone)]
@@ -915,6 +925,48 @@ mod tests {
915925 assert_eq!(found, None);
916926 }
917927
928+ #[tokio::test]
929+ async fn a_deleted_repository_is_gone_and_frees_its_name() {
930+ let pool = test_pool().await;
931+ let orgs = SqliteOrgRepo::new(pool.clone());
932+ let repos = SqliteRepoRepo::new(pool);
933+ let org = org_with(&orgs, "acme").await;
934+ let repo = saved_repo(&repos, &org, "steid", Visibility::Public).await;
935+
936+ repos.delete(&repo.id).await.expect("delete");
937+
938+ assert_eq!(repos.find_by_id(&repo.id).await.expect("lookup"), None);
939+ // The unique (org_id, name) pair is what would fail if the row lingered.
940+ saved_repo(&repos, &org, "steid", Visibility::Private).await;
941+ }
942+
943+ #[tokio::test]
944+ async fn deleting_a_repository_that_is_not_there_succeeds() {
945+ // Idempotent, matching the fake: a second click must not become an error.
946+ let pool = test_pool().await;
947+ let repos = SqliteRepoRepo::new(pool);
948+
949+ assert!(repos.delete(&RepoId::generate()).await.is_ok());
950+ }
951+
952+ #[tokio::test]
953+ async fn deleting_one_repository_leaves_the_others() {
954+ let pool = test_pool().await;
955+ let orgs = SqliteOrgRepo::new(pool.clone());
956+ let repos = SqliteRepoRepo::new(pool);
957+ let org = org_with(&orgs, "acme").await;
958+ let doomed = saved_repo(&repos, &org, "alpha", Visibility::Public).await;
959+ saved_repo(&repos, &org, "zebra", Visibility::Public).await;
960+
961+ repos.delete(&doomed.id).await.expect("delete");
962+
963+ let listed = repos.list_by_org(&org.id).await.expect("list");
964+ assert_eq!(
965+ listed.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
966+ vec!["zebra"]
967+ );
968+ }
969+
918970 // --- SqliteTokenRepo ---------------------------------------------------------
919971
920972 /// A pool plus a user to hang tokens off, since the foreign key runs that way.
src/infrastructure/web/browse.rs+492 −15View file
@@ -13,6 +13,11 @@
1313 //! One route serves both directories and files. Which one a path is, is git's answer,
1414 //! not the URL's, and a link that had to know would be wrong the moment a file became
1515 //! a directory.
16+//!
17+//! A fifth route, `/raw/{rev}/-/{*path}`, serves a file's bytes verbatim. It is not a
18+//! page: it hands back arbitrary bytes an untrusted person put in a repository, so its
19+//! response headers are a security policy rather than a convenience — see
20+//! [`raw_response`].
1621
1722 use std::time::{SystemTime, UNIX_EPOCH};
1823
@@ -21,14 +26,20 @@ use topcoat::{
2126 context::Cx,
2227 icon::{icon, iconify::iconify_icon},
2328 router::{
29+ Body, Response, StatusCode,
2430 error::{RouterErrorExt, not_found},
25 page, path_param,
31+ header::{CONTENT_DISPOSITION, CONTENT_TYPE},
32+ page, path_param, route,
2633 },
27 view::{attributes, component, view},
34+ view::{View, attributes, component, view},
2835 };
2936
3037 use crate::{
31 application::{Browsed, FileView, RepoView, browse_repo, repo_log},
38+ application::{
39+ Browsed, FileView, RepoView,
40+ browse::{RawFile, RefList, list_refs, read_raw_file},
41+ browse_repo, repo_log,
42+ },
3243 components::badge::{BadgeVariant, badge},
3344 domain::{CommitSummary, EntryKind, RefName, RepoPath, TreeEntry},
3445 };
@@ -93,6 +104,51 @@ async fn log_rev_page(cx: &Cx) -> Result {
93104 view! { history(rev: Some(rev)) }
94105 }
95106
107+/// A file's bytes, exactly as they are stored.
108+///
109+/// A route rather than a page: the response is a file, not a document, so no layout
110+/// wraps it. `/-/` separates the revision from the path for the same reason the tree
111+/// route does — both can contain slashes and they sit adjacent.
112+///
113+/// Authorized through [`read_raw_file`], which goes through the same `view_repo` every
114+/// browse page does, so a private repository answers 404 here exactly as it does there.
115+#[route(GET "/{handle}/repos/{name}/raw/{rev}/-/{*path}")]
116+async fn raw_page(cx: &Cx) -> Result<Response<Body>> {
117+ let rev = rev_param(cx)?;
118+ let path = path_arg(cx)?;
119+ let repo = repo_for(cx).await?;
120+
121+ let raw = read_raw_file(
122+ &repo.handle,
123+ &repo.name,
124+ Some(&rev),
125+ &path,
126+ &current_actor(cx).await?,
127+ &orgs(cx),
128+ &memberships(cx),
129+ &repos(cx),
130+ &queries(cx),
131+ )
132+ .await
133+ .map_err(server_error)?
134+ .ok_or_not_found()?;
135+
136+ match raw {
137+ RawFile::Ready { name, content } => raw_response(&name, content),
138+ // The only case that is neither a file nor a 404. Its body is Steid's own text,
139+ // never the repository's, so it is the one raw response that may name a type.
140+ RawFile::TooLarge { size } => Response::builder()
141+ .status(StatusCode::PAYLOAD_TOO_LARGE)
142+ .header(CONTENT_TYPE, "text/plain; charset=utf-8")
143+ .header(NOSNIFF.0, NOSNIFF.1)
144+ .body(Body::from(format!(
145+ "This file is {}, which is larger than this instance serves raw. Clone the repository to read it.\n",
146+ size_of(size)
147+ )))
148+ .map_err(server_error),
149+ }
150+}
151+
96152 /// Reads a path in a repository, or 404.
97153 ///
98154 /// Shared with the repository page, which browses the default branch at the root.
@@ -118,6 +174,28 @@ pub(super) async fn browsed_at(
118174 .ok_or_not_found()?)
119175 }
120176
177+/// The branches and tags of a repository the viewer can already see, or 404.
178+///
179+/// **One extra `git` process, ~14ms**, on top of the two or three a browse already
180+/// spends — see the Milestone 5 amendment to
181+/// [0006](../../plans/decisions/0006-git-binary-behind-narrow-ports.md). Called only by
182+/// the pages that show the switcher, and skipped for a repository with no commits,
183+/// where there is nothing to list.
184+async fn refs_for(cx: &Cx, repo: &RepoView) -> Result<RefList> {
185+ Ok(list_refs(
186+ &repo.handle,
187+ &repo.name,
188+ &current_actor(cx).await?,
189+ &orgs(cx),
190+ &memberships(cx),
191+ &repos(cx),
192+ &queries(cx),
193+ )
194+ .await
195+ .map_err(server_error)?
196+ .ok_or_not_found()?)
197+}
198+
121199 /// The tree and blob pages, which differ only in what git found at the path.
122200 ///
123201 /// A component rather than a plain function because `view!` needs the request context
@@ -129,8 +207,27 @@ async fn browsing(cx: &Cx, rev: Option<RefName>, path: RepoPath) -> Result {
129207 let browsed = browsed_at(cx, &repo, rev.as_ref(), &path).await?;
130208 let clone = clone_url_for(cx, &repo);
131209
210+ // An empty repository has no refs, so the fork is not spent asking.
211+ let refs = match browsed {
212+ Browsed::Empty => RefList::default(),
213+ _ => refs_for(cx, &repo).await?,
214+ };
215+
216+ let at = browsed_rev(&browsed);
217+ let switch = Switch::Tree(&path);
218+ let handle = repo.handle.as_str();
219+ let name = repo.name.as_str();
220+ let known = RefName::new(at).is_ok_and(|at| refs.contains(&at));
221+ let branches = ref_links(handle, name, &refs.branches, at, &switch);
222+ let tags = ref_links(handle, name, &refs.tags, at, &switch);
223+
132224 view! {
133 repo_bar(repo: &repo, rev: browsed_rev(&browsed), active: Tab::Files)
225+ repo_bar(
226+ repo: &repo,
227+ rev: at,
228+ active: Tab::Files,
229+ rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
230+ )
134231
135232 match &browsed {
136233 Browsed::Empty => {
@@ -174,11 +271,23 @@ async fn history(cx: &Cx, rev: Option<RefName>) -> Result {
174271 .map_err(server_error)?
175272 .ok_or_not_found()?;
176273
274+ let refs = refs_for(cx, &repo).await?;
275+ let at = rev.as_ref().map(RefName::as_str).unwrap_or_default();
276+ let handle = repo.handle.as_str();
277+ let name = repo.name.as_str();
278+ let known = RefName::new(at).is_ok_and(|at| refs.contains(&at));
279+ let branches = ref_links(handle, name, &refs.branches, at, &Switch::Log);
280+ let tags = ref_links(handle, name, &refs.tags, at, &Switch::Log);
281+
177282 view! {
178283 repo_bar(
179284 repo: &repo,
180 rev: rev.as_ref().map(RefName::as_str).unwrap_or_default(),
285+ rev: at,
181286 active: Tab::Log,
287+ // At the default branch the URL names no revision and `repo_log` does not
288+ // report the one it resolved, so the switcher opens with nothing marked
289+ // current rather than guessing. Noted in `plans/current.md`.
290+ rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
182291 )
183292 commit_log(commits: &log)
184293 }
@@ -192,6 +301,83 @@ fn browsed_rev(browsed: &Browsed) -> &str {
192301 }
193302 }
194303
304+// --- Serving bytes ----------------------------------------------------------------
305+
306+/// The header that stops a browser second-guessing a `Content-Type`.
307+///
308+/// A pair rather than a constant string so the name is written once; `http` has no
309+/// constant for it.
310+const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
311+
312+/// What a raw file is served as.
313+///
314+/// **Never the file's own type, and never guessed from its extension.** Steid serves
315+/// repository contents from the same origin as the application, so a file the origin
316+/// labels `text/html` runs *as this site*: it reads the session cookie, calls Steid's
317+/// own endpoints as the viewer, and rewrites the page around it. Somebody pushing
318+/// `evil.html` would then have stored XSS on every visitor who followed a link to it.
319+/// The same holds for SVG (scriptable), XML, and anything a browser will render.
320+///
321+/// Today only the repository's owner can push, so the only person who could attack a
322+/// viewer is the person whose site it is. Milestone 7 adds other users and this endpoint
323+/// will outlive that assumption, so the policy is written for the world where the bytes
324+/// are hostile.
325+///
326+/// Four headers, each closing a different door:
327+///
328+/// - `application/octet-stream` — a type no browser renders. Not `text/plain`, which
329+/// *is* rendered, and which older browsers have been talked into sniffing as HTML.
330+/// - `nosniff` — without it a browser is free to ignore the type above and decide from
331+/// the content, which is exactly the guess this policy refuses to make.
332+/// - `Content-Disposition: attachment` — the file is saved, not shown, so nothing it
333+/// contains is ever parsed in this origin's context. It also stops a same-origin
334+/// `<iframe>` from rendering it.
335+/// - `Content-Security-Policy: default-src 'none'; sandbox` — belt and braces for the
336+/// case where one of the above is wrong or unsupported. Nothing in the response may
337+/// load, run, or navigate.
338+///
339+/// The cost is that a raw URL downloads rather than displays. That is the correct trade
340+/// for a forge serving other people's bytes, and it is what `curl` wants anyway.
341+fn raw_response(name: &str, content: Vec<u8>) -> Result<Response<Body>> {
342+ Response::builder()
343+ .header(CONTENT_TYPE, "application/octet-stream")
344+ .header(NOSNIFF.0, NOSNIFF.1)
345+ .header(CONTENT_DISPOSITION, disposition(name))
346+ .header("content-security-policy", "default-src 'none'; sandbox")
347+ .body(Body::from(content))
348+ .map_err(server_error)
349+}
350+
351+/// The `Content-Disposition` for a downloaded file.
352+///
353+/// Two filenames, per RFC 6266: a plain `filename` every client understands, and a
354+/// `filename*` carrying the real name as percent-encoded UTF-8 for those that do. The
355+/// plain one is reduced to characters that cannot end the quoted string or be read as a
356+/// header of their own — a name is repository content, so a quote or a newline in it
357+/// would otherwise be header injection.
358+fn disposition(name: &str) -> String {
359+ let mut safe = String::with_capacity(name.len());
360+
361+ for char in name.chars() {
362+ match char {
363+ 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' => safe.push(char),
364+ _ => safe.push('_'),
365+ }
366+ }
367+
368+ // A name reduced to nothing recognisable — punctuation, or a name written entirely
369+ // in a script the plain form cannot carry — downloads as `file` rather than as
370+ // `___`. The real name is still in `filename*` for anything that reads it.
371+ if !safe.chars().any(|char| char.is_ascii_alphanumeric()) {
372+ safe = "file".to_owned();
373+ }
374+
375+ format!(
376+ "attachment; filename=\"{safe}\"; filename*=UTF-8''{}",
377+ encode(name, false)
378+ )
379+}
380+
195381 // --- URLs -------------------------------------------------------------------------
196382
197383 /// The URL for a path at a revision.
@@ -212,6 +398,18 @@ pub(super) fn tree_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath)
212398 }
213399 }
214400
401+/// The URL a file's bytes are served from.
402+///
403+/// Always has a path: there are no raw bytes for a directory, so the root has no raw
404+/// URL and the `/-/` separator is unconditional.
405+pub(super) fn raw_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
406+ format!(
407+ "/{handle}/repos/{name}/raw/{}/-/{}",
408+ encode(rev.as_str(), false),
409+ encode(path.as_str(), true)
410+ )
411+}
412+
215413 /// The commit log's URL, at a revision or at the default branch.
216414 fn log_url(handle: &str, name: &str, rev: &str) -> String {
217415 if rev.is_empty() {
@@ -221,6 +419,65 @@ fn log_url(handle: &str, name: &str, rev: &str) -> String {
221419 }
222420 }
223421
422+// --- The revision switcher --------------------------------------------------------
423+
424+/// Which page a switcher's links lead back to.
425+///
426+/// Switching branch keeps you where you are: the same path on the tree, the log on the
427+/// log. A path that does not exist on the revision you picked lands on a 404, which is
428+/// the honest answer — the alternative is silently sending you somewhere you did not
429+/// ask for.
430+enum Switch<'a> {
431+ Tree(&'a RepoPath),
432+ Log,
433+}
434+
435+/// One row of the switcher.
436+struct RefLink {
437+ name: String,
438+ href: String,
439+ current: bool,
440+}
441+
442+fn ref_links(
443+ handle: &str,
444+ name: &str,
445+ refs: &[RefName],
446+ current: &str,
447+ switch: &Switch,
448+) -> Vec<RefLink> {
449+ refs.iter()
450+ .map(|git_ref| RefLink {
451+ name: git_ref.to_string(),
452+ href: match switch {
453+ Switch::Tree(path) => tree_url(handle, name, git_ref, path),
454+ Switch::Log => log_url(handle, name, git_ref.as_str()),
455+ },
456+ current: git_ref.as_str() == current,
457+ })
458+ .collect()
459+}
460+
461+/// Whether a revision is an object id rather than a name.
462+///
463+/// A heuristic, and only used for display: the worst it can do is abbreviate a branch
464+/// somebody named `deadbeef`.
465+fn is_object_id(rev: &str) -> bool {
466+ rev.len() >= 7 && rev.len() <= 64 && rev.chars().all(|char| char.is_ascii_hexdigit())
467+}
468+
469+/// How the switcher labels the revision it is on.
470+///
471+/// A ref by its name; a commit browsed directly by its abbreviation, because forty
472+/// characters of hex in a control reads as noise and is not a branch.
473+fn rev_label(rev: &str, known: bool) -> String {
474+ if !known && is_object_id(rev) {
475+ rev[..7].to_owned()
476+ } else {
477+ rev.to_owned()
478+ }
479+}
480+
224481 /// Percent-encodes a URL segment.
225482 ///
226483 /// Hand-rolled rather than pulled in as a dependency: it is the unreserved set from
@@ -345,7 +602,14 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) {
345602 /// Kept out of the repository page's own header so that a tree, a file and a log all
346603 /// read as the same repository rather than as three unrelated pages.
347604 #[component]
348pub(super) async fn repo_bar(repo: &RepoView, rev: &str, active: Tab) -> Result {
605+pub(super) async fn repo_bar(
606+ repo: &RepoView,
607+ rev: &str,
608+ active: Tab,
609+ /// The revision control, when the page has one. Empty on a page that does not.
610+ #[default]
611+ child: View,
612+) -> Result {
349613 let handle = repo.handle.as_str();
350614 let name = repo.name.as_str();
351615 let tab = |current| {
@@ -380,19 +644,118 @@ pub(super) async fn repo_bar(repo: &RepoView, rev: &str, active: Tab) -> Result
380644 class=(format!("-mb-3 border-b-2 pb-2 {}", tab(active == Tab::Log)))
381645 >"Commits"</a>
382646
383 if !rev.is_empty() {
384 <span class="ml-auto inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
385 icon(data: iconify_icon!("feather:git-branch"), attrs: attributes! {
386 class="size-3.5"
387 })
388 (rev)
389 </span>
390 }
647+ <span class="ml-auto">(child)</span>
391648 </nav>
392649 </header>
393650 }
394651 }
395652
653+/// The branch and tag picker.
654+///
655+/// A `<details>` disclosure, so it opens and closes with no scripting — the rest of
656+/// Steid works without JavaScript and a navigation control is the last place to start
657+/// requiring it. Every entry is a plain link, so it is also the whole keyboard and
658+/// screen-reader story for free.
659+///
660+/// Renders nothing at all when there is neither a revision nor a ref to offer, which is
661+/// an empty repository.
662+#[component]
663+async fn rev_switcher(
664+ current: &str,
665+ known: bool,
666+ branches: &[RefLink],
667+ tags: &[RefLink],
668+) -> Result {
669+ let label = rev_label(current, known);
670+ let empty = branches.is_empty() && tags.is_empty();
671+
672+ view! {
673+ if empty {
674+ // Nothing to switch to, so the control degrades to what it replaced: a
675+ // statement of where you are.
676+ if !current.is_empty() {
677+ <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
678+ icon(data: iconify_icon!("feather:git-branch"), attrs: attributes! {
679+ class="size-3.5"
680+ })
681+ (label)
682+ </span>
683+ }
684+ } else {
685+ <details class="group relative inline-block">
686+ <summary class="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-lg border border-border px-2.5 py-1 font-mono text-xs text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
687+ icon(
688+ data: if known {
689+ iconify_icon!("feather:git-branch")
690+ } else {
691+ iconify_icon!("feather:git-commit")
692+ },
693+ attrs: attributes! { class="size-3.5" },
694+ )
695+ (if label.is_empty() { "Revision" } else { label.as_str() })
696+ icon(
697+ data: iconify_icon!("feather:chevron-down"),
698+ attrs: attributes! {
699+ class="size-3.5 transition-transform group-open:rotate-180"
700+ },
701+ )
702+ </summary>
703+
704+ <div class="absolute right-0 z-20 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-background p-1 shadow-lg">
705+ if !known && !current.is_empty() {
706+ <p class="px-2 py-1.5 font-mono text-xs text-muted-foreground">
707+ "At commit " (label)
708+ </p>
709+ }
710+
711+ ref_group(title: "Branches", links: branches)
712+ ref_group(title: "Tags", links: tags)
713+ </div>
714+ </details>
715+ }
716+ }
717+}
718+
719+/// One labelled section of the switcher, omitted entirely when it is empty.
720+///
721+/// The heading is what tells a branch from a tag; nothing else in the list does, and
722+/// picking a tag when you meant a branch of the same name is a confusing way to end up
723+/// on the wrong tree.
724+#[component]
725+async fn ref_group(title: &str, links: &[RefLink]) -> Result {
726+ view! {
727+ if !links.is_empty() {
728+ <p class="px-2 pt-1.5 pb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
729+ (title)
730+ </p>
731+ <ul>
732+ for link in links {
733+ <li>
734+ <a
735+ href=(&link.href)
736+ class=(format!(
737+ "flex items-center gap-2 rounded-md px-2 py-1.5 font-mono text-sm hover:bg-foreground/5 {}",
738+ if link.current { "font-medium" } else { "" },
739+ ))
740+ >
741+ <span class="truncate">(&link.name)</span>
742+ if link.current {
743+ <span class="ml-auto text-muted-foreground">
744+ icon(
745+ data: iconify_icon!("feather:check"),
746+ label: "Current",
747+ attrs: attributes! { class="size-3.5" },
748+ )
749+ </span>
750+ }
751+ </a>
752+ </li>
753+ }
754+ </ul>
755+ }
756+ }
757+}
758+
396759 /// What a repository with no commits offers instead of a listing.
397760 ///
398761 /// This is the state every freshly-created repository is in, so it is the first thing
@@ -597,7 +960,20 @@ pub(super) async fn blob(
597960 <div class="overflow-hidden rounded-lg border border-border">
598961 <div class="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-2.5">
599962 crumbs(handle: handle, name: name, rev: rev, path: path)
600 <span class="font-mono text-xs text-muted-foreground">(size_of(file.size))</span>
963+ <span class="flex items-center gap-3 font-mono text-xs text-muted-foreground">
964+ (size_of(file.size))
965+ // The way out for anything the page cannot show — a binary, an
966+ // oversized file — and the URL to hand to `curl`.
967+ <a
968+ href=(raw_url(handle, name, rev, path))
969+ class="inline-flex items-center gap-1 hover:text-foreground"
970+ >
971+ icon(data: iconify_icon!("feather:download"), attrs: attributes! {
972+ class="size-3.5"
973+ })
974+ "Raw"
975+ </a>
976+ </span>
601977 </div>
602978
603979 match &file.text {
@@ -726,6 +1102,107 @@ mod tests {
7261102 );
7271103 }
7281104
1105+ #[test]
1106+ fn a_raw_url_always_carries_a_path() {
1107+ let path = RepoPath::new("src/main.rs").expect("valid");
1108+
1109+ assert_eq!(
1110+ raw_url("ada", "steid", &rev("main"), &path),
1111+ "/ada/repos/steid/raw/main/-/src/main.rs"
1112+ );
1113+ assert_eq!(
1114+ raw_url("ada", "steid", &rev("feature/login"), &path),
1115+ "/ada/repos/steid/raw/feature%2Flogin/-/src/main.rs"
1116+ );
1117+ }
1118+
1119+ #[test]
1120+ fn a_raw_response_carries_the_whole_policy() {
1121+ let response = raw_response("notes.txt", b"hello".to_vec()).expect("should build");
1122+ let header = |name: &str| {
1123+ response
1124+ .headers()
1125+ .get(name)
1126+ .and_then(|value| value.to_str().ok())
1127+ .unwrap_or_default()
1128+ .to_owned()
1129+ };
1130+
1131+ // Each of these is load-bearing on its own; see `raw_response`.
1132+ assert_eq!(header("content-type"), "application/octet-stream");
1133+ assert_eq!(header("x-content-type-options"), "nosniff");
1134+ assert!(header("content-disposition").starts_with("attachment;"));
1135+ assert_eq!(
1136+ header("content-security-policy"),
1137+ "default-src \'none\'; sandbox"
1138+ );
1139+ }
1140+
1141+ #[test]
1142+ fn a_disposition_carries_both_spellings_of_the_name() {
1143+ assert_eq!(
1144+ disposition("notes.txt"),
1145+ "attachment; filename=\"notes.txt\"; filename*=UTF-8\'\'notes.txt"
1146+ );
1147+ }
1148+
1149+ #[test]
1150+ fn a_disposition_cannot_be_escaped_by_a_filename() {
1151+ // A file name is repository content, so it is somebody else\'s input arriving
1152+ // in a header. A quote would end the quoted string and a newline would start a
1153+ // header of its own.
1154+ let hostile = disposition("a\"; x=1\r\nSet-Cookie: nope=1");
1155+
1156+ assert!(!hostile.contains('\r'));
1157+ assert!(!hostile.contains('\n'));
1158+ assert_eq!(hostile.matches('"').count(), 2);
1159+ }
1160+
1161+ #[test]
1162+ fn a_nameless_file_still_downloads_as_something() {
1163+ assert!(disposition("...").starts_with("attachment; filename=\"file\""));
1164+ }
1165+
1166+ #[test]
1167+ fn a_non_ascii_name_survives_in_the_extended_form() {
1168+ let value = disposition("日本語.txt");
1169+
1170+ // The plain form is reduced to what a header can carry safely; the real name
1171+ // rides along percent-encoded, which is what a modern client uses.
1172+ assert!(value.contains("filename=\"___.txt\""));
1173+ assert!(value.contains("filename*=UTF-8\'\'%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"));
1174+ }
1175+
1176+ #[test]
1177+ fn the_switcher_marks_the_revision_it_is_on() {
1178+ let refs = [RefName::from_trusted("main"), RefName::from_trusted("next")];
1179+ let path = RepoPath::new("src").expect("valid");
1180+ let links = ref_links("ada", "steid", &refs, "next", &Switch::Tree(&path));
1181+
1182+ assert_eq!(links[0].href, "/ada/repos/steid/tree/main/-/src");
1183+ assert!(!links[0].current);
1184+ assert!(links[1].current);
1185+ }
1186+
1187+ #[test]
1188+ fn switching_from_the_log_stays_on_the_log() {
1189+ let refs = [RefName::from_trusted("v1.0")];
1190+ let links = ref_links("ada", "steid", &refs, "main", &Switch::Log);
1191+
1192+ assert_eq!(links[0].href, "/ada/repos/steid/log/v1.0");
1193+ }
1194+
1195+ #[test]
1196+ fn an_object_id_is_labelled_as_a_commit_rather_than_a_branch() {
1197+ let id = "0123456789abcdef0123456789abcdef01234567";
1198+
1199+ assert!(is_object_id(id));
1200+ assert_eq!(rev_label(id, false), "0123456");
1201+ // A branch that happens to look like hex is still shown by its name.
1202+ assert_eq!(rev_label("deadbeef", true), "deadbeef");
1203+ assert_eq!(rev_label("main", false), "main");
1204+ }
1205+
7291206 #[test]
7301207 fn sizes_read_as_sizes() {
7311208 assert_eq!(size_of(0), "0 B");
src/infrastructure/web/markdown.rs+678 −0View file
@@ -0,0 +1,678 @@
1+//! Rendering markdown, for READMEs and later for posts.
2+//!
3+//! **Raw HTML in the source is never passed through.** Today only a repository's owner
4+//! can push to it, so a README is trusted content — but Milestone 7 adds other users,
5+//! and markdown rendered with HTML passthrough on the same origin as the session
6+//! cookie is stored XSS. Turning it off now costs nothing; retrofitting a sanitiser
7+//! later is a security migration. Do not "improve" this back on.
8+//!
9+//! The guarantee is structural rather than a filter: this module walks the parser's
10+//! event stream and writes the HTML itself, so the set of tags that can reach a page is
11+//! exactly the set spelled out in [`Writer`]. A `<script>` in the source is not
12+//! stripped — it arrives as [`Event::Html`] and is written as *text*, so a reader sees
13+//! what was written and a browser sees nothing to execute.
14+//!
15+//! Writing the HTML by hand rather than calling `pulldown_cmark::html::push_html` is
16+//! also forced: `pulldown-cmark` is depended on with `default-features = false`, which
17+//! turns its `html` module off. Escaping is not hand-rolled — it goes through
18+//! [`HtmlContext`], the same escaper the `view!` macro writes every dynamic value
19+//! through.
20+//!
21+//! The output is [`Unescaped`], which is how it reaches a `view!`: everything in a view
22+//! is escaped by default, and this is the deliberate hatch. That hatch is exactly why
23+//! raw HTML in the source has to be off — the two decisions are one decision.
24+
25+use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
26+use topcoat::view::{Formatter, HtmlContext, Unescaped};
27+
28+/// URL schemes a link or image may use.
29+///
30+/// An allowlist, not a `javascript:` denylist: a denylist has to anticipate every
31+/// scripting scheme (`vbscript:`, `data:text/html`, whatever a browser adds next),
32+/// while an allowlist only has to name the ones that are useful in prose.
33+const ALLOWED_SCHEMES: &[&str] = &["http", "https", "mailto", "ftp", "ftps", "tel"];
34+
35+/// Renders markdown to HTML, leaving relative links exactly as written.
36+pub fn render(source: &str) -> Unescaped<String> {
37+ render_with_links(source, |_| None)
38+}
39+
40+/// Renders markdown, giving `resolve` a chance to rewrite relative links.
41+///
42+/// `resolve` is called only for a destination that is relative — no scheme, and not
43+/// starting with `/` or `#` — and returning `None` leaves it untouched. It is a
44+/// parameter rather than something this module knows how to do because what a relative
45+/// link means depends on where the markdown came from: inside a repository it is
46+/// another file in the tree, and in a post it will be something else.
47+///
48+/// Resolution happens *before* the scheme check, so a resolver cannot hand back a
49+/// `javascript:` URL and have it reach the page.
50+pub fn render_with_links(
51+ source: &str,
52+ resolve: impl Fn(&str) -> Option<String>,
53+) -> Unescaped<String> {
54+ let mut buf = String::with_capacity(source.len());
55+ let mut writer = Writer::new(&mut buf, resolve);
56+
57+ for event in Parser::new_ext(source, options()) {
58+ writer.event(event);
59+ }
60+
61+ Unescaped::new_unchecked(buf)
62+}
63+
64+/// The dialect Steid understands.
65+///
66+/// Tables, strikethrough, task lists and footnotes are the parts of GitHub-flavoured
67+/// markdown a README actually uses. Smart punctuation is deliberately *off*: it turns
68+/// `--flag` into an en dash, which silently corrupts command-line flags written in
69+/// prose — the exact thing a README is full of.
70+fn options() -> Options {
71+ Options::ENABLE_TABLES
72+ | Options::ENABLE_STRIKETHROUGH
73+ | Options::ENABLE_TASKLISTS
74+ | Options::ENABLE_FOOTNOTES
75+}
76+
77+/// What a link destination turned out to be.
78+enum Destination {
79+ /// Safe to put in an `href` or `src`.
80+ Allowed(String),
81+ /// A scheme that is not in [`ALLOWED_SCHEMES`]. The attribute is dropped entirely.
82+ Refused,
83+}
84+
85+/// Decides whether a destination may appear in an attribute, and in what form.
86+///
87+/// `resolve_relative` is off for an image: a relative image points at a file that has
88+/// to be served as its own bytes, and rewriting it to a page URL would produce a
89+/// broken image rather than a working one.
90+fn destination(
91+ raw: &str,
92+ resolve_relative: bool,
93+ resolve: &impl Fn(&str) -> Option<String>,
94+) -> Destination {
95+ // Browsers strip ASCII control characters — tab, newline, carriage return among
96+ // them — out of a URL before parsing it, so `java&Tab;script:alert(1)` is a
97+ // `javascript:` URL by the time it matters. Judge the stripped form *and* emit it,
98+ // or the check and the browser would be looking at different strings. Interior
99+ // spaces are left alone: they are legal in a `<...>` destination and are not a way
100+ // to hide a scheme, since a scheme cannot contain one.
101+ let cleaned: String = raw
102+ .trim()
103+ .chars()
104+ .filter(|c| !c.is_ascii_control())
105+ .collect();
106+
107+ let resolved = match scheme_of(&cleaned) {
108+ Some(_) => cleaned,
109+ // No scheme. An absolute path and a fragment already point at this origin; only
110+ // a genuinely relative destination is the caller's to reinterpret.
111+ None if cleaned.starts_with('/') || cleaned.starts_with('#') || cleaned.is_empty() => {
112+ cleaned
113+ }
114+ None if resolve_relative => resolve(&cleaned).unwrap_or(cleaned),
115+ None => cleaned,
116+ };
117+
118+ match scheme_of(&resolved) {
119+ Some(scheme) if !ALLOWED_SCHEMES.contains(&scheme.as_str()) => Destination::Refused,
120+ _ => Destination::Allowed(resolved),
121+ }
122+}
123+
124+/// The URL's scheme, lowercased, or `None` when it has none.
125+///
126+/// A colon only introduces a scheme when it comes before any `/`, `?` or `#` and what
127+/// precedes it is a legal scheme name — otherwise `docs/a:b.md` and `#a:b` would read
128+/// as schemes and be refused.
129+fn scheme_of(url: &str) -> Option<String> {
130+ let end = url.find([':', '/', '?', '#'])?;
131+
132+ if url.as_bytes()[end] != b':' {
133+ return None;
134+ }
135+
136+ let scheme = &url[..end];
137+ let mut chars = scheme.chars();
138+
139+ if !chars.next()?.is_ascii_alphabetic() {
140+ return None;
141+ }
142+ if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
143+ return None;
144+ }
145+
146+ Some(scheme.to_ascii_lowercase())
147+}
148+
149+/// An image being assembled.
150+///
151+/// An image's alt text arrives as the events *between* its start and end tags, so it
152+/// has to be collected before the `<img>` can be written at all.
153+struct Image {
154+ destination: Destination,
155+ title: String,
156+ alt: String,
157+}
158+
159+/// Writes an event stream out as HTML.
160+///
161+/// Every tag this can emit is written literally below, which is what makes "raw HTML
162+/// cannot reach the page" a property of the code rather than of a filter that has to be
163+/// kept correct. The classes are Tailwind utilities in theme tokens only — a hardcoded
164+/// colour would follow neither a palette change nor the colour scheme — and they live
165+/// here as literals so Tailwind's scan of `./src/**/*.rs` finds them.
166+struct Writer<'a, F> {
167+ out: Formatter<'a>,
168+ resolve: F,
169+ image: Option<Image>,
170+ alignments: Vec<Alignment>,
171+ column: usize,
172+ in_head: bool,
173+}
174+
175+impl<'a, F: Fn(&str) -> Option<String>> Writer<'a, F> {
176+ fn new(buf: &'a mut String, resolve: F) -> Self {
177+ Self {
178+ out: Formatter::new(buf),
179+ resolve,
180+ image: None,
181+ alignments: Vec::new(),
182+ column: 0,
183+ in_head: false,
184+ }
185+ }
186+
187+ /// Markup this module chose. Never anything derived from the source.
188+ fn raw(&mut self, markup: &str) {
189+ self.out.write_str(markup);
190+ }
191+
192+ /// Content from the source, as a text node — or as alt text while inside an image.
193+ fn text(&mut self, value: &str) {
194+ if let Some(image) = self.image.as_mut() {
195+ image.alt.push_str(value);
196+ return;
197+ }
198+
199+ HtmlContext::Text.writer(&mut self.out).write_str(value);
200+ }
201+
202+ /// Content from the source, inside a double-quoted attribute value.
203+ fn attribute(&mut self, name: &str, value: &str) {
204+ self.out.write_str(" ");
205+ self.out.write_str(name);
206+ self.out.write_str("=\"");
207+ HtmlContext::AttributeValue
208+ .writer(&mut self.out)
209+ .write_str(value);
210+ self.out.write_str("\"");
211+ }
212+
213+ fn event(&mut self, event: Event<'_>) {
214+ match event {
215+ Event::Start(tag) => self.start(tag),
216+ Event::End(tag) => self.end(tag),
217+ Event::Text(text) => self.text(&text),
218+ Event::Code(code) => {
219+ self.raw(
220+ "<code class=\"rounded border border-border bg-surface px-1 py-0.5 font-mono text-[0.85em]\">",
221+ );
222+ self.text(&code);
223+ self.raw("</code>");
224+ }
225+ // The security-critical case, and the reason this module exists in this
226+ // shape. HTML in the source is written as text: visible, inert, and
227+ // honest about what the author wrote.
228+ Event::Html(html) | Event::InlineHtml(html) => self.text(&html),
229+ Event::SoftBreak => self.raw("\n"),
230+ Event::HardBreak => self.raw("<br />"),
231+ Event::Rule => self.raw("<hr class=\"my-6 border-t border-border\" />"),
232+ Event::FootnoteReference(label) => {
233+ self.raw("<sup><a class=\"text-primary hover:underline\"");
234+ self.attribute("href", &format!("#fn-{label}"));
235+ self.raw(">");
236+ self.text(&label);
237+ self.raw("</a></sup>");
238+ }
239+ Event::TaskListMarker(checked) => {
240+ self.raw("<input type=\"checkbox\" disabled class=\"mr-2 align-middle\"");
241+ if checked {
242+ self.raw(" checked");
243+ }
244+ self.raw(" />");
245+ }
246+ // Only reachable with options this module does not enable.
247+ Event::InlineMath(value) | Event::DisplayMath(value) => self.text(&value),
248+ }
249+ }
250+
251+ fn start(&mut self, tag: Tag<'_>) {
252+ match tag {
253+ Tag::Paragraph => self.raw("<p class=\"my-3 leading-relaxed\">"),
254+ Tag::Heading { level, .. } => self.raw(heading_open(level)),
255+ Tag::BlockQuote(_) => self.raw(
256+ "<blockquote class=\"my-4 border-l-2 border-border pl-4 text-muted-foreground\">",
257+ ),
258+ Tag::CodeBlock(_) => self.raw(
259+ "<pre class=\"my-4 overflow-x-auto rounded-lg border border-border bg-surface px-4 py-3 font-mono text-xs leading-relaxed\"><code>",
260+ ),
261+ // The block itself is nothing; its lines arrive as `Event::Html` and are
262+ // written as text, so they need somewhere to sit.
263+ Tag::HtmlBlock => self.raw("<p class=\"my-3 leading-relaxed\">"),
264+ Tag::List(None) => self.raw("<ul class=\"my-3 list-disc space-y-1 pl-6\">"),
265+ Tag::List(Some(first)) => {
266+ self.raw("<ol class=\"my-3 list-decimal space-y-1 pl-6\"");
267+ if first != 1 {
268+ self.attribute("start", &first.to_string());
269+ }
270+ self.raw(">");
271+ }
272+ Tag::Item => self.raw("<li>"),
273+ Tag::FootnoteDefinition(label) => {
274+ self.raw("<div class=\"mt-2 text-xs text-muted-foreground\"");
275+ self.attribute("id", &format!("fn-{label}"));
276+ self.raw("><span class=\"mr-2 font-mono\">");
277+ self.text(&label);
278+ self.raw("</span>");
279+ }
280+ Tag::Table(alignments) => {
281+ self.alignments = alignments;
282+ self.raw(
283+ "<div class=\"my-4 overflow-x-auto\"><table class=\"w-full border-collapse text-sm\">",
284+ );
285+ }
286+ Tag::TableHead => {
287+ self.in_head = true;
288+ self.column = 0;
289+ self.raw("<thead><tr>");
290+ }
291+ Tag::TableRow => {
292+ self.column = 0;
293+ self.raw("<tr>");
294+ }
295+ Tag::TableCell => {
296+ let alignment = self.alignments.get(self.column).copied();
297+
298+ if self.in_head {
299+ // A header with no stated alignment is left-aligned rather than
300+ // left to the browser, which centres `<th>` by default and makes a
301+ // plain markdown table look deliberately centred when it is not.
302+ self.raw("<th class=\"border-b border-border px-3 py-2 font-medium ");
303+ self.raw(align_class(alignment).unwrap_or("text-left"));
304+ } else {
305+ self.raw("<td class=\"border-b border-border px-3 py-2 ");
306+ self.raw(align_class(alignment).unwrap_or(""));
307+ }
308+
309+ self.raw("\">");
310+ }
311+ Tag::Emphasis => self.raw("<em>"),
312+ Tag::Strong => self.raw("<strong class=\"font-semibold\">"),
313+ Tag::Strikethrough => self.raw("<del>"),
314+ Tag::Superscript => self.raw("<sup>"),
315+ Tag::Subscript => self.raw("<sub>"),
316+ Tag::Link {
317+ dest_url, title, ..
318+ } => {
319+ self.raw("<a");
320+
321+ // A refused destination keeps its text but loses its `href` — and its
322+ // link styling with it, so it does not read as a link that silently
323+ // does nothing. Dropping the element entirely would swallow what the
324+ // author wrote.
325+ if let Destination::Allowed(url) = destination(&dest_url, true, &self.resolve) {
326+ self.raw(" class=\"text-primary hover:underline\"");
327+ self.attribute("href", &url);
328+ }
329+ if !title.is_empty() {
330+ self.attribute("title", &title);
331+ }
332+
333+ self.raw(">");
334+ }
335+ Tag::Image {
336+ dest_url, title, ..
337+ } => {
338+ self.image = Some(Image {
339+ destination: destination(&dest_url, false, &self.resolve),
340+ title: title.into_string(),
341+ alt: String::new(),
342+ });
343+ }
344+ // Only reachable with options this module does not enable.
345+ Tag::DefinitionList
346+ | Tag::DefinitionListTitle
347+ | Tag::DefinitionListDefinition
348+ | Tag::MetadataBlock(_) => {}
349+ }
350+ }
351+
352+ fn end(&mut self, tag: TagEnd) {
353+ match tag {
354+ TagEnd::Paragraph | TagEnd::HtmlBlock => self.raw("</p>"),
355+ TagEnd::Heading(level) => self.raw(heading_close(level)),
356+ TagEnd::BlockQuote(_) => self.raw("</blockquote>"),
357+ TagEnd::CodeBlock => self.raw("</code></pre>"),
358+ TagEnd::List(true) => self.raw("</ol>"),
359+ TagEnd::List(false) => self.raw("</ul>"),
360+ TagEnd::Item => self.raw("</li>"),
361+ TagEnd::FootnoteDefinition => self.raw("</div>"),
362+ TagEnd::Table => self.raw("</table></div>"),
363+ TagEnd::TableHead => {
364+ self.in_head = false;
365+ self.raw("</tr></thead><tbody>");
366+ }
367+ TagEnd::TableRow => self.raw("</tr>"),
368+ TagEnd::TableCell => {
369+ self.column += 1;
370+ if self.in_head {
371+ self.raw("</th>");
372+ } else {
373+ self.raw("</td>");
374+ }
375+ }
376+ TagEnd::Emphasis => self.raw("</em>"),
377+ TagEnd::Strong => self.raw("</strong>"),
378+ TagEnd::Strikethrough => self.raw("</del>"),
379+ TagEnd::Superscript => self.raw("</sup>"),
380+ TagEnd::Subscript => self.raw("</sub>"),
381+ TagEnd::Link => self.raw("</a>"),
382+ TagEnd::Image => self.image(),
383+ TagEnd::DefinitionList
384+ | TagEnd::DefinitionListTitle
385+ | TagEnd::DefinitionListDefinition
386+ | TagEnd::MetadataBlock(_) => {}
387+ }
388+ }
389+
390+ /// Writes the image whose alt text has just finished arriving.
391+ ///
392+ /// A refused source is not rendered as a broken image: the alt text is written on
393+ /// its own, which is the same thing a reader with images off would get.
394+ fn image(&mut self) {
395+ let Some(image) = self.image.take() else {
396+ return;
397+ };
398+
399+ let Destination::Allowed(url) = image.destination else {
400+ self.text(&image.alt);
401+ return;
402+ };
403+
404+ self.raw("<img class=\"my-4 max-w-full rounded-lg border border-border\"");
405+ self.attribute("src", &url);
406+ self.attribute("alt", &image.alt);
407+ if !image.title.is_empty() {
408+ self.attribute("title", &image.title);
409+ }
410+ self.raw(" />");
411+ }
412+}
413+
414+/// The class for a column's stated alignment, or `None` when it stated none.
415+fn align_class(alignment: Option<Alignment>) -> Option<&'static str> {
416+ match alignment? {
417+ Alignment::Center => Some("text-center"),
418+ Alignment::Right => Some("text-right"),
419+ Alignment::Left => Some("text-left"),
420+ Alignment::None => None,
421+ }
422+}
423+
424+/// Headings step down in weight as well as size, so a README's `##` sections read as
425+/// sections rather than as six sizes of the same thing.
426+fn heading_open(level: HeadingLevel) -> &'static str {
427+ match level {
428+ HeadingLevel::H1 => "<h1 class=\"mt-8 mb-3 text-2xl font-semibold tracking-tight\">",
429+ HeadingLevel::H2 => {
430+ "<h2 class=\"mt-8 mb-3 border-b border-border pb-2 text-xl font-semibold tracking-tight\">"
431+ }
432+ HeadingLevel::H3 => "<h3 class=\"mt-6 mb-2 text-lg font-semibold\">",
433+ HeadingLevel::H4 => "<h4 class=\"mt-6 mb-2 text-base font-semibold\">",
434+ HeadingLevel::H5 => "<h5 class=\"mt-4 mb-2 text-sm font-semibold\">",
435+ HeadingLevel::H6 => "<h6 class=\"mt-4 mb-2 text-sm font-semibold text-muted-foreground\">",
436+ }
437+}
438+
439+fn heading_close(level: HeadingLevel) -> &'static str {
440+ match level {
441+ HeadingLevel::H1 => "</h1>",
442+ HeadingLevel::H2 => "</h2>",
443+ HeadingLevel::H3 => "</h3>",
444+ HeadingLevel::H4 => "</h4>",
445+ HeadingLevel::H5 => "</h5>",
446+ HeadingLevel::H6 => "</h6>",
447+ }
448+}
449+
450+#[cfg(test)]
451+mod tests {
452+ use super::*;
453+
454+ /// The rendered HTML, for a test to look inside.
455+ fn html(source: &str) -> String {
456+ render(source).as_str().to_owned()
457+ }
458+
459+ /// The same, with a resolver for relative links.
460+ fn html_with(source: &str, resolve: impl Fn(&str) -> Option<String>) -> String {
461+ render_with_links(source, resolve).as_str().to_owned()
462+ }
463+
464+ #[test]
465+ fn headings_become_headings() {
466+ assert!(html("# Steid").contains("<h1 class=\""));
467+ assert!(html("# Steid").contains(">Steid</h1>"));
468+ assert!(html("### Deeper").contains(">Deeper</h3>"));
469+ }
470+
471+ #[test]
472+ fn lists_keep_their_kind() {
473+ let bullets = html("- one\n- two\n");
474+ assert!(bullets.contains("<ul class="));
475+ assert!(bullets.contains("<li>one</li>"));
476+
477+ let numbers = html("3. three\n4. four\n");
478+ assert!(numbers.contains("<ol class="));
479+ assert!(numbers.contains("start=\"3\""));
480+ }
481+
482+ #[test]
483+ fn a_fenced_code_block_is_a_pre_and_its_contents_are_text() {
484+ let rendered = html("```rust\nlet x = 1 < 2;\n```\n");
485+
486+ assert!(rendered.contains("<pre class="));
487+ assert!(rendered.contains("<code>let x = 1 &lt; 2;\n</code></pre>"));
488+ }
489+
490+ #[test]
491+ fn inline_code_is_a_code_element() {
492+ assert!(html("use `cargo test`").contains("<code class=\"rounded"));
493+ }
494+
495+ #[test]
496+ fn a_table_renders_as_a_table_with_alignment() {
497+ let rendered = html("| a | b |\n|:-:|--:|\n| 1 | 2 |\n");
498+
499+ assert!(rendered.contains("<table class="));
500+ assert!(
501+ rendered.contains(
502+ "<th class=\"border-b border-border px-3 py-2 font-medium text-center\">"
503+ )
504+ );
505+ assert!(rendered.contains("text-right"));
506+ assert!(rendered.contains("<tbody><tr><td"));
507+ }
508+
509+ #[test]
510+ fn a_header_with_no_stated_alignment_is_left_aligned() {
511+ // A browser centres `<th>` on its own, which reads as a deliberate choice.
512+ let rendered = html("| a |\n|---|\n| 1 |\n");
513+
514+ assert!(rendered.contains("font-medium text-left\">"), "{rendered}");
515+ }
516+
517+ #[test]
518+ fn task_lists_render_as_disabled_checkboxes() {
519+ let rendered = html("- [x] done\n- [ ] not\n");
520+
521+ assert!(rendered.contains("type=\"checkbox\" disabled"));
522+ assert!(rendered.contains(" checked />"));
523+ }
524+
525+ // --- Safety ---------------------------------------------------------------------
526+
527+ #[test]
528+ fn a_script_tag_in_the_source_is_text_and_not_a_tag() {
529+ let rendered = html("<script>alert(1)</script>\n");
530+
531+ assert!(
532+ !rendered.contains("<script"),
533+ "a script tag reached the page: {rendered}"
534+ );
535+ assert!(rendered.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
536+ }
537+
538+ #[test]
539+ fn an_inline_event_handler_is_text_and_not_a_tag() {
540+ let rendered = html("Look: <img src=x onerror=\"alert(1)\"> at that.\n");
541+
542+ // The words are still there — they are *text* now. What matters is that no
543+ // element was created for the handler to hang off.
544+ assert!(
545+ !rendered.contains("<img"),
546+ "an image tag reached the page: {rendered}"
547+ );
548+ assert!(rendered.contains("&lt;img src=x onerror="));
549+ }
550+
551+ #[test]
552+ fn an_html_comment_cannot_hide_markup() {
553+ let rendered = html("<!-- <script>alert(1)</script> -->\n");
554+
555+ assert!(!rendered.contains("<script"), "{rendered}");
556+ assert!(!rendered.contains("<!--"), "{rendered}");
557+ }
558+
559+ #[test]
560+ fn a_javascript_link_loses_its_href() {
561+ let rendered = html("[click](javascript:alert(1))");
562+
563+ assert!(!rendered.contains("javascript"), "{rendered}");
564+ assert!(rendered.contains("<a>click</a>"), "{rendered}");
565+ }
566+
567+ #[test]
568+ fn a_javascript_url_split_by_a_control_character_still_loses_its_href() {
569+ // A browser strips the tab before parsing the URL, so the check has to as well.
570+ let rendered = html("[click](<java&#9;script:alert(1)>)");
571+
572+ assert!(!rendered.contains("href"), "{rendered}");
573+ }
574+
575+ #[test]
576+ fn an_uppercase_scheme_is_still_refused() {
577+ assert!(!html("[click](JaVaScRiPt:alert(1))").contains("href"));
578+ }
579+
580+ #[test]
581+ fn a_data_url_image_is_refused_and_leaves_its_alt_text() {
582+ let rendered = html("![a logo](data:text/html;base64,PHNjcmlwdD4=)");
583+
584+ assert!(!rendered.contains("<img"), "{rendered}");
585+ assert!(rendered.contains("a logo"));
586+ }
587+
588+ #[test]
589+ fn ordinary_links_and_images_survive() {
590+ assert!(
591+ html("[home](https://example.com/a?b=1)")
592+ .contains("href=\"https://example.com/a?b=1\"")
593+ );
594+ assert!(html("[mail](mailto:ada@example.com)").contains("href=\"mailto:ada@example.com\""));
595+ assert!(
596+ html("![logo](https://example.com/l.png)")
597+ .contains("src=\"https://example.com/l.png\"")
598+ );
599+ assert!(html("[anchor](#usage)").contains("href=\"#usage\""));
600+ }
601+
602+ #[test]
603+ fn text_that_needs_escaping_is_escaped() {
604+ let rendered = html("5 < 6 & \"quoted\" > 4\n");
605+
606+ assert!(rendered.contains("5 &lt; 6 &amp; \"quoted\" &gt; 4"));
607+ }
608+
609+ #[test]
610+ fn a_title_cannot_break_out_of_its_attribute() {
611+ let rendered = html("[x](https://example.com \"a \\\" onmouseover=alert(1)\")");
612+
613+ assert!(rendered.contains("&quot;"), "{rendered}");
614+ assert!(!rendered.contains("\" onmouseover"), "{rendered}");
615+ }
616+
617+ // --- Relative links -------------------------------------------------------------
618+
619+ #[test]
620+ fn a_relative_link_is_handed_to_the_resolver() {
621+ let rendered = html_with("[c](./CONTRIBUTING.md)", |dest| {
622+ Some(format!("/tree/{dest}"))
623+ });
624+
625+ assert!(
626+ rendered.contains("href=\"/tree/./CONTRIBUTING.md\""),
627+ "{rendered}"
628+ );
629+ }
630+
631+ #[test]
632+ fn absolute_paths_anchors_and_urls_are_not_resolved() {
633+ let resolve = |_: &str| Some("/rewritten".to_owned());
634+
635+ for source in ["[a](/already)", "[a](#anchor)", "[a](https://example.com/)"] {
636+ let rendered = html_with(source, resolve);
637+ assert!(!rendered.contains("/rewritten"), "{source}: {rendered}");
638+ }
639+ }
640+
641+ #[test]
642+ fn a_resolver_cannot_smuggle_in_a_dangerous_url() {
643+ // The scheme check runs after resolution, on purpose.
644+ let rendered = html_with("[a](whatever.md)", |_| {
645+ Some("javascript:alert(1)".to_owned())
646+ });
647+
648+ assert!(!rendered.contains("href"), "{rendered}");
649+ }
650+
651+ #[test]
652+ fn a_relative_image_is_never_resolved() {
653+ // A tree URL serves a *page*, so rewriting an image source to one would swap a
654+ // 404 for a broken image. The resolver is for links only.
655+ let rendered = html_with("![l](logo.png)", |_| Some("/rewritten".to_owned()));
656+
657+ assert!(rendered.contains("src=\"logo.png\""), "{rendered}");
658+ }
659+
660+ #[test]
661+ fn without_a_resolver_a_relative_link_is_left_alone() {
662+ assert!(html("[c](./CONTRIBUTING.md)").contains("href=\"./CONTRIBUTING.md\""));
663+ }
664+
665+ #[test]
666+ fn a_colon_in_a_path_is_not_a_scheme() {
667+ assert!(scheme_of("docs/a:b.md").is_none());
668+ assert!(scheme_of("#a:b").is_none());
669+ assert!(scheme_of("./a.md").is_none());
670+ assert_eq!(scheme_of("HTTPS://example.com").as_deref(), Some("https"));
671+ }
672+
673+ #[test]
674+ fn empty_input_renders_nothing() {
675+ assert!(html("").is_empty());
676+ assert!(html(" \n\n ").is_empty());
677+ }
678+}
src/infrastructure/web/mod.rs+2 −0View file
@@ -6,10 +6,12 @@ pub mod context;
66 pub mod git;
77 pub mod health;
88 pub mod layout;
9+pub mod markdown;
910 pub mod pages;
1011 pub mod profile;
1112 pub mod rate_limit;
1213 pub mod repo;
14+pub mod repo_settings;
1315 pub mod session_cookie;
1416 pub mod settings;
1517 pub mod setup;
src/infrastructure/web/repo.rs+257 −4View file
@@ -18,24 +18,29 @@ use topcoat::{
1818 };
1919
2020 use crate::{
21 application::{Browsed, Error, NewRepo, RepoSummary, RepoView, create_repo, view_repo},
21+ application::{
22+ Browsed, Error, FileView, NewRepo, RepoSummary, RepoView, create_repo, view_repo,
23+ },
2224 components::{
2325 badge::{BadgeVariant, badge},
24 button::button,
26+ button::{ButtonSize, ButtonVariant, button, button_variants},
2527 flash::{FlashKind, flash},
2628 input::input,
2729 label::label,
2830 select::select,
2931 textarea::textarea,
3032 },
31 domain::{DomainError, RepoName, RepoPath, Repository, Visibility},
33+ domain::{
34+ DomainError, EntryKind, RefName, RepoName, RepoPath, Repository, TreeEntry, Visibility,
35+ },
3236 };
3337
3438 use super::{
35 browse::{blob, browsed_at, directory, empty_repo},
39+ browse::{blob, browsed_at, directory, empty_repo, tree_url},
3640 context::{
3741 current_actor, location, memberships, orgs, public_origin, repos, server_error, storage,
3842 },
43+ markdown,
3944 profile::profile_for,
4045 };
4146
@@ -187,6 +192,18 @@ async fn repo_page(cx: &Cx) -> Result {
187192 if !repo.visibility.is_public() {
188193 badge(variant: BadgeVariant::Outline, "Private")
189194 }
195+ // Only the owner can change a repository, so only the owner is offered
196+ // the way in. The settings page enforces this again — this is the link
197+ // not being a dead end, not the authorization.
198+ if repo.viewer_is_owner {
199+ <a
200+ href=(format!("/{}/repos/{}/settings", repo.handle, repo.name))
201+ class=(format!(
202+ "ml-auto {}",
203+ button_variants(ButtonVariant::Outline, ButtonSize::Sm)
204+ ))
205+ >"Settings"</a>
206+ }
190207 </div>
191208 ({
192209 match &repo.description {
@@ -219,6 +236,13 @@ async fn repo_page(cx: &Cx) -> Result {
219236 path: path,
220237 entries: entries,
221238 )
239+
240+ // No README means nothing here at all — an empty panel saying a
241+ // repository has no README is worse than the silence.
242+ match readme_of(entries) {
243+ Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry),
244+ None => "",
245+ }
222246 },
223247 // The root of a revision is always a directory, so this is unreachable in
224248 // practice — rendered rather than errored so it can never be a 500.
@@ -235,6 +259,133 @@ async fn repo_page(cx: &Cx) -> Result {
235259 }
236260 }
237261
262+// --- README -----------------------------------------------------------------------
263+
264+/// Extensions a README may carry, in the order they are preferred.
265+///
266+/// Empty last: a plain `README` is a README, but a repository holding both `README.md`
267+/// and `README` means the markdown one. Nothing that is not markdown is here — a
268+/// `README.rst` rendered as markdown would be worse than the file listing.
269+const README_EXTENSIONS: [&str; 5] = ["md", "markdown", "mdown", "mkd", ""];
270+
271+/// How strongly a name says "this is the README", or `None` if it does not.
272+///
273+/// Case-insensitive, because the file is spelled `README`, `readme` and `Readme` in the
274+/// wild and all three mean the same thing.
275+fn readme_rank(name: &str) -> Option<usize> {
276+ let lowered = name.to_ascii_lowercase();
277+ let extension = match lowered.strip_prefix("readme")? {
278+ "" => "",
279+ rest => rest.strip_prefix('.')?,
280+ };
281+
282+ README_EXTENSIONS
283+ .iter()
284+ .position(|candidate| *candidate == extension)
285+}
286+
287+/// The README in a listing, if there is one.
288+///
289+/// Chosen from the entries the page already has rather than by asking git for a file
290+/// that may not exist: every `git` call is a fork of about 12ms, and a speculative one
291+/// would be spent on every repository without a README. See
292+/// [0006](../../../plans/decisions/0006-git-binary-behind-narrow-ports.md).
293+fn readme_of(entries: &[TreeEntry]) -> Option<&TreeEntry> {
294+ entries
295+ .iter()
296+ .filter(|entry| entry.kind == EntryKind::Blob)
297+ .filter_map(|entry| Some((readme_rank(&entry.name)?, entry)))
298+ .min_by_key(|(rank, _)| *rank)
299+ .map(|(_, entry)| entry)
300+}
301+
302+/// Where a relative link in a README should point.
303+///
304+/// A README's links are written against the repository's own files, so `./CONTRIBUTING.md`
305+/// means a file in the tree and not a Steid route — left alone it would 404. Anything
306+/// this cannot make sense of returns `None` and is left exactly as written, which is the
307+/// same thing a plain markdown renderer would do.
308+///
309+/// Only links. A relative *image* is left alone deliberately: an image needs the file's
310+/// bytes, and a tree URL serves a page, so rewriting one would trade a 404 for a broken
311+/// image. [`markdown::render_with_links`] never offers this an image.
312+fn readme_link(handle: &str, name: &str, rev: &RefName, destination: &str) -> Option<String> {
313+ // A query or fragment addresses something inside a rendered document; a file in a
314+ // tree has neither.
315+ let target = destination.split(['?', '#']).next()?;
316+ let target = target.strip_prefix("./").unwrap_or(target);
317+
318+ // `RepoPath` refuses `..` and `.` components, so a link cannot walk out of the
319+ // repository — it simply stays as it was written.
320+ let path = RepoPath::new(target).ok()?;
321+
322+ if path.is_root() {
323+ return None;
324+ }
325+
326+ Some(tree_url(handle, name, rev, &path))
327+}
328+
329+/// The rendered README, under the file listing.
330+///
331+/// Reading it costs the page one more `git` call, which is why it is the only file the
332+/// page fetches beyond the listing itself.
333+///
334+/// A component rather than a function because `view!` needs the request context in
335+/// scope — see [`browsing`](super::browse).
336+#[component]
337+async fn readme_card(cx: &Cx, repo: &RepoView, rev: &RefName, entry: &TreeEntry) -> Result {
338+ let path = RepoPath::new(&entry.name).map_err(|_| not_found())?;
339+
340+ // The entry came out of a listing read moments ago, so anything but a file means
341+ // the tree changed underneath this request. A vanished README is not a reason to
342+ // fail the page it was going to decorate.
343+ let Browsed::File { file, .. } = browsed_at(cx, repo, Some(rev), &path).await? else {
344+ return view! {};
345+ };
346+
347+ let handle = repo.handle.as_str();
348+ let name = repo.name.as_str();
349+
350+ view! {
351+ <section class="mt-6 overflow-hidden rounded-lg border border-border">
352+ <div class="border-b border-border px-4 py-2.5">
353+ <a
354+ href=(tree_url(handle, name, rev, &path))
355+ class="font-mono text-sm hover:underline"
356+ >(&entry.name)</a>
357+ </div>
358+ readme_body(handle: handle, name: name, rev: rev, file: &file)
359+ </section>
360+ }
361+}
362+
363+/// A README's contents, in the three states a file can be in.
364+///
365+/// Split out so the `view!` holding the rendered markdown is the only place the
366+/// escape hatch is used, and it is one line long.
367+#[component]
368+async fn readme_body(handle: &str, name: &str, rev: &RefName, file: &FileView) -> Result {
369+ view! {
370+ match &file.text {
371+ // The only unescaped content on any Steid page. It is safe because
372+ // `markdown` writes every tag itself and never passes source HTML
373+ // through — see that module's header.
374+ Some(text) => <div class="px-5 py-4 text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
375+ (markdown::render_with_links(text, |destination| {
376+ readme_link(handle, name, rev, destination)
377+ }))
378+ </div>,
379+ None if file.too_large => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
380+ "This README is too large to render here. Open it in the file listing above."
381+ </p>,
382+ None => <p class="px-4 py-6 text-center text-sm text-muted-foreground">
383+ "This README is not valid UTF-8, so it cannot be rendered."
384+ </p>,
385+ }
386+ }
387+}
388+
238389 /// The creation form.
239390 ///
240391 /// Values arrive as parameters rather than being read back, so a rejected submission
@@ -388,3 +539,105 @@ pub(super) async fn repo_list(handle: &str, repos: &[RepoSummary]) -> Result {
388539 }
389540 }
390541 }
542+
543+#[cfg(test)]
544+mod tests {
545+ use crate::domain::ObjectId;
546+
547+ use super::*;
548+
549+ fn entry(name: &str, kind: EntryKind) -> TreeEntry {
550+ TreeEntry {
551+ name: name.to_owned(),
552+ kind,
553+ id: ObjectId::from_trusted("0".repeat(40)),
554+ size: Some(0),
555+ }
556+ }
557+
558+ fn rev() -> RefName {
559+ RefName::new("main").expect("valid revision")
560+ }
561+
562+ #[test]
563+ fn the_usual_readme_spellings_are_all_readmes() {
564+ for name in [
565+ "README.md",
566+ "readme.md",
567+ "Readme.md",
568+ "README",
569+ "readme",
570+ "README.markdown",
571+ "README.mkd",
572+ ] {
573+ assert!(readme_rank(name).is_some(), "{name} should be a README");
574+ }
575+ }
576+
577+ #[test]
578+ fn things_that_merely_start_with_readme_are_not_readmes() {
579+ for name in [
580+ "READMEISH.md",
581+ "readme-first.md",
582+ "README.rst",
583+ "README.txt",
584+ "docs.md",
585+ "",
586+ ] {
587+ assert!(readme_rank(name).is_none(), "{name} should not be a README");
588+ }
589+ }
590+
591+ #[test]
592+ fn the_markdown_readme_wins_over_the_plain_one() {
593+ let entries = [
594+ entry("readme", EntryKind::Blob),
595+ entry("README.md", EntryKind::Blob),
596+ ];
597+
598+ assert_eq!(
599+ readme_of(&entries).map(|found| found.name.as_str()),
600+ Some("README.md")
601+ );
602+ }
603+
604+ #[test]
605+ fn a_directory_called_readme_is_not_a_readme() {
606+ // Reading it would ask git for a blob at a tree's path and get nothing.
607+ let entries = [entry("readme", EntryKind::Tree)];
608+
609+ assert!(readme_of(&entries).is_none());
610+ }
611+
612+ #[test]
613+ fn a_listing_without_one_has_no_readme() {
614+ let entries = [
615+ entry("src", EntryKind::Tree),
616+ entry("Cargo.toml", EntryKind::Blob),
617+ ];
618+
619+ assert!(readme_of(&entries).is_none());
620+ }
621+
622+ #[test]
623+ fn a_relative_link_becomes_a_link_into_the_tree() {
624+ assert_eq!(
625+ readme_link("ada", "steid", &rev(), "./CONTRIBUTING.md").as_deref(),
626+ Some("/ada/repos/steid/tree/main/-/CONTRIBUTING.md")
627+ );
628+ assert_eq!(
629+ readme_link("ada", "steid", &rev(), "docs/design.md#why").as_deref(),
630+ Some("/ada/repos/steid/tree/main/-/docs/design.md")
631+ );
632+ }
633+
634+ #[test]
635+ fn a_link_that_would_walk_out_of_the_repository_is_left_alone() {
636+ for destination in ["../elsewhere.md", "./", "", "a/../b.md"] {
637+ assert!(
638+ readme_link("ada", "steid", &rev(), destination).is_none(),
639+ "{destination} should not resolve"
640+ );
641+ }
642+ }
643+}
src/infrastructure/web/repo_settings.rs+336 −0View file
@@ -0,0 +1,336 @@
1+//! Repository settings — `/{handle}/repos/{name}/settings`.
2+//!
3+//! Owner-only: change the description, change visibility, delete.
4+//!
5+//! `settings` sits in the verb position after `{name}`, where nothing user-controlled
6+//! ever appears, so unlike `new` it needs no reservation in
7+//! [`RepoName`](crate::domain::RepoName).
8+//!
9+//! **A non-owner gets a 404, not a 403.** That is the rule
10+//! [`view_repo`](crate::application::view_repo) already sets for repositories: a 403
11+//! would confirm that a private repository by that name exists, and there is no reason
12+//! for the settings page to be more talkative than the repository page it belongs to.
13+
14+use serde::Deserialize;
15+use topcoat::{
16+ Result,
17+ context::Cx,
18+ router::{StatusCode, content::Form, error::not_found, page, query_params},
19+ view::{attributes, component, view},
20+};
21+
22+use crate::{
23+ application::{
24+ Error, RepoView,
25+ repo::{RepoEdit, delete_repo, update_repo},
26+ },
27+ components::{
28+ button::{ButtonVariant, button},
29+ flash::{FlashKind, flash},
30+ input::input,
31+ label::label,
32+ select::select,
33+ textarea::textarea,
34+ },
35+ domain::{DomainError, RepoName, Repository, Visibility},
36+};
37+
38+use super::{
39+ context::{current_actor, location, memberships, orgs, repos, server_error, storage},
40+ repo::repo_for,
41+};
42+
43+#[derive(Debug, Deserialize)]
44+struct SettingsForm {
45+ description: String,
46+ visibility: String,
47+}
48+
49+#[derive(Debug, Deserialize)]
50+struct DeleteForm {
51+ confirm: String,
52+}
53+
54+/// Set after a successful save so the confirmation survives the redirect.
55+///
56+/// Post-redirect-get: reloading after a save must not resubmit it.
57+#[query_params(error = bad_request)]
58+struct Saved {
59+ saved: Option<String>,
60+}
61+
62+/// Blank input means "clear this field", which the domain treats as unset.
63+fn optional(value: &str) -> Option<String> {
64+ Some(value.trim().to_owned()).filter(|value| !value.is_empty())
65+}
66+
67+/// Resolves the repository in the path, or 404 for anyone who does not own it.
68+///
69+/// [`repo_for`] already 404s for a repository the viewer may not see; this adds the
70+/// stronger half. The use cases decide this too — the guard here only keeps a page from
71+/// rendering for someone whose submission would be refused anyway.
72+async fn owned_repo(cx: &Cx) -> Result<RepoView> {
73+ let repo = repo_for(cx).await?;
74+
75+ if !repo.viewer_is_owner {
76+ return Err(not_found().into());
77+ }
78+
79+ Ok(repo)
80+}
81+
82+#[page("/{handle}/repos/{name}/settings")]
83+async fn repo_settings_page(cx: &Cx) -> Result {
84+ let repo = owned_repo(cx).await?;
85+ let saved = query_params::<Saved>(cx)?.saved.is_some();
86+
87+ view! {
88+ settings_view(
89+ handle: repo.handle.as_str(),
90+ name: repo.name.as_str(),
91+ description: repo.description.as_deref().unwrap_or(""),
92+ visibility: repo.visibility,
93+ saved: saved,
94+ error: "",
95+ )
96+ }
97+}
98+
99+/// Saves the description and visibility.
100+///
101+/// Success replies 303 so a reload cannot resubmit — see
102+/// [`location`](super::context::location) for why it is spelled this way rather than
103+/// with `redirect()`, which is a 307 and would re-POST this form to itself.
104+///
105+/// Failure re-renders with the reason and **what was typed**, rather than bouncing back
106+/// to the stored values and hiding what went wrong.
107+#[page(POST "/{handle}/repos/{name}/settings")]
108+async fn save(cx: &Cx, Form(submitted): Form<SettingsForm>) -> Result {
109+ let repo = owned_repo(cx).await?;
110+
111+ // An unparseable value is a tampered form, not something to default: defaulting
112+ // here could publish a repository the owner asked to keep private.
113+ let visibility = submitted
114+ .visibility
115+ .parse::<Visibility>()
116+ .map_err(|_| topcoat::router::error::bad_request("unknown visibility"))?;
117+
118+ let outcome = update_repo(
119+ &current_actor(cx).await?,
120+ &repo.handle,
121+ &repo.name,
122+ &RepoEdit {
123+ description: optional(&submitted.description),
124+ visibility,
125+ },
126+ &orgs(cx),
127+ &memberships(cx),
128+ &repos(cx),
129+ )
130+ .await;
131+
132+ let message = match outcome {
133+ Ok(_) => {
134+ return view! {
135+ (StatusCode::SEE_OTHER)
136+ (location(&format!(
137+ "/{}/repos/{}/settings?saved",
138+ repo.handle, repo.name
139+ ))?)
140+ };
141+ }
142+ // The visitor's to fix, so it is shown.
143+ Err(Error::Domain(DomainError::Validation { field, reason })) => {
144+ format!("That {field} is no good: {reason}.")
145+ }
146+ // Both mean "not yours" here, and both look like a page that is not there.
147+ Err(Error::Domain(DomainError::Forbidden | DomainError::NotFound { .. })) => {
148+ return Err(not_found().into());
149+ }
150+ // Ours, so it is logged and answered generically.
151+ Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
152+ };
153+
154+ view! {
155+ settings_view(
156+ handle: repo.handle.as_str(),
157+ name: repo.name.as_str(),
158+ description: submitted.description.as_str(),
159+ visibility: visibility,
160+ saved: false,
161+ error: message.as_str(),
162+ )
163+ }
164+}
165+
166+/// Deletes the repository, then sends the owner back to their profile.
167+///
168+/// The repository page no longer exists, so there is nowhere else to go.
169+///
170+/// **Guarded by typing the name.** Deletion takes the git history with it and there is
171+/// no undo; a single button is too easy to hit by accident for something unrecoverable.
172+/// The typed value goes through [`RepoName::new`], so it is compared the same way the
173+/// name was normalised on the way in — `MyRepo` confirms `myrepo`, and stray whitespace
174+/// is not a reason to refuse.
175+#[page(POST "/{handle}/repos/{name}/settings/delete")]
176+async fn delete(cx: &Cx, Form(submitted): Form<DeleteForm>) -> Result {
177+ let repo = owned_repo(cx).await?;
178+
179+ let confirmed = RepoName::new(&submitted.confirm).is_ok_and(|typed| typed == repo.name);
180+
181+ if !confirmed {
182+ let message = format!(
183+ "Type {} exactly to confirm. Nothing was deleted.",
184+ repo.name
185+ );
186+
187+ return view! {
188+ settings_view(
189+ handle: repo.handle.as_str(),
190+ name: repo.name.as_str(),
191+ description: repo.description.as_deref().unwrap_or(""),
192+ visibility: repo.visibility,
193+ saved: false,
194+ error: message.as_str(),
195+ )
196+ };
197+ }
198+
199+ match delete_repo(
200+ &current_actor(cx).await?,
201+ &repo.handle,
202+ &repo.name,
203+ &orgs(cx),
204+ &memberships(cx),
205+ &repos(cx),
206+ &storage(cx),
207+ )
208+ .await
209+ {
210+ Ok(()) => {}
211+ Err(Error::Domain(DomainError::Forbidden | DomainError::NotFound { .. })) => {
212+ return Err(not_found().into());
213+ }
214+ Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
215+ }
216+
217+ view! {
218+ (StatusCode::SEE_OTHER)
219+ (location(&format!("/{}", repo.handle))?)
220+ }
221+}
222+
223+/// The settings page.
224+///
225+/// Values arrive as parameters rather than being read back from storage, so a rejected
226+/// submission can re-render exactly what was typed.
227+#[component]
228+async fn settings_view(
229+ handle: &str,
230+ name: &str,
231+ description: &str,
232+ visibility: Visibility,
233+ saved: bool,
234+ error: &str,
235+) -> Result {
236+ view! {
237+ <h1 class="text-xl font-semibold tracking-tight">"Repository settings"</h1>
238+ <p class="mt-1 font-mono text-sm text-muted-foreground">
239+ "@" (handle) " / " (name)
240+ </p>
241+
242+ if saved {
243+ <div class="mt-6">
244+ flash(kind: FlashKind::Success, "Repository updated.")
245+ </div>
246+ }
247+
248+ if !error.is_empty() {
249+ <div class="mt-6">
250+ flash(kind: FlashKind::Error, (error))
251+ </div>
252+ }
253+
254+ <form
255+ method="post"
256+ action=(format!("/{handle}/repos/{name}/settings"))
257+ class="mt-6 space-y-5"
258+ >
259+ <div class="space-y-2">
260+ label(attrs: attributes! { for="description" }, "Description")
261+ textarea(
262+ attrs: attributes! {
263+ id="description"
264+ name="description"
265+ rows="2"
266+ maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
267+ placeholder="A sentence for your profile."
268+ },
269+ (description)
270+ )
271+ <p class="text-xs text-muted-foreground">
272+ "Optional. At most "
273+ (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
274+ </p>
275+ </div>
276+
277+ <div class="space-y-2">
278+ label(attrs: attributes! { for="visibility" }, "Visibility")
279+ select(
280+ attrs: attributes! { id="visibility" name="visibility" },
281+ <option value="public" selected=(visibility.is_public())>"Public"</option>
282+ <option value="private" selected=(!visibility.is_public())>"Private"</option>
283+ )
284+ <p class="text-xs text-muted-foreground">
285+ "A private repository is hidden from your profile and needs a token "
286+ "to clone."
287+ </p>
288+ </div>
289+
290+ <div class="flex items-center gap-3">
291+ button(attrs: attributes! { type="submit" }, "Save")
292+ <a
293+ href=(format!("/{handle}/repos/{name}"))
294+ class="text-sm text-muted-foreground hover:text-foreground"
295+ >"Back to repository"</a>
296+ </div>
297+ </form>
298+
299+ <section class="mt-10 rounded-lg border border-destructive/30 p-4">
300+ <h2 class="text-xs font-medium uppercase tracking-wider text-destructive">
301+ "Delete this repository"
302+ </h2>
303+ <p class="mt-2 text-sm text-muted-foreground">
304+ "This cannot be undone. The commits, branches and tags go with it, and "
305+ "anyone with a clone keeps their copy while this instance keeps nothing."
306+ </p>
307+
308+ <form
309+ method="post"
310+ action=(format!("/{handle}/repos/{name}/settings/delete"))
311+ class="mt-4 space-y-2"
312+ >
313+ label(
314+ attrs: attributes! { for="confirm" },
315+ "Type " (name) " to confirm"
316+ )
317+ input(attrs: attributes! {
318+ id="confirm"
319+ name="confirm"
320+ type="text"
321+ value=""
322+ placeholder=(name)
323+ required=(true)
324+ autocomplete="off"
325+ })
326+ <div class="pt-1">
327+ button(
328+ attrs: attributes! { type="submit" },
329+ variant: ButtonVariant::Destructive,
330+ "Delete repository"
331+ )
332+ </div>
333+ </form>
334+ </section>
335+ }
336+}