fabrica

hanna/fabrica

14678 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//! The git layer: object-model reads over `git2`, on-disk repository layout, and
6//! (in later phases) attribute resolution, signature verification, and
7//! pack-transport spawning.
8//!
9//! # Division of responsibility
10//!
11//! `git2` (libgit2) is the object-model library — refs, trees, blobs, commits,
12//! revwalks, diffs, signatures. It is **client-only for transport**: there is no
13//! server-side `upload-pack`/`receive-pack`, so clone/fetch/push are handled by
14//! spawning the `git` binary (a later phase). This crate owns the in-process
15//! reads and the bare-repository creation that both paths share.
16//!
17//! # Threading
18//!
19//! libgit2 calls are blocking and `git2::Repository` is not `Sync`. Callers on an
20//! async runtime **MUST** run these methods on `tokio::task::spawn_blocking` (or a
21//! bounded blocking pool); [`Repo`] is opened per operation, which is acceptable
22//! for the MVP. Nothing here spawns threads or touches the network.
23//!
24//! # Layout
25//!
26//! Repositories live at `{repo_dir}/{id[0..2]}/{id}.git` keyed by ULID, never by
27//! name, so renames and group moves are metadata-only. The database is the sole
28//! name→path authority; [`repo_path`] is the one place that mapping is computed.
29
30mod attributes;
31pub mod merge;
32pub mod mirror;
33pub mod pack;
34mod pubkey;
35mod repo;
36mod signature;
37mod types;
38
39use std::fs;
40use std::io;
41use std::os::unix::fs::PermissionsExt;
42use std::path::{Path, PathBuf};
43use std::process::Command;
44
45use git2::{Repository, RepositoryInitOptions};
46
47pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};
48pub use crate::pubkey::{ParsedKey, parse_public_key};
49pub use crate::repo::Repo;
50pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_challenge};
51pub use crate::types::{
52 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
53 FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
54 TreeAnnotations, TreeEntry,
55};
56
57/// An error from the git layer.
58#[derive(Debug, thiserror::Error)]
59pub enum GitError {
60 /// An error surfaced by libgit2.
61 #[error(transparent)]
62 Libgit2(#[from] git2::Error),
63
64 /// A filesystem operation failed, with the path that was being touched.
65 #[error("git io error at {path}: {source}")]
66 Io {
67 /// The path involved.
68 path: PathBuf,
69 /// The underlying I/O error.
70 source: io::Error,
71 },
72
73 /// A repository id was too short to shard (ids are 26-char ULIDs).
74 #[error("repository id {0:?} is too short")]
75 BadId(String),
76
77 /// A revision string did not resolve to any ref or object.
78 #[error("revision {0:?} not found")]
79 RevNotFound(String),
80
81 /// A path was not present in the given tree.
82 #[error("path {0:?} not found")]
83 PathNotFound(String),
84
85 /// A path resolved to something other than a file where a file was required.
86 #[error("path {0:?} is not a file")]
87 NotAFile(String),
88
89 /// A public key could not be parsed as the declared kind.
90 #[error("invalid {kind} public key: {reason}")]
91 BadPublicKey {
92 /// The declared key kind.
93 kind: model::KeyKind,
94 /// A human-readable parse failure reason.
95 reason: String,
96 },
97
98 /// A server-side merge failed for a reason other than a conflict (a
99 /// subprocess error, a lost compare-and-swap race, malformed output).
100 #[error("merge failed: {0}")]
101 Merge(String),
102
103 /// A server-side merge could not apply cleanly: the three-way merge or the
104 /// rebase replay hit conflicts. The string names the conflicting paths (or
105 /// the git output) when available.
106 #[error("merge has conflicts: {0}")]
107 MergeConflict(String),
108
109 /// `git init` (used for SHA-256 repositories) failed.
110 #[error("git init failed: {0}")]
111 Init(String),
112}
113
114/// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`.
115///
116/// # Errors
117///
118/// Returns [`GitError::BadId`] if `id` is shorter than the two-character shard
119/// prefix.
120pub fn repo_path(root: &Path, id: &str) -> Result<PathBuf, GitError> {
121 if id.len() < 2 {
122 return Err(GitError::BadId(id.to_string()));
123 }
124 Ok(root.join(&id[0..2]).join(format!("{id}.git")))
125}
126
127/// Create a bare repository for `id` under `root` and return an open handle.
128///
129/// Performs the filesystem half of repo creation (the caller inserts the database
130/// row): `init_bare` with `HEAD` pointing at `default_branch`; the config the
131/// server relies on (`core.logAllRefUpdates=true`, `receive.denyNonFastForwards=
132/// false`, `gc.auto=0` — maintenance is fabrica-driven, not push-driven); and the
133/// `post-receive`/`pre-receive` hooks that shell out to `hook_binary`.
134///
135/// On any failure after the directory is created, the partially built directory
136/// is removed so a retry starts clean.
137///
138/// # Errors
139///
140/// Returns [`GitError`] if the id is invalid, the directory already exists, or any
141/// libgit2/filesystem step fails.
142pub fn create_bare(
143 root: &Path,
144 id: &str,
145 default_branch: &str,
146 hook_binary: &Path,
147) -> Result<Repo, GitError> {
148 let path = repo_path(root, id)?;
149 if path.exists() {
150 return Err(GitError::Io {
151 path: path.clone(),
152 source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"),
153 });
154 }
155 if let Some(parent) = path.parent() {
156 fs::create_dir_all(parent).map_err(|source| GitError::Io {
157 path: parent.to_path_buf(),
158 source,
159 })?;
160 }
161
162 // Any error past this point should not leave a half-built repo behind.
163 match init_inner(&path, default_branch, hook_binary) {
164 Ok(()) => Repo::open_path(&path),
165 Err(err) => {
166 let _ = fs::remove_dir_all(&path);
167 Err(err)
168 }
169 }
170}
171
172/// The hash algorithm a repository's objects are named with.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
174pub enum ObjectFormat {
175 /// SHA-1 (the historical default).
176 #[default]
177 Sha1,
178 /// SHA-256 (git's newer, collision-resistant format).
179 Sha256,
180}
181
182impl ObjectFormat {
183 /// The stable token stored on the repo row and passed to `git init`.
184 #[must_use]
185 pub fn as_str(self) -> &'static str {
186 match self {
187 Self::Sha1 => "sha1",
188 Self::Sha256 => "sha256",
189 }
190 }
191
192 /// Parse a stored/submitted token, defaulting to SHA-1.
193 #[must_use]
194 pub fn from_token(token: &str) -> Self {
195 if token.eq_ignore_ascii_case("sha256") {
196 Self::Sha256
197 } else {
198 Self::Sha1
199 }
200 }
201}
202
203/// Create a bare repository with a chosen object format.
204///
205/// SHA-1 uses the in-process [`create_bare`]. SHA-256 is created by the `git`
206/// binary (`git init --object-format=sha256`), because libgit2's SHA-256 support
207/// is experimental and often unavailable — the resulting repository is still a
208/// fully valid git repo served over the pack transport, though in-process
209/// browsing depends on the local libgit2 build.
210///
211/// # Errors
212///
213/// Returns [`GitError`] if the id is invalid, the directory already exists, or a
214/// git/filesystem step fails.
215pub fn create_bare_with_format(
216 root: &Path,
217 id: &str,
218 default_branch: &str,
219 hook_binary: &Path,
220 format: ObjectFormat,
221) -> Result<(), GitError> {
222 match format {
223 ObjectFormat::Sha1 => create_bare(root, id, default_branch, hook_binary).map(|_| ()),
224 ObjectFormat::Sha256 => create_bare_sha256(root, id, default_branch, hook_binary),
225 }
226}
227
228/// Create a SHA-256 bare repository via the `git` binary, then apply the same
229/// server config and hooks [`create_bare`] installs.
230fn create_bare_sha256(
231 root: &Path,
232 id: &str,
233 default_branch: &str,
234 hook_binary: &Path,
235) -> Result<(), GitError> {
236 let path = repo_path(root, id)?;
237 if path.exists() {
238 return Err(GitError::Io {
239 path: path.clone(),
240 source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"),
241 });
242 }
243 if let Some(parent) = path.parent() {
244 fs::create_dir_all(parent).map_err(|source| GitError::Io {
245 path: parent.to_path_buf(),
246 source,
247 })?;
248 }
249 match init_sha256_inner(&path, default_branch, hook_binary) {
250 Ok(()) => Ok(()),
251 Err(err) => {
252 let _ = fs::remove_dir_all(&path);
253 Err(err)
254 }
255 }
256}
257
258/// The fallible body of [`create_bare_sha256`].
259fn init_sha256_inner(
260 path: &Path,
261 default_branch: &str,
262 hook_binary: &Path,
263) -> Result<(), GitError> {
264 let run = |args: &[&str]| -> Result<(), GitError> {
265 let out = Command::new("git")
266 .args(args)
267 .output()
268 .map_err(|source| GitError::Io {
269 path: path.to_path_buf(),
270 source,
271 })?;
272 if out.status.success() {
273 Ok(())
274 } else {
275 Err(GitError::Init(
276 String::from_utf8_lossy(&out.stderr).trim().to_string(),
277 ))
278 }
279 };
280 let path_str = path.to_string_lossy();
281 run(&[
282 "init",
283 "--bare",
284 "--object-format=sha256",
285 "--initial-branch",
286 default_branch,
287 &path_str,
288 ])?;
289 // Mirror the server config `create_bare` sets (§3.2).
290 for (key, value) in [
291 ("core.logallrefupdates", "true"),
292 ("receive.denyNonFastForwards", "false"),
293 ("gc.auto", "0"),
294 ] {
295 run(&["--git-dir", &path_str, "config", key, value])?;
296 }
297 install_hooks(path, hook_binary)?;
298 Ok(())
299}
300
301/// The fallible body of [`create_bare`], separated so the caller can clean up on
302/// any error.
303fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> {
304 let mut opts = RepositoryInitOptions::new();
305 opts.bare(true).initial_head(default_branch).mkpath(true);
306 let repo = Repository::init_opts(path, &opts)?;
307
308 let mut config = repo.config()?;
309 config.set_bool("core.logallrefupdates", true)?;
310 config.set_bool("receive.denyNonFastForwards", false)?;
311 config.set_i32("gc.auto", 0)?;
312
313 install_hooks(path, hook_binary)?;
314 Ok(())
315}
316
317/// Write the `post-receive` and `pre-receive` hooks. `post-receive` dispatches to
318/// `fabrica hook post-receive`; `pre-receive` is a reserved no-op. Both are made
319/// executable.
320fn install_hooks(repo_path: &Path, hook_binary: &Path) -> Result<(), GitError> {
321 let hooks_dir = repo_path.join("hooks");
322 fs::create_dir_all(&hooks_dir).map_err(|source| GitError::Io {
323 path: hooks_dir.clone(),
324 source,
325 })?;
326
327 let binary = hook_binary.display();
328 write_hook(
329 &hooks_dir.join("post-receive"),
330 &format!("#!/bin/sh\nexec \"{binary}\" hook post-receive\n"),
331 )?;
332 // Reserved for future branch protection; accepts every push for now.
333 write_hook(&hooks_dir.join("pre-receive"), "#!/bin/sh\nexit 0\n")?;
334 Ok(())
335}
336
337/// Write a single hook file and mark it executable (`0o755`).
338fn write_hook(path: &Path, contents: &str) -> Result<(), GitError> {
339 fs::write(path, contents).map_err(|source| GitError::Io {
340 path: path.to_path_buf(),
341 source,
342 })?;
343 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).map_err(|source| {
344 GitError::Io {
345 path: path.to_path_buf(),
346 source,
347 }
348 })?;
349 Ok(())
350}
351
352#[cfg(test)]
353mod tests {
354 #![allow(clippy::unwrap_used)]
355
356 use super::*;
357
358 #[test]
359 fn repo_path_shards_by_id_prefix() {
360 let root = Path::new("/srv/repos");
361 let path = repo_path(root, "01hzxk9m2n8p7q6r5s4t3v2w1x").unwrap();
362 assert_eq!(
363 path,
364 Path::new("/srv/repos/01/01hzxk9m2n8p7q6r5s4t3v2w1x.git")
365 );
366 }
367
368 #[test]
369 fn repo_path_rejects_short_ids() {
370 assert!(matches!(
371 repo_path(Path::new("/srv"), "a"),
372 Err(GitError::BadId(_))
373 ));
374 }
375
376 #[test]
377 fn create_bare_sets_head_config_and_hooks() {
378 let dir = tempfile::tempdir().unwrap();
379 let repo = create_bare(
380 dir.path(),
381 "01hzxk9m2n8p7q6r5s4t3v2w1x",
382 "trunk",
383 Path::new("/usr/bin/fabrica"),
384 )
385 .unwrap();
386
387 // HEAD points at the requested initial branch.
388 assert_eq!(repo.head_branch().unwrap().as_deref(), Some("trunk"));
389
390 let path = repo_path(dir.path(), "01hzxk9m2n8p7q6r5s4t3v2w1x").unwrap();
391 // The hook exists, is executable, and dispatches to `fabrica hook`.
392 let hook = path.join("hooks").join("post-receive");
393 let body = std::fs::read_to_string(&hook).unwrap();
394 assert!(body.contains("hook post-receive"), "body: {body}");
395 let mode = std::fs::metadata(&hook).unwrap().permissions().mode();
396 assert_eq!(mode & 0o111, 0o111, "hook must be executable");
397 }
398
399 #[test]
400 fn create_bare_is_not_clobbering() {
401 let dir = tempfile::tempdir().unwrap();
402 let id = "01hzxk9m2n8p7q6r5s4t3v2w1x";
403 create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap();
404 // A second create at the same id fails (the directory already exists).
405 assert!(create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).is_err());
406 }
407
408 #[test]
409 fn create_bare_sha256_uses_the_requested_format() {
410 // SHA-256 creation shells out to `git`; skip cleanly where it is absent.
411 if Command::new("git").arg("--version").output().is_err() {
412 return;
413 }
414 let dir = tempfile::tempdir().unwrap();
415 let id = "01hzxk9m2n8p7q6r5s4t3v2w1y";
416 create_bare_with_format(
417 dir.path(),
418 id,
419 "main",
420 Path::new("/usr/bin/fabrica"),
421 ObjectFormat::Sha256,
422 )
423 .unwrap();
424
425 let path = repo_path(dir.path(), id).unwrap();
426 // git records the object format under extensions.objectformat.
427 let out = Command::new("git")
428 .args(["--git-dir"])
429 .arg(&path)
430 .args(["rev-parse", "--show-object-format"])
431 .output()
432 .unwrap();
433 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256");
434 // The server hooks are installed as for a SHA-1 repo.
435 assert!(path.join("hooks").join("post-receive").exists());
436 }
437}