fabrica

hanna/fabrica

feat(git,web,cli): smart-HTTP pack transport and post-receive hook

6055231 · hanna committed on 2026-07-25

Add the read-only smart-HTTP git transport over a single shared spawn
path (git::pack::spawn), used now by HTTP and later by SSH.

- git::pack spawns upload-pack/receive-pack/upload-archive with the §3.3
  isolation env (GIT_DIR, GIT_TERMINAL_PROMPT=0, GIT_CONFIG_NOSYSTEM=1,
  HOME, and the FABRICA_* identity for hooks), piped stdio, kill_on_drop.
- web serves GET .git/info/refs (framed advertisement) and POST
  git-upload-pack: authorize BEFORE spawning; the small request is
  buffered (gunzipped if needed) and the pack response streams from the
  child's stdout, never buffered. Anonymous access to a private repo gets
  401 Basic; an authenticated stranger gets 404. HTTP push returns a
  deliberate 403 naming the SSH URL.
- fabrica hook post-receive updates pushed_at/size_bytes (store gains
  record_push), keyed by the GIT_DIR basename, always exiting 0.

Verified end-to-end: anonymous public clone, 401 private, Basic private
clone, and the 403 push all behave per spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
13 files changed · +715 −2UnifiedSplit
Cargo.lock +4 −0
@@ -1541,6 +1541,7 @@ dependencies = [
15411541 "ssh-key",
15421542 "tempfile",
15431543 "thiserror 2.0.19",
1544+ "tokio",
15441545 ]
15451546
15461547 [[package]]
@@ -4381,8 +4382,10 @@ dependencies = [
43814382 "auth",
43824383 "axum",
43834384 "axum-extra",
4385+ "base64",
43844386 "comrak",
43854387 "config",
4388+ "flate2",
43864389 "git",
43874390 "highlight",
43884391 "mail",
@@ -4396,6 +4399,7 @@ dependencies = [
43964399 "thiserror 2.0.19",
43974400 "time",
43984401 "tokio",
4402+ "tokio-util",
43994403 "tower",
44004404 "tower-http",
44014405 "tracing",
Cargo.toml +6 −0
@@ -84,6 +84,8 @@ tokio = { version = "1", features = [
8484 "net",
8585 "time",
8686 "fs",
87+ "process",
88+ "io-util",
8789 ] }
8890 tempfile = "3"
8991 # Git object model (refs, trees, blobs, commits, diffs, signatures). Linked
@@ -144,6 +146,10 @@ time = "0.3"
144146 # Markdown rendering (GFM) + HTML sanitization for READMEs and comments.
145147 comrak = { version = "0.54", default-features = false }
146148 ammonia = "4"
149+# Streaming the pack subprocess stdout into the HTTP response body; gunzip for
150+# gzip-encoded upload-pack requests.
151+tokio-util = { version = "0.7", features = ["io"] }
152+flate2 = "1"
147153 tracing = "0.1"
148154 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
149155
crates/cli/src/hook_cmd.rs +96 −0
@@ -0,0 +1,96 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! `fabrica hook post-receive` — the git hook entry point (internal, hidden).
6+//!
7+//! Invoked by the `post-receive` hook the server installs at repo init. It updates
8+//! `pushed_at` and `size_bytes` for the pushed repo. A hook failure **must not**
9+//! reject a push git already accepted, so this always exits `0`, logging loudly on
10+//! error.
11+
12+use std::io::Read as _;
13+use std::path::{Path, PathBuf};
14+use std::process::ExitCode;
15+
16+use clap::Subcommand;
17+
18+use crate::context::{block_on, open_store};
19+
20+/// Actions under the hidden `fabrica hook` group.
21+#[derive(Debug, Subcommand)]
22+pub(crate) enum HookCommand {
23+ /// Run after a push is accepted.
24+ PostReceive,
25+}
26+
27+/// Dispatch a hook subcommand. Always exits successfully.
28+pub(crate) fn run(command: &HookCommand) -> ExitCode {
29+ match command {
30+ HookCommand::PostReceive => {
31+ if let Err(err) = post_receive() {
32+ // Loud, but non-fatal: the push has already been accepted.
33+ eprintln!("fabrica hook post-receive: {err:#}");
34+ }
35+ ExitCode::SUCCESS
36+ }
37+ }
38+}
39+
40+/// The fallible body of `post-receive`.
41+fn post_receive() -> anyhow::Result<()> {
42+ // Drain stdin (`<old> <new> <ref>` lines) so git does not see a broken pipe,
43+ // even though we do not currently act per-ref.
44+ let mut input = String::new();
45+ std::io::stdin().read_to_string(&mut input)?;
46+
47+ let git_dir = std::env::var_os("GIT_DIR")
48+ .map(PathBuf::from)
49+ .or_else(|| std::env::current_dir().ok())
50+ .ok_or_else(|| anyhow::anyhow!("cannot determine GIT_DIR"))?;
51+
52+ // The bare repo dir is `{id}.git`; the stem is the repo's ULID.
53+ let Some(repo_id) = git_dir
54+ .file_name()
55+ .and_then(|n| n.to_str())
56+ .and_then(|n| n.strip_suffix(".git"))
57+ .filter(|id| id.len() == 26)
58+ else {
59+ // Not a fabrica-managed repository; nothing to do.
60+ return Ok(());
61+ };
62+ let repo_id = repo_id.to_string();
63+
64+ let config_path = std::env::var_os("FABRICA_CONFIG").map(PathBuf::from);
65+ let size = dir_size(&git_dir);
66+
67+ block_on(async move {
68+ let (_config, store) = open_store(config_path.as_deref()).await?;
69+ store.record_push(&repo_id, size).await?;
70+ anyhow::Ok(())
71+ })
72+}
73+
74+/// Sum the sizes of every regular file under `dir` (an approximate on-disk size).
75+fn dir_size(dir: &Path) -> i64 {
76+ fn walk(dir: &Path, total: &mut u64) {
77+ let Ok(entries) = std::fs::read_dir(dir) else {
78+ return;
79+ };
80+ for entry in entries.flatten() {
81+ let path = entry.path();
82+ match entry.file_type() {
83+ Ok(ft) if ft.is_dir() => walk(&path, total),
84+ Ok(ft) if ft.is_file() => {
85+ if let Ok(meta) = entry.metadata() {
86+ *total += meta.len();
87+ }
88+ }
89+ _ => {}
90+ }
91+ }
92+ }
93+ let mut total = 0u64;
94+ walk(dir, &mut total);
95+ i64::try_from(total).unwrap_or(i64::MAX)
96+}
crates/cli/src/lib.rs +9 −0
@@ -13,6 +13,7 @@ mod admin_cmd;
1313 mod config_cmd;
1414 mod context;
1515 mod group_cmd;
16+mod hook_cmd;
1617 mod key_cmd;
1718 mod prompt;
1819 mod repo_cmd;
@@ -104,6 +105,13 @@ enum Command {
104105 #[arg(long)]
105106 quiet: bool,
106107 },
108+ /// Internal git-hook entry points (invoked by installed hooks).
109+ #[command(hide = true)]
110+ Hook {
111+ /// The hook to run.
112+ #[command(subcommand)]
113+ command: hook_cmd::HookCommand,
114+ },
107115 }
108116
109117 /// The parsed request to run `fabrica serve`, handed to the serve callback the
@@ -169,5 +177,6 @@ where
169177 Command::Token { command } => token_cmd::run(command, cli.config.as_deref(), cli.json),
170178 Command::Migrate => admin_cmd::migrate(cli.config.as_deref()),
171179 Command::Doctor { quiet } => admin_cmd::doctor(cli.config.as_deref(), *quiet),
180+ Command::Hook { command } => Ok(hook_cmd::run(command)),
172181 }
173182 }
crates/git/Cargo.toml +3 −0
@@ -16,6 +16,9 @@ globset = { workspace = true }
1616 moka = { workspace = true }
1717 ssh-key = { workspace = true }
1818 pgp = { workspace = true }
19+# Async subprocess spawning for pack transport (clone/fetch/push). One code path
20+# shared by the HTTP (web) and SSH transports.
21+tokio = { workspace = true }
1922 thiserror = { workspace = true }
2023 # Pin the libgit2 bindings to a build that requires libgit2 <= the version
2124 # nixpkgs ships (1.9.4). git2 0.20's default libgit2-sys (0.18.7, bundling 1.9.6)
crates/git/src/lib.rs +1 −0
@@ -28,6 +28,7 @@
2828 //! name→path authority; [`repo_path`] is the one place that mapping is computed.
2929
3030 mod attributes;
31+pub mod pack;
3132 mod pubkey;
3233 mod repo;
3334 mod signature;
crates/git/src/pack.rs +176 −0
@@ -0,0 +1,176 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Pack transport: the single code path that spawns `git upload-pack` /
6+//! `git receive-pack` / `git upload-archive`.
7+//!
8+//! libgit2 has no server side of the pack protocol, so clone/fetch/push are served
9+//! by spawning the `git` binary (§3.1). Both the HTTP (web) and SSH transports call
10+//! [`spawn`]. **Authorization happens before the spawn — the subprocess never makes
11+//! an access decision** — and both directions stream; the pack is never buffered by
12+//! the transport. The child is `kill_on_drop` so a dropped request reaps it.
13+
14+use std::path::{Path, PathBuf};
15+use std::process::Stdio;
16+
17+use tokio::process::{Child, Command};
18+
19+/// The git service to run.
20+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21+pub enum Service {
22+ /// `git upload-pack` — serves clone/fetch (read).
23+ UploadPack,
24+ /// `git receive-pack` — serves push (write).
25+ ReceivePack,
26+ /// `git upload-archive` — serves `git archive --remote`.
27+ UploadArchive,
28+}
29+
30+impl Service {
31+ /// The `git` subcommand name.
32+ fn subcommand(self) -> &'static str {
33+ match self {
34+ Self::UploadPack => "upload-pack",
35+ Self::ReceivePack => "receive-pack",
36+ Self::UploadArchive => "upload-archive",
37+ }
38+ }
39+}
40+
41+/// Environment applied to every pack subprocess: isolation for git, plus the
42+/// identity fields hooks consume.
43+#[derive(Debug, Clone, Default)]
44+pub struct PackEnv {
45+ /// `HOME` for the child (a scratch dir under `data_dir`), so git never reads a
46+ /// real user's global config.
47+ pub home: PathBuf,
48+ /// The client's `Git-Protocol` value, passed through as `GIT_PROTOCOL` when v2
49+ /// was negotiated.
50+ pub protocol: Option<String>,
51+ /// Config path for `fabrica hook post-receive` to load (`FABRICA_CONFIG`).
52+ pub config_path: Option<String>,
53+ /// The authenticated user's id (`FABRICA_USER_ID`).
54+ pub user_id: Option<String>,
55+ /// The authenticated user's name (`FABRICA_USER_NAME`).
56+ pub user_name: Option<String>,
57+ /// The repository id (`FABRICA_REPO_ID`).
58+ pub repo_id: Option<String>,
59+ /// The SSH key id used to authenticate (`FABRICA_KEY_ID`).
60+ pub key_id: Option<String>,
61+}
62+
63+/// Spawn a pack service against `repo_path` with piped stdio.
64+///
65+/// `extra_args` are inserted before the repository path (e.g. `--stateless-rpc`,
66+/// `--advertise-refs` for smart HTTP). The caller must have already authorized the
67+/// request. Never interpolates into a shell — args are explicit.
68+///
69+/// # Errors
70+///
71+/// Returns the [`std::io::Error`] from spawning if the `git` binary cannot be run.
72+pub fn spawn(
73+ binary: &str,
74+ service: Service,
75+ extra_args: &[&str],
76+ repo_path: &Path,
77+ env: &PackEnv,
78+) -> std::io::Result<Child> {
79+ let mut cmd = Command::new(binary);
80+ cmd.arg(service.subcommand());
81+ cmd.args(extra_args);
82+ cmd.arg(repo_path);
83+
84+ // Isolation (§3.3).
85+ cmd.env("GIT_DIR", repo_path);
86+ cmd.env("GIT_TERMINAL_PROMPT", "0");
87+ cmd.env("GIT_CONFIG_NOSYSTEM", "1");
88+ cmd.env("HOME", &env.home);
89+ // Do not leak the parent's git environment into the child.
90+ cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES");
91+ cmd.env_remove("GIT_OBJECT_DIRECTORY");
92+
93+ if let Some(protocol) = &env.protocol {
94+ cmd.env("GIT_PROTOCOL", protocol);
95+ }
96+ for (key, value) in [
97+ ("FABRICA_CONFIG", &env.config_path),
98+ ("FABRICA_USER_ID", &env.user_id),
99+ ("FABRICA_USER_NAME", &env.user_name),
100+ ("FABRICA_REPO_ID", &env.repo_id),
101+ ("FABRICA_KEY_ID", &env.key_id),
102+ ] {
103+ if let Some(value) = value {
104+ cmd.env(key, value);
105+ }
106+ }
107+
108+ cmd.stdin(Stdio::piped());
109+ cmd.stdout(Stdio::piped());
110+ cmd.stderr(Stdio::piped());
111+ cmd.kill_on_drop(true);
112+ cmd.spawn()
113+}
114+
115+/// Encode `payload` as a single git pkt-line (`{len:04x}{payload}`). Used to frame
116+/// the smart-HTTP service advertisement.
117+#[must_use]
118+pub fn pkt_line(payload: &str) -> String {
119+ format!("{:04x}{payload}", payload.len() + 4)
120+}
121+
122+/// The pkt-line flush packet.
123+pub const FLUSH_PKT: &str = "0000";
124+
125+#[cfg(test)]
126+mod tests {
127+ #![allow(clippy::unwrap_used, clippy::expect_used)]
128+
129+ use super::*;
130+
131+ #[test]
132+ fn pkt_line_frames_length() {
133+ // "# service=git-upload-pack\n" is 26 bytes; framed length is 26 + 4 = 30.
134+ assert_eq!(
135+ pkt_line("# service=git-upload-pack\n"),
136+ "001e# service=git-upload-pack\n"
137+ );
138+ assert_eq!(pkt_line("a"), "0005a");
139+ }
140+
141+ #[tokio::test]
142+ async fn spawn_upload_pack_advertises_refs_on_a_real_repo() {
143+ use std::path::Path;
144+
145+ use crate::create_bare;
146+
147+ // git must be present; skip cleanly otherwise (matches the test policy).
148+ if Command::new("git").arg("--version").output().await.is_err() {
149+ eprintln!("skipping: git not on PATH");
150+ return;
151+ }
152+
153+ let dir = tempfile::tempdir().expect("tempdir");
154+ let id = "01hzxk9m2n8p7q6r5s4t3v2w1x";
155+ create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).expect("create bare");
156+ let repo_path = crate::repo_path(dir.path(), id).expect("repo path");
157+
158+ let env = PackEnv {
159+ home: dir.path().to_path_buf(),
160+ ..PackEnv::default()
161+ };
162+ let child = spawn(
163+ "git",
164+ Service::UploadPack,
165+ &["--stateless-rpc", "--advertise-refs"],
166+ &repo_path,
167+ &env,
168+ )
169+ .expect("spawn upload-pack");
170+ let output = child.wait_with_output().await.expect("wait");
171+ // A fresh repo advertises capabilities even with no refs; the output is
172+ // valid pkt-line data (or empty for an unborn HEAD), and the process
173+ // succeeds.
174+ assert!(output.status.success(), "upload-pack should exit cleanly");
175+ }
176+}
crates/store/src/repos.rs +20 −0
@@ -182,6 +182,26 @@ impl Store {
182182 Ok(updated > 0)
183183 }
184184
185+ /// Record that a repository was just pushed to: stamp `pushed_at` and refresh
186+ /// `size_bytes`. Called by `fabrica hook post-receive`.
187+ ///
188+ /// # Errors
189+ ///
190+ /// Returns [`StoreError::Query`] on a database failure.
191+ pub async fn record_push(&self, repo_id: &str, size_bytes: i64) -> Result<bool, StoreError> {
192+ let now = now_ms();
193+ let updated = sqlx::query(
194+ "UPDATE repos SET pushed_at = $1, size_bytes = $2, updated_at = $1 WHERE id = $3",
195+ )
196+ .bind(now)
197+ .bind(size_bytes)
198+ .bind(repo_id)
199+ .execute(&self.pool)
200+ .await?
201+ .rows_affected();
202+ Ok(updated > 0)
203+ }
204+
185205 /// Delete a repository row (cascading to collaborators). The caller is
186206 /// responsible for the on-disk directory — moving it to trash, or purging it.
187207 /// Returns `true` if a row was removed.
crates/web/Cargo.toml +3 −0
@@ -26,6 +26,9 @@ rust-embed = { workspace = true }
2626 mime_guess = { workspace = true }
2727 comrak = { workspace = true }
2828 ammonia = { workspace = true }
29+tokio-util = { workspace = true }
30+flate2 = { workspace = true }
31+base64 = { workspace = true }
2932 time = { workspace = true }
3033 serde = { workspace = true }
3134 serde_json = { workspace = true }
crates/web/src/git_http.rs +342 −0
@@ -0,0 +1,342 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! The smart-HTTP git transport (read-only).
6+//!
7+//! Only clone/fetch (`upload-pack`) is served; push (`receive-pack`) returns a
8+//! deliberate `403` pointing at the SSH URL (§3.3). Authorization happens **before**
9+//! the `git` subprocess is spawned. The refs advertisement is small and buffered;
10+//! the pack response streams straight from the child's stdout so it is never held
11+//! in memory. Dumb HTTP is not implemented.
12+
13+use axum::body::Body;
14+use axum::extract::{Path, State};
15+use axum::http::{HeaderMap, StatusCode, header};
16+use axum::response::{IntoResponse, Response};
17+use axum_extra::extract::cookie::CookieJar;
18+use base64::Engine as _;
19+use model::{Repo, User};
20+use tokio::io::AsyncWriteExt as _;
21+use tokio_util::io::ReaderStream;
22+
23+use crate::AppState;
24+
25+/// Split `"repo/path.git/service..."` into the repo path and the trailing service
26+/// path (`info/refs`, `git-upload-pack`, `git-receive-pack`). Also accepts the
27+/// forms without `.git` is *not* supported here — callers strip that themselves.
28+#[must_use]
29+pub fn split_git(rest: &str) -> Option<(&str, &str)> {
30+ let idx = rest.find(".git/")?;
31+ Some((&rest[..idx], &rest[idx + ".git/".len()..]))
32+}
33+
34+/// Handle a GET under a `.git/` path — only `info/refs?service=git-upload-pack`.
35+pub async fn http_get(
36+ state: &AppState,
37+ jar: &CookieJar,
38+ headers: &HeaderMap,
39+ owner: &str,
40+ repo_path: &str,
41+ tail: &str,
42+ service: Option<&str>,
43+) -> Response {
44+ if tail != "info/refs" {
45+ return StatusCode::NOT_FOUND.into_response();
46+ }
47+ match service {
48+ Some("git-upload-pack") => {}
49+ Some("git-receive-pack") => return push_disabled(state, owner, repo_path),
50+ _ => return StatusCode::FORBIDDEN.into_response(),
51+ }
52+
53+ let repo = match authorize(state, jar, headers, owner, repo_path).await {
54+ Ok(repo) => repo,
55+ Err(response) => return response,
56+ };
57+ info_refs(state, &repo, headers).await
58+}
59+
60+/// Handle a POST under a `.git/` path — `git-upload-pack` (served) or
61+/// `git-receive-pack` (403).
62+pub async fn pack_rpc(
63+ State(state): State<AppState>,
64+ jar: CookieJar,
65+ headers: HeaderMap,
66+ Path((owner, rest)): Path<(String, String)>,
67+ body: Body,
68+) -> Response {
69+ let Some((repo_path, tail)) = split_git(&rest) else {
70+ return StatusCode::NOT_FOUND.into_response();
71+ };
72+ match tail {
73+ "git-upload-pack" => {}
74+ "git-receive-pack" => return push_disabled(&state, &owner, repo_path),
75+ _ => return StatusCode::NOT_FOUND.into_response(),
76+ }
77+
78+ let repo = match authorize(&state, &jar, &headers, &owner, repo_path).await {
79+ Ok(repo) => repo,
80+ Err(response) => return response,
81+ };
82+ upload_pack_rpc(&state, &repo, &headers, body).await
83+}
84+
85+/// Spawn `upload-pack --advertise-refs`, frame the output as a smart-HTTP service
86+/// advertisement, and return it.
87+async fn info_refs(state: &AppState, repo: &Repo, headers: &HeaderMap) -> Response {
88+ let repo_dir = match git::repo_path(&state.config.storage.repo_dir, &repo.id) {
89+ Ok(p) => p,
90+ Err(e) => return internal(&e.to_string()),
91+ };
92+ let env = pack_env(state, headers, repo);
93+ let child = git::pack::spawn(
94+ &state.config.git.binary,
95+ git::pack::Service::UploadPack,
96+ &["--stateless-rpc", "--advertise-refs"],
97+ &repo_dir,
98+ &env,
99+ );
100+ let output = match child {
101+ Ok(child) => match child.wait_with_output().await {
102+ Ok(out) => out,
103+ Err(e) => return internal(&e.to_string()),
104+ },
105+ Err(e) => return internal(&e.to_string()),
106+ };
107+
108+ let mut framed = git::pack::pkt_line("# service=git-upload-pack\n").into_bytes();
109+ framed.extend_from_slice(git::pack::FLUSH_PKT.as_bytes());
110+ framed.extend_from_slice(&output.stdout);
111+
112+ (
113+ [
114+ (
115+ header::CONTENT_TYPE,
116+ "application/x-git-upload-pack-advertisement",
117+ ),
118+ (header::CACHE_CONTROL, "no-cache"),
119+ ],
120+ framed,
121+ )
122+ .into_response()
123+}
124+
125+/// Spawn `upload-pack --stateless-rpc`, feed it the (buffered, possibly gunzipped)
126+/// request, and stream its stdout back as the response body.
127+async fn upload_pack_rpc(
128+ state: &AppState,
129+ repo: &Repo,
130+ headers: &HeaderMap,
131+ body: Body,
132+) -> Response {
133+ let repo_dir = match git::repo_path(&state.config.storage.repo_dir, &repo.id) {
134+ Ok(p) => p,
135+ Err(e) => return internal(&e.to_string()),
136+ };
137+
138+ // The upload-pack request (the client's "wants") is small; buffer it, and
139+ // decompress if the client gzipped it. The large pack is the *response*, which
140+ // is streamed below.
141+ let Ok(raw) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else {
142+ return (StatusCode::BAD_REQUEST, "request body too large").into_response();
143+ };
144+ let request = if header_eq(headers, header::CONTENT_ENCODING, "gzip") {
145+ match gunzip(&raw) {
146+ Ok(data) => data,
147+ Err(_) => return (StatusCode::BAD_REQUEST, "invalid gzip body").into_response(),
148+ }
149+ } else {
150+ raw.to_vec()
151+ };
152+
153+ let env = pack_env(state, headers, repo);
154+ let mut child = match git::pack::spawn(
155+ &state.config.git.binary,
156+ git::pack::Service::UploadPack,
157+ &["--stateless-rpc"],
158+ &repo_dir,
159+ &env,
160+ ) {
161+ Ok(child) => child,
162+ Err(e) => return internal(&e.to_string()),
163+ };
164+
165+ let (Some(mut stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
166+ return internal("failed to capture pack subprocess stdio");
167+ };
168+
169+ // Feed the request, then own the child until it exits (or the client
170+ // disconnects, closing stdout and making upload-pack exit on write).
171+ tokio::spawn(async move {
172+ let _ = stdin.write_all(&request).await;
173+ let _ = stdin.shutdown().await;
174+ drop(stdin);
175+ let _ = child.wait().await;
176+ });
177+
178+ let stream = ReaderStream::new(stdout);
179+ (
180+ [(header::CONTENT_TYPE, "application/x-git-upload-pack-result")],
181+ Body::from_stream(stream),
182+ )
183+ .into_response()
184+}
185+
186+/// Resolve and authorize a repo for pack access. On failure returns the response
187+/// to send: `401` (anonymous, credentials needed) or `404` (missing, or an
188+/// authenticated stranger — no existence leak).
189+#[allow(clippy::result_large_err)] // the Err is a ready-to-send Response by design.
190+async fn authorize(
191+ state: &AppState,
192+ jar: &CookieJar,
193+ headers: &HeaderMap,
194+ owner: &str,
195+ repo_path: &str,
196+) -> Result<Repo, Response> {
197+ let owner_user = state.store.user_by_username(owner).await.ok().flatten();
198+ let repo = match owner_user {
199+ Some(ref u) => state
200+ .store
201+ .repo_by_owner_path(&u.id, repo_path)
202+ .await
203+ .ok()
204+ .flatten(),
205+ None => None,
206+ };
207+ let Some(repo) = repo else {
208+ return Err(StatusCode::NOT_FOUND.into_response());
209+ };
210+
211+ let viewer = authenticate(state, jar, headers).await;
212+ let viewer_id = viewer.as_ref().map(|u| auth::Viewer {
213+ id: u.id.clone(),
214+ is_admin: u.is_admin,
215+ });
216+ let collaborator = match &viewer {
217+ Some(u) => state
218+ .store
219+ .collaborator_permission(&repo.id, &u.id)
220+ .await
221+ .ok()
222+ .flatten()
223+ .and_then(|p| auth::Permission::parse(&p)),
224+ None => None,
225+ };
226+ let access = auth::access(
227+ viewer_id.as_ref(),
228+ &repo,
229+ collaborator,
230+ state.config.instance.allow_anonymous,
231+ );
232+ if access >= auth::Access::Read {
233+ Ok(repo)
234+ } else if viewer.is_none() {
235+ Err(unauthorized())
236+ } else {
237+ // An authenticated stranger: 404, not 403, so existence never leaks.
238+ Err(StatusCode::NOT_FOUND.into_response())
239+ }
240+}
241+
242+/// Authenticate a pack request via the session cookie or HTTP Basic (username +
243+/// password). Returns the user, or `None` for anonymous.
244+async fn authenticate(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> Option<User> {
245+ if let Some(cookie) = jar.get(&state.config.auth.cookie_name)
246+ && let Ok(Some(user)) = state
247+ .store
248+ .user_for_session(&auth::session_id(cookie.value()))
249+ .await
250+ {
251+ return Some(user);
252+ }
253+ let (username, password) = parse_basic(headers)?;
254+ let user = state
255+ .store
256+ .user_by_username(&username)
257+ .await
258+ .ok()
259+ .flatten()?;
260+ if user.disabled_at.is_none()
261+ && user.password_hash.is_some()
262+ && auth::verify_password(&password, user.password_hash.as_deref())
263+ {
264+ Some(user)
265+ } else {
266+ None
267+ }
268+}
269+
270+/// Parse an HTTP Basic `Authorization` header into `(username, password)`.
271+fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
272+ let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
273+ let encoded = value.strip_prefix("Basic ")?;
274+ let decoded = base64::engine::general_purpose::STANDARD
275+ .decode(encoded.trim())
276+ .ok()?;
277+ let text = String::from_utf8(decoded).ok()?;
278+ let (user, pass) = text.split_once(':')?;
279+ Some((user.to_string(), pass.to_string()))
280+}
281+
282+/// Build the subprocess environment for a pack spawn.
283+fn pack_env(state: &AppState, headers: &HeaderMap, repo: &Repo) -> git::pack::PackEnv {
284+ git::pack::PackEnv {
285+ home: state.config.storage.data_dir.join("tmp"),
286+ protocol: headers
287+ .get("git-protocol")
288+ .and_then(|v| v.to_str().ok())
289+ .map(str::to_string),
290+ repo_id: Some(repo.id.clone()),
291+ ..git::pack::PackEnv::default()
292+ }
293+}
294+
295+/// The `403` push-over-HTTPS-disabled response, naming the SSH remote.
296+fn push_disabled(state: &AppState, owner: &str, repo_path: &str) -> Response {
297+ let host = &state.config.ssh.clone_host;
298+ let port = state.config.ssh.clone_port;
299+ let ssh_url = if port == 22 {
300+ format!("git@{host}:{owner}/{repo_path}.git")
301+ } else {
302+ format!("ssh://git@{host}:{port}/{owner}/{repo_path}.git")
303+ };
304+ (
305+ StatusCode::FORBIDDEN,
306+ format!("Pushing over HTTPS is disabled. Push over SSH instead:\n\n {ssh_url}\n"),
307+ )
308+ .into_response()
309+}
310+
311+/// The `401` challenge for anonymous access to a private repo.
312+fn unauthorized() -> Response {
313+ (
314+ StatusCode::UNAUTHORIZED,
315+ [(header::WWW_AUTHENTICATE, "Basic realm=\"fabrica\"")],
316+ "Authentication required.\n",
317+ )
318+ .into_response()
319+}
320+
321+/// A `500` for an internal transport failure (detail logged, not shown).
322+fn internal(detail: &str) -> Response {
323+ tracing::error!(error.detail = %detail, "pack transport error");
324+ (StatusCode::INTERNAL_SERVER_ERROR, "internal error\n").into_response()
325+}
326+
327+/// Whether a header equals a value (ASCII case-insensitive).
328+fn header_eq(headers: &HeaderMap, name: header::HeaderName, value: &str) -> bool {
329+ headers
330+ .get(name)
331+ .and_then(|v| v.to_str().ok())
332+ .is_some_and(|v| v.eq_ignore_ascii_case(value))
333+}
334+
335+/// Decompress a gzip body.
336+fn gunzip(data: &[u8]) -> std::io::Result<Vec<u8>> {
337+ use std::io::Read as _;
338+ let mut decoder = flate2::read::GzDecoder::new(data);
339+ let mut out = Vec::new();
340+ decoder.read_to_end(&mut out)?;
341+ Ok(out)
342+}
crates/web/src/lib.rs +7 −1
@@ -13,6 +13,7 @@
1313 mod assets;
1414 mod diff;
1515 mod error;
16+mod git_http;
1617 mod layout;
1718 mod markdown;
1819 mod pages;
@@ -118,8 +119,13 @@ pub fn build_router(state: AppState) -> Router {
118119 .route("/theme/{file}", get(serve_assets::theme_named))
119120 .route("/assets/{*path}", get(serve_assets::assets))
120121 // Repository browsing. Variable-length paths split on `/-/` in the handler.
122+ // The same catch-all serves smart-HTTP pack transport: GET handles
123+ // `…​.git/info/refs` inside the dispatcher; POST is the pack RPC.
121124 .route("/{owner}", get(repo::user_page))
122- .route("/{owner}/{*rest}", get(repo::dispatch))
125+ .route(
126+ "/{owner}/{*rest}",
127+ get(repo::dispatch).post(git_http::pack_rpc),
128+ )
123129 .layer(
124130 ServiceBuilder::new()
125131 .layer(SetRequestIdLayer::new(
crates/web/src/repo.rs +22 −1
@@ -14,7 +14,7 @@
1414 use std::path::Path as FsPath;
1515
1616 use axum::extract::{Path, Query, State};
17-use axum::http::{Uri, header};
17+use axum::http::{HeaderMap, Uri, header};
1818 use axum::response::{Html, IntoResponse, Response};
1919 use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
2020 use maud::{Markup, html};
@@ -43,6 +43,8 @@ pub struct DiffQuery {
4343 from: Option<u32>,
4444 /// The 1-based last line of the context window.
4545 to: Option<u32>,
46+ /// The smart-HTTP service, for `…​.git/info/refs?service=`.
47+ service: Option<String>,
4648 }
4749
4850 /// The candidate README filenames, in lookup order.
@@ -173,10 +175,29 @@ pub async fn dispatch(
173175 State(state): State<AppState>,
174176 MaybeUser(viewer): MaybeUser,
175177 jar: CookieJar,
178+ headers: HeaderMap,
176179 uri: Uri,
177180 Query(dq): Query<DiffQuery>,
178181 Path((owner_name, rest)): Path<(String, String)>,
179182 ) -> AppResult<Response> {
183+ // Smart-HTTP pack transport: `{owner}/{path}.git/info/refs`. Guarded so a blob
184+ // path that merely contains ".git/" is never misrouted (those carry "/-/").
185+ if !rest.contains("/-/")
186+ && let Some((repo_path, tail)) = crate::git_http::split_git(&rest)
187+ && matches!(tail, "info/refs" | "git-upload-pack" | "git-receive-pack")
188+ {
189+ return Ok(crate::git_http::http_get(
190+ &state,
191+ &jar,
192+ &headers,
193+ &owner_name,
194+ repo_path,
195+ tail,
196+ dq.service.as_deref(),
197+ )
198+ .await);
199+ }
200+
180201 if let Some((repo_path, view)) = rest.split_once("/-/") {
181202 let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref())
182203 .await?
docs/decisions.md +26 −0
@@ -447,3 +447,29 @@ this app: pack-transport routes (§3.3) stream arbitrarily long clones and must
447447 never be cut off, so timeouts (if any) belong per-route on non-git paths, added in
448448 the polish phase. The theme picker is a real GET form (works without JS) that sets
449449 a `fabrica_theme` cookie; `SIGHUP` re-scans `themes_dir` via an `RwLock<Assets>`.
450+
451+## 2026-07-25 — Pack transport: 401 (not 404) for anonymous, buffered request
452+
453+**Decision:** Smart-HTTP pack routes return **401** with
454+`WWW-Authenticate: Basic` for anonymous access to a repo they can't read (so git
455+prompts for credentials), whereas an authenticated stranger gets **404** (no
456+existence leak). This differs from the browse UI, which always 404s. The
457+upload-pack **request** (client wants, small) is buffered — decompressing gzip when
458+present — while the **response** pack streams straight from the child's stdout via
459+`ReaderStream`, never buffered. Push over HTTP (`git-receive-pack`) returns a
460+deliberate 403 naming the SSH URL.
461+
462+**Alternatives:** 404 for anonymous too (no credential prompt, breaking
463+`git clone` of private repos); stream the request as well.
464+
465+**Rationale:** git's HTTP client only sends Basic credentials after a 401
466+challenge, so a private clone is impossible without it — the spec mandates the 401.
467+The authenticated-stranger 404 preserves the no-leak invariant for logged-in users.
468+The upload-pack request is a few KB of wants, so buffering it (and handling
469+`Content-Encoding: gzip`) is simpler than streaming and violates no memory
470+constraint; the pack that must never be buffered is the response, which is
471+streamed. HTTP auth is username+password (or session cookie); API-token Basic auth
472+lands with the API phase. `fabrica hook post-receive` derives the repo id from the
473+`GIT_DIR` basename and loads config from `FABRICA_CONFIG` (set by the SSH/HTTP
474+server on the child) or the default location; a local push with neither set no-ops
475+cleanly, and it always exits 0 so a hook error never rejects an accepted push.