Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +4 −0
| @@ -1541,6 +1541,7 @@ dependencies = [ | |||
| 1541 | 1541 | "ssh-key", | |
| 1542 | 1542 | "tempfile", | |
| 1543 | 1543 | "thiserror 2.0.19", | |
| 1544 | + | "tokio", | |
| 1544 | 1545 | ] | |
| 1545 | 1546 | ||
| 1546 | 1547 | [[package]] | |
| @@ -4381,8 +4382,10 @@ dependencies = [ | |||
| 4381 | 4382 | "auth", | |
| 4382 | 4383 | "axum", | |
| 4383 | 4384 | "axum-extra", | |
| 4385 | + | "base64", | |
| 4384 | 4386 | "comrak", | |
| 4385 | 4387 | "config", | |
| 4388 | + | "flate2", | |
| 4386 | 4389 | "git", | |
| 4387 | 4390 | "highlight", | |
| 4388 | 4391 | "mail", | |
| @@ -4396,6 +4399,7 @@ dependencies = [ | |||
| 4396 | 4399 | "thiserror 2.0.19", | |
| 4397 | 4400 | "time", | |
| 4398 | 4401 | "tokio", | |
| 4402 | + | "tokio-util", | |
| 4399 | 4403 | "tower", | |
| 4400 | 4404 | "tower-http", | |
| 4401 | 4405 | "tracing", | |
Cargo.toml +6 −0
| @@ -84,6 +84,8 @@ tokio = { version = "1", features = [ | |||
| 84 | 84 | "net", | |
| 85 | 85 | "time", | |
| 86 | 86 | "fs", | |
| 87 | + | "process", | |
| 88 | + | "io-util", | |
| 87 | 89 | ] } | |
| 88 | 90 | tempfile = "3" | |
| 89 | 91 | # Git object model (refs, trees, blobs, commits, diffs, signatures). Linked | |
| @@ -144,6 +146,10 @@ time = "0.3" | |||
| 144 | 146 | # Markdown rendering (GFM) + HTML sanitization for READMEs and comments. | |
| 145 | 147 | comrak = { version = "0.54", default-features = false } | |
| 146 | 148 | 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" | |
| 147 | 153 | tracing = "0.1" | |
| 148 | 154 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } | |
| 149 | 155 | ||
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; | |||
| 13 | 13 | mod config_cmd; | |
| 14 | 14 | mod context; | |
| 15 | 15 | mod group_cmd; | |
| 16 | + | mod hook_cmd; | |
| 16 | 17 | mod key_cmd; | |
| 17 | 18 | mod prompt; | |
| 18 | 19 | mod repo_cmd; | |
| @@ -104,6 +105,13 @@ enum Command { | |||
| 104 | 105 | #[arg(long)] | |
| 105 | 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 | } | |
| 108 | 116 | ||
| 109 | 117 | /// The parsed request to run `fabrica serve`, handed to the serve callback the | |
| @@ -169,5 +177,6 @@ where | |||
| 169 | 177 | Command::Token { command } => token_cmd::run(command, cli.config.as_deref(), cli.json), | |
| 170 | 178 | Command::Migrate => admin_cmd::migrate(cli.config.as_deref()), | |
| 171 | 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 } | |||
| 16 | 16 | moka = { workspace = true } | |
| 17 | 17 | ssh-key = { workspace = true } | |
| 18 | 18 | 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 } | |
| 19 | 22 | thiserror = { workspace = true } | |
| 20 | 23 | # Pin the libgit2 bindings to a build that requires libgit2 <= the version | |
| 21 | 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 | 28 | //! name→path authority; [`repo_path`] is the one place that mapping is computed. | |
| 29 | 29 | ||
| 30 | 30 | mod attributes; | |
| 31 | + | pub mod pack; | |
| 31 | 32 | mod pubkey; | |
| 32 | 33 | mod repo; | |
| 33 | 34 | 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 { | |||
| 182 | 182 | Ok(updated > 0) | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 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 | 205 | /// Delete a repository row (cascading to collaborators). The caller is | |
| 186 | 206 | /// responsible for the on-disk directory — moving it to trash, or purging it. | |
| 187 | 207 | /// Returns `true` if a row was removed. | |
crates/web/Cargo.toml +3 −0
| @@ -26,6 +26,9 @@ rust-embed = { workspace = true } | |||
| 26 | 26 | mime_guess = { workspace = true } | |
| 27 | 27 | comrak = { workspace = true } | |
| 28 | 28 | ammonia = { workspace = true } | |
| 29 | + | tokio-util = { workspace = true } | |
| 30 | + | flate2 = { workspace = true } | |
| 31 | + | base64 = { workspace = true } | |
| 29 | 32 | time = { workspace = true } | |
| 30 | 33 | serde = { workspace = true } | |
| 31 | 34 | 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 @@ | |||
| 13 | 13 | mod assets; | |
| 14 | 14 | mod diff; | |
| 15 | 15 | mod error; | |
| 16 | + | mod git_http; | |
| 16 | 17 | mod layout; | |
| 17 | 18 | mod markdown; | |
| 18 | 19 | mod pages; | |
| @@ -118,8 +119,13 @@ pub fn build_router(state: AppState) -> Router { | |||
| 118 | 119 | .route("/theme/{file}", get(serve_assets::theme_named)) | |
| 119 | 120 | .route("/assets/{*path}", get(serve_assets::assets)) | |
| 120 | 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 | 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 | 129 | .layer( | |
| 124 | 130 | ServiceBuilder::new() | |
| 125 | 131 | .layer(SetRequestIdLayer::new( | |
crates/web/src/repo.rs +22 −1
| @@ -14,7 +14,7 @@ | |||
| 14 | 14 | use std::path::Path as FsPath; | |
| 15 | 15 | ||
| 16 | 16 | use axum::extract::{Path, Query, State}; | |
| 17 | - | use axum::http::{Uri, header}; | |
| 17 | + | use axum::http::{HeaderMap, Uri, header}; | |
| 18 | 18 | use axum::response::{Html, IntoResponse, Response}; | |
| 19 | 19 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; | |
| 20 | 20 | use maud::{Markup, html}; | |
| @@ -43,6 +43,8 @@ pub struct DiffQuery { | |||
| 43 | 43 | from: Option<u32>, | |
| 44 | 44 | /// The 1-based last line of the context window. | |
| 45 | 45 | to: Option<u32>, | |
| 46 | + | /// The smart-HTTP service, for `….git/info/refs?service=`. | |
| 47 | + | service: Option<String>, | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | 50 | /// The candidate README filenames, in lookup order. | |
| @@ -173,10 +175,29 @@ pub async fn dispatch( | |||
| 173 | 175 | State(state): State<AppState>, | |
| 174 | 176 | MaybeUser(viewer): MaybeUser, | |
| 175 | 177 | jar: CookieJar, | |
| 178 | + | headers: HeaderMap, | |
| 176 | 179 | uri: Uri, | |
| 177 | 180 | Query(dq): Query<DiffQuery>, | |
| 178 | 181 | Path((owner_name, rest)): Path<(String, String)>, | |
| 179 | 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 | 201 | if let Some((repo_path, view)) = rest.split_once("/-/") { | |
| 181 | 202 | let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref()) | |
| 182 | 203 | .await? | |
docs/decisions.md +26 −0
| @@ -447,3 +447,29 @@ this app: pack-transport routes (§3.3) stream arbitrarily long clones and must | |||
| 447 | 447 | never be cut off, so timeouts (if any) belong per-route on non-git paths, added in | |
| 448 | 448 | the polish phase. The theme picker is a real GET form (works without JS) that sets | |
| 449 | 449 | 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. | |