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 = [
1541 "ssh-key",1541 "ssh-key",
1542 "tempfile",1542 "tempfile",
1543 "thiserror 2.0.19",1543 "thiserror 2.0.19",
1544 "tokio",
1544]1545]
15451546
1546[[package]]1547[[package]]
@@ -4381,8 +4382,10 @@ dependencies = [
4381 "auth",4382 "auth",
4382 "axum",4383 "axum",
4383 "axum-extra",4384 "axum-extra",
4385 "base64",
4384 "comrak",4386 "comrak",
4385 "config",4387 "config",
4388 "flate2",
4386 "git",4389 "git",
4387 "highlight",4390 "highlight",
4388 "mail",4391 "mail",
@@ -4396,6 +4399,7 @@ dependencies = [
4396 "thiserror 2.0.19",4399 "thiserror 2.0.19",
4397 "time",4400 "time",
4398 "tokio",4401 "tokio",
4402 "tokio-util",
4399 "tower",4403 "tower",
4400 "tower-http",4404 "tower-http",
4401 "tracing",4405 "tracing",
Cargo.toml +6 −0
@@ -84,6 +84,8 @@ tokio = { version = "1", features = [
84 "net",84 "net",
85 "time",85 "time",
86 "fs",86 "fs",
87 "process",
88 "io-util",
87] }89] }
88tempfile = "3"90tempfile = "3"
89# Git object model (refs, trees, blobs, commits, diffs, signatures). Linked91# Git object model (refs, trees, blobs, commits, diffs, signatures). Linked
@@ -144,6 +146,10 @@ time = "0.3"
144# Markdown rendering (GFM) + HTML sanitization for READMEs and comments.146# Markdown rendering (GFM) + HTML sanitization for READMEs and comments.
145comrak = { version = "0.54", default-features = false }147comrak = { version = "0.54", default-features = false }
146ammonia = "4"148ammonia = "4"
149# Streaming the pack subprocess stdout into the HTTP response body; gunzip for
150# gzip-encoded upload-pack requests.
151tokio-util = { version = "0.7", features = ["io"] }
152flate2 = "1"
147tracing = "0.1"153tracing = "0.1"
148tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }154tracing-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
12use std::io::Read as _;
13use std::path::{Path, PathBuf};
14use std::process::ExitCode;
15
16use clap::Subcommand;
17
18use crate::context::{block_on, open_store};
19
20/// Actions under the hidden `fabrica hook` group.
21#[derive(Debug, Subcommand)]
22pub(crate) enum HookCommand {
23 /// Run after a push is accepted.
24 PostReceive,
25}
26
27/// Dispatch a hook subcommand. Always exits successfully.
28pub(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`.
41fn 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).
75fn 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;
13mod config_cmd;13mod config_cmd;
14mod context;14mod context;
15mod group_cmd;15mod group_cmd;
16mod hook_cmd;
16mod key_cmd;17mod key_cmd;
17mod prompt;18mod prompt;
18mod repo_cmd;19mod repo_cmd;
@@ -104,6 +105,13 @@ enum Command {
104 #[arg(long)]105 #[arg(long)]
105 quiet: bool,106 quiet: bool,
106 },107 },
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 },
107}115}
108116
109/// The parsed request to run `fabrica serve`, handed to the serve callback the117/// The parsed request to run `fabrica serve`, handed to the serve callback the
@@ -169,5 +177,6 @@ where
169 Command::Token { command } => token_cmd::run(command, cli.config.as_deref(), cli.json),177 Command::Token { command } => token_cmd::run(command, cli.config.as_deref(), cli.json),
170 Command::Migrate => admin_cmd::migrate(cli.config.as_deref()),178 Command::Migrate => admin_cmd::migrate(cli.config.as_deref()),
171 Command::Doctor { quiet } => admin_cmd::doctor(cli.config.as_deref(), *quiet),179 Command::Doctor { quiet } => admin_cmd::doctor(cli.config.as_deref(), *quiet),
180 Command::Hook { command } => Ok(hook_cmd::run(command)),
172 }181 }
173}182}
crates/git/Cargo.toml +3 −0
@@ -16,6 +16,9 @@ globset = { workspace = true }
16moka = { workspace = true }16moka = { workspace = true }
17ssh-key = { workspace = true }17ssh-key = { workspace = true }
18pgp = { workspace = true }18pgp = { workspace = true }
19# Async subprocess spawning for pack transport (clone/fetch/push). One code path
20# shared by the HTTP (web) and SSH transports.
21tokio = { workspace = true }
19thiserror = { workspace = true }22thiserror = { workspace = true }
20# Pin the libgit2 bindings to a build that requires libgit2 <= the version23# Pin the libgit2 bindings to a build that requires libgit2 <= the version
21# nixpkgs ships (1.9.4). git2 0.20's default libgit2-sys (0.18.7, bundling 1.9.6)24# 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 @@
28//! name→path authority; [`repo_path`] is the one place that mapping is computed.28//! name→path authority; [`repo_path`] is the one place that mapping is computed.
2929
30mod attributes;30mod attributes;
31pub mod pack;
31mod pubkey;32mod pubkey;
32mod repo;33mod repo;
33mod signature;34mod 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
14use std::path::{Path, PathBuf};
15use std::process::Stdio;
16
17use tokio::process::{Child, Command};
18
19/// The git service to run.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub 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
30impl 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)]
44pub 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.
72pub 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]
118pub fn pkt_line(payload: &str) -> String {
119 format!("{:04x}{payload}", payload.len() + 4)
120}
121
122/// The pkt-line flush packet.
123pub const FLUSH_PKT: &str = "0000";
124
125#[cfg(test)]
126mod 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 {
182 Ok(updated > 0)182 Ok(updated > 0)
183 }183 }
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
185 /// Delete a repository row (cascading to collaborators). The caller is205 /// Delete a repository row (cascading to collaborators). The caller is
186 /// responsible for the on-disk directory — moving it to trash, or purging it.206 /// responsible for the on-disk directory — moving it to trash, or purging it.
187 /// Returns `true` if a row was removed.207 /// Returns `true` if a row was removed.
crates/web/Cargo.toml +3 −0
@@ -26,6 +26,9 @@ rust-embed = { workspace = true }
26mime_guess = { workspace = true }26mime_guess = { workspace = true }
27comrak = { workspace = true }27comrak = { workspace = true }
28ammonia = { workspace = true }28ammonia = { workspace = true }
29tokio-util = { workspace = true }
30flate2 = { workspace = true }
31base64 = { workspace = true }
29time = { workspace = true }32time = { workspace = true }
30serde = { workspace = true }33serde = { workspace = true }
31serde_json = { workspace = true }34serde_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
13use axum::body::Body;
14use axum::extract::{Path, State};
15use axum::http::{HeaderMap, StatusCode, header};
16use axum::response::{IntoResponse, Response};
17use axum_extra::extract::cookie::CookieJar;
18use base64::Engine as _;
19use model::{Repo, User};
20use tokio::io::AsyncWriteExt as _;
21use tokio_util::io::ReaderStream;
22
23use 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]
29pub 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`.
35pub 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).
62pub 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.
87async 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.
127async 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.
190async 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.
244async 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)`.
271fn 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.
283fn 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.
296fn 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.
312fn 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).
322fn 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).
328fn 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.
336fn 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 @@
13mod assets;13mod assets;
14mod diff;14mod diff;
15mod error;15mod error;
16mod git_http;
16mod layout;17mod layout;
17mod markdown;18mod markdown;
18mod pages;19mod pages;
@@ -118,8 +119,13 @@ pub fn build_router(state: AppState) -> Router {
118 .route("/theme/{file}", get(serve_assets::theme_named))119 .route("/theme/{file}", get(serve_assets::theme_named))
119 .route("/assets/{*path}", get(serve_assets::assets))120 .route("/assets/{*path}", get(serve_assets::assets))
120 // Repository browsing. Variable-length paths split on `/-/` in the handler.121 // 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.
121 .route("/{owner}", get(repo::user_page))124 .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 )
123 .layer(129 .layer(
124 ServiceBuilder::new()130 ServiceBuilder::new()
125 .layer(SetRequestIdLayer::new(131 .layer(SetRequestIdLayer::new(
crates/web/src/repo.rs +22 −1
@@ -14,7 +14,7 @@
14use std::path::Path as FsPath;14use std::path::Path as FsPath;
1515
16use axum::extract::{Path, Query, State};16use axum::extract::{Path, Query, State};
17use axum::http::{Uri, header};17use axum::http::{HeaderMap, Uri, header};
18use axum::response::{Html, IntoResponse, Response};18use axum::response::{Html, IntoResponse, Response};
19use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};19use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
20use maud::{Markup, html};20use maud::{Markup, html};
@@ -43,6 +43,8 @@ pub struct DiffQuery {
43 from: Option<u32>,43 from: Option<u32>,
44 /// The 1-based last line of the context window.44 /// The 1-based last line of the context window.
45 to: Option<u32>,45 to: Option<u32>,
46 /// The smart-HTTP service, for `…​.git/info/refs?service=`.
47 service: Option<String>,
46}48}
4749
48/// The candidate README filenames, in lookup order.50/// The candidate README filenames, in lookup order.
@@ -173,10 +175,29 @@ pub async fn dispatch(
173 State(state): State<AppState>,175 State(state): State<AppState>,
174 MaybeUser(viewer): MaybeUser,176 MaybeUser(viewer): MaybeUser,
175 jar: CookieJar,177 jar: CookieJar,
178 headers: HeaderMap,
176 uri: Uri,179 uri: Uri,
177 Query(dq): Query<DiffQuery>,180 Query(dq): Query<DiffQuery>,
178 Path((owner_name, rest)): Path<(String, String)>,181 Path((owner_name, rest)): Path<(String, String)>,
179) -> AppResult<Response> {182) -> 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
180 if let Some((repo_path, view)) = rest.split_once("/-/") {201 if let Some((repo_path, view)) = rest.split_once("/-/") {
181 let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref())202 let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref())
182 .await?203 .await?
docs/decisions.md +26 −0
@@ -447,3 +447,29 @@ this app: pack-transport routes (§3.3) stream arbitrarily long clones and must
447never be cut off, so timeouts (if any) belong per-route on non-git paths, added in447never be cut off, so timeouts (if any) belong per-route on non-git paths, added in
448the polish phase. The theme picker is a real GET form (works without JS) that sets448the polish phase. The theme picker is a real GET form (works without JS) that sets
449a `fabrica_theme` cookie; `SIGHUP` re-scans `themes_dir` via an `RwLock<Assets>`.449a `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
455prompts for credentials), whereas an authenticated stranger gets **404** (no
456existence leak). This differs from the browse UI, which always 404s. The
457upload-pack **request** (client wants, small) is buffered — decompressing gzip when
458present — while the **response** pack streams straight from the child's stdout via
459`ReaderStream`, never buffered. Push over HTTP (`git-receive-pack`) returns a
460deliberate 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
466challenge, so a private clone is impossible without it — the spec mandates the 401.
467The authenticated-stranger 404 preserves the no-leak invariant for logged-in users.
468The 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
470constraint; the pack that must never be buffered is the response, which is
471streamed. HTTP auth is username+password (or session cookie); API-token Basic auth
472lands 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
474server on the child) or the default location; a local push with neither set no-ops
475cleanly, and it always exits 0 so a hook error never rejects an accepted push.