Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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 | ] |
| 1545 | 1546 | ||
| 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 | ] } |
| 88 | tempfile = "3" | 90 | tempfile = "3" |
| 89 | # Git object model (refs, trees, blobs, commits, diffs, signatures). Linked | 91 | # 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. |
| 145 | comrak = { version = "0.54", default-features = false } | 147 | comrak = { version = "0.54", default-features = false } |
| 146 | ammonia = "4" | 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 | tracing = "0.1" | 153 | tracing = "0.1" |
| 148 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } | 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 | mod config_cmd; | 13 | mod config_cmd; |
| 14 | mod context; | 14 | mod context; |
| 15 | mod group_cmd; | 15 | mod group_cmd; |
| 16 | mod hook_cmd; | ||
| 16 | mod key_cmd; | 17 | mod key_cmd; |
| 17 | mod prompt; | 18 | mod prompt; |
| 18 | mod repo_cmd; | 19 | mod 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 | } |
| 108 | 116 | ||
| 109 | /// The parsed request to run `fabrica serve`, handed to the serve callback the | 117 | /// 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 } | |||
| 16 | moka = { workspace = true } | 16 | moka = { workspace = true } |
| 17 | ssh-key = { workspace = true } | 17 | ssh-key = { workspace = true } |
| 18 | pgp = { workspace = true } | 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 | thiserror = { workspace = true } | 22 | thiserror = { workspace = true } |
| 20 | # Pin the libgit2 bindings to a build that requires libgit2 <= the version | 23 | # 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. |
| 29 | 29 | ||
| 30 | mod attributes; | 30 | mod attributes; |
| 31 | pub mod pack; | ||
| 31 | mod pubkey; | 32 | mod pubkey; |
| 32 | mod repo; | 33 | mod repo; |
| 33 | mod signature; | 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 | Ok(updated > 0) | 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 | /// Delete a repository row (cascading to collaborators). The caller is | 205 | /// 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 } | |||
| 26 | mime_guess = { workspace = true } | 26 | mime_guess = { workspace = true } |
| 27 | comrak = { workspace = true } | 27 | comrak = { workspace = true } |
| 28 | ammonia = { workspace = true } | 28 | ammonia = { workspace = true } |
| 29 | tokio-util = { workspace = true } | ||
| 30 | flate2 = { workspace = true } | ||
| 31 | base64 = { workspace = true } | ||
| 29 | time = { workspace = true } | 32 | time = { workspace = true } |
| 30 | serde = { workspace = true } | 33 | serde = { workspace = true } |
| 31 | serde_json = { workspace = true } | 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 | mod assets; | 13 | mod assets; |
| 14 | mod diff; | 14 | mod diff; |
| 15 | mod error; | 15 | mod error; |
| 16 | mod git_http; | ||
| 16 | mod layout; | 17 | mod layout; |
| 17 | mod markdown; | 18 | mod markdown; |
| 18 | mod pages; | 19 | mod 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 @@ | |||
| 14 | use std::path::Path as FsPath; | 14 | use std::path::Path as FsPath; |
| 15 | 15 | ||
| 16 | use axum::extract::{Path, Query, State}; | 16 | use axum::extract::{Path, Query, State}; |
| 17 | use axum::http::{Uri, header}; | 17 | use axum::http::{HeaderMap, Uri, header}; |
| 18 | use axum::response::{Html, IntoResponse, Response}; | 18 | use axum::response::{Html, IntoResponse, Response}; |
| 19 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; | 19 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; |
| 20 | use maud::{Markup, html}; | 20 | use 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 | } |
| 47 | 49 | ||
| 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 | |||
| 447 | never be cut off, so timeouts (if any) belong per-route on non-git paths, added in | 447 | never be cut off, so timeouts (if any) belong per-route on non-git paths, added in |
| 448 | the polish phase. The theme picker is a real GET form (works without JS) that sets | 448 | the polish phase. The theme picker is a real GET form (works without JS) that sets |
| 449 | a `fabrica_theme` cookie; `SIGHUP` re-scans `themes_dir` via an `RwLock<Assets>`. | 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. | ||