fabrica

hanna/fabrica

6969 bytes
Raw
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/// Spawn `git archive` for a source download, streaming the archive on stdout.
116///
117/// `format` is a git archive format (`zip` or `tar.gz`); `prefix` is the top-level
118/// directory inside the archive (e.g. `repo-v1.0`); `rev` is the tag/branch/commit.
119///
120/// # Errors
121///
122/// Returns the [`std::io::Error`] from spawning if the `git` binary cannot be run.
123pub fn archive(
124 binary: &str,
125 repo_path: &Path,
126 format: &str,
127 rev: &str,
128 prefix: &str,
129 home: &Path,
130) -> std::io::Result<Child> {
131 let mut cmd = Command::new(binary);
132 cmd.arg("archive");
133 cmd.arg(format!("--format={format}"));
134 cmd.arg(format!("--prefix={prefix}/"));
135 cmd.arg(rev);
136 cmd.env("GIT_DIR", repo_path);
137 cmd.env("GIT_TERMINAL_PROMPT", "0");
138 cmd.env("GIT_CONFIG_NOSYSTEM", "1");
139 cmd.env("HOME", home);
140 cmd.stdout(Stdio::piped());
141 cmd.stderr(Stdio::null());
142 cmd.kill_on_drop(true);
143 cmd.spawn()
144}
145
146/// Encode `payload` as a single git pkt-line (`{len:04x}{payload}`). Used to frame
147/// the smart-HTTP service advertisement.
148#[must_use]
149pub fn pkt_line(payload: &str) -> String {
150 format!("{:04x}{payload}", payload.len() + 4)
151}
152
153/// The pkt-line flush packet.
154pub const FLUSH_PKT: &str = "0000";
155
156#[cfg(test)]
157mod tests {
158 #![allow(clippy::unwrap_used, clippy::expect_used)]
159
160 use super::*;
161
162 #[test]
163 fn pkt_line_frames_length() {
164 // "# service=git-upload-pack\n" is 26 bytes; framed length is 26 + 4 = 30.
165 assert_eq!(
166 pkt_line("# service=git-upload-pack\n"),
167 "001e# service=git-upload-pack\n"
168 );
169 assert_eq!(pkt_line("a"), "0005a");
170 }
171
172 #[tokio::test]
173 async fn spawn_upload_pack_advertises_refs_on_a_real_repo() {
174 use std::path::Path;
175
176 use crate::create_bare;
177
178 // git must be present; skip cleanly otherwise (matches the test policy).
179 if Command::new("git").arg("--version").output().await.is_err() {
180 eprintln!("skipping: git not on PATH");
181 return;
182 }
183
184 let dir = tempfile::tempdir().expect("tempdir");
185 let id = "01hzxk9m2n8p7q6r5s4t3v2w1x";
186 create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).expect("create bare");
187 let repo_path = crate::repo_path(dir.path(), id).expect("repo path");
188
189 let env = PackEnv {
190 home: dir.path().to_path_buf(),
191 ..PackEnv::default()
192 };
193 let child = spawn(
194 "git",
195 Service::UploadPack,
196 &["--stateless-rpc", "--advertise-refs"],
197 &repo_path,
198 &env,
199 )
200 .expect("spawn upload-pack");
201 let output = child.wait_with_output().await.expect("wait");
202 // A fresh repo advertises capabilities even with no refs; the output is
203 // valid pkt-line data (or empty for an unborn HEAD), and the process
204 // succeeds.
205 assert!(output.status.success(), "upload-pack should exit cleanly");
206 }
207}