// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Pack transport: the single code path that spawns `git upload-pack` / //! `git receive-pack` / `git upload-archive`. //! //! libgit2 has no server side of the pack protocol, so clone/fetch/push are served //! by spawning the `git` binary (§3.1). Both the HTTP (web) and SSH transports call //! [`spawn`]. **Authorization happens before the spawn — the subprocess never makes //! an access decision** — and both directions stream; the pack is never buffered by //! the transport. The child is `kill_on_drop` so a dropped request reaps it. use std::path::{Path, PathBuf}; use std::process::Stdio; use tokio::process::{Child, Command}; /// The git service to run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Service { /// `git upload-pack` — serves clone/fetch (read). UploadPack, /// `git receive-pack` — serves push (write). ReceivePack, /// `git upload-archive` — serves `git archive --remote`. UploadArchive, } impl Service { /// The `git` subcommand name. fn subcommand(self) -> &'static str { match self { Self::UploadPack => "upload-pack", Self::ReceivePack => "receive-pack", Self::UploadArchive => "upload-archive", } } } /// Environment applied to every pack subprocess: isolation for git, plus the /// identity fields hooks consume. #[derive(Debug, Clone, Default)] pub struct PackEnv { /// `HOME` for the child (a scratch dir under `data_dir`), so git never reads a /// real user's global config. pub home: PathBuf, /// The client's `Git-Protocol` value, passed through as `GIT_PROTOCOL` when v2 /// was negotiated. pub protocol: Option, /// Config path for `fabrica hook post-receive` to load (`FABRICA_CONFIG`). pub config_path: Option, /// The authenticated user's id (`FABRICA_USER_ID`). pub user_id: Option, /// The authenticated user's name (`FABRICA_USER_NAME`). pub user_name: Option, /// The repository id (`FABRICA_REPO_ID`). pub repo_id: Option, /// The SSH key id used to authenticate (`FABRICA_KEY_ID`). pub key_id: Option, } /// Spawn a pack service against `repo_path` with piped stdio. /// /// `extra_args` are inserted before the repository path (e.g. `--stateless-rpc`, /// `--advertise-refs` for smart HTTP). The caller must have already authorized the /// request. Never interpolates into a shell — args are explicit. /// /// # Errors /// /// Returns the [`std::io::Error`] from spawning if the `git` binary cannot be run. pub fn spawn( binary: &str, service: Service, extra_args: &[&str], repo_path: &Path, env: &PackEnv, ) -> std::io::Result { let mut cmd = Command::new(binary); cmd.arg(service.subcommand()); cmd.args(extra_args); cmd.arg(repo_path); // Isolation (§3.3). cmd.env("GIT_DIR", repo_path); cmd.env("GIT_TERMINAL_PROMPT", "0"); cmd.env("GIT_CONFIG_NOSYSTEM", "1"); cmd.env("HOME", &env.home); // Do not leak the parent's git environment into the child. cmd.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES"); cmd.env_remove("GIT_OBJECT_DIRECTORY"); if let Some(protocol) = &env.protocol { cmd.env("GIT_PROTOCOL", protocol); } for (key, value) in [ ("FABRICA_CONFIG", &env.config_path), ("FABRICA_USER_ID", &env.user_id), ("FABRICA_USER_NAME", &env.user_name), ("FABRICA_REPO_ID", &env.repo_id), ("FABRICA_KEY_ID", &env.key_id), ] { if let Some(value) = value { cmd.env(key, value); } } cmd.stdin(Stdio::piped()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); cmd.kill_on_drop(true); cmd.spawn() } /// Spawn `git archive` for a source download, streaming the archive on stdout. /// /// `format` is a git archive format (`zip` or `tar.gz`); `prefix` is the top-level /// directory inside the archive (e.g. `repo-v1.0`); `rev` is the tag/branch/commit. /// /// # Errors /// /// Returns the [`std::io::Error`] from spawning if the `git` binary cannot be run. pub fn archive( binary: &str, repo_path: &Path, format: &str, rev: &str, prefix: &str, home: &Path, ) -> std::io::Result { let mut cmd = Command::new(binary); cmd.arg("archive"); cmd.arg(format!("--format={format}")); cmd.arg(format!("--prefix={prefix}/")); cmd.arg(rev); cmd.env("GIT_DIR", repo_path); cmd.env("GIT_TERMINAL_PROMPT", "0"); cmd.env("GIT_CONFIG_NOSYSTEM", "1"); cmd.env("HOME", home); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::null()); cmd.kill_on_drop(true); cmd.spawn() } /// Encode `payload` as a single git pkt-line (`{len:04x}{payload}`). Used to frame /// the smart-HTTP service advertisement. #[must_use] pub fn pkt_line(payload: &str) -> String { format!("{:04x}{payload}", payload.len() + 4) } /// The pkt-line flush packet. pub const FLUSH_PKT: &str = "0000"; #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; #[test] fn pkt_line_frames_length() { // "# service=git-upload-pack\n" is 26 bytes; framed length is 26 + 4 = 30. assert_eq!( pkt_line("# service=git-upload-pack\n"), "001e# service=git-upload-pack\n" ); assert_eq!(pkt_line("a"), "0005a"); } #[tokio::test] async fn spawn_upload_pack_advertises_refs_on_a_real_repo() { use std::path::Path; use crate::create_bare; // git must be present; skip cleanly otherwise (matches the test policy). if Command::new("git").arg("--version").output().await.is_err() { eprintln!("skipping: git not on PATH"); return; } let dir = tempfile::tempdir().expect("tempdir"); let id = "01hzxk9m2n8p7q6r5s4t3v2w1x"; create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).expect("create bare"); let repo_path = crate::repo_path(dir.path(), id).expect("repo path"); let env = PackEnv { home: dir.path().to_path_buf(), ..PackEnv::default() }; let child = spawn( "git", Service::UploadPack, &["--stateless-rpc", "--advertise-refs"], &repo_path, &env, ) .expect("spawn upload-pack"); let output = child.wait_with_output().await.expect("wait"); // A fresh repo advertises capabilities even with no refs; the output is // valid pkt-line data (or empty for an unborn HEAD), and the process // succeeds. assert!(output.status.success(), "upload-pack should exit cleanly"); } }