// 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/. //! The git layer: object-model reads over `git2`, on-disk repository layout, and //! (in later phases) attribute resolution, signature verification, and //! pack-transport spawning. //! //! # Division of responsibility //! //! `git2` (libgit2) is the object-model library — refs, trees, blobs, commits, //! revwalks, diffs, signatures. It is **client-only for transport**: there is no //! server-side `upload-pack`/`receive-pack`, so clone/fetch/push are handled by //! spawning the `git` binary (a later phase). This crate owns the in-process //! reads and the bare-repository creation that both paths share. //! //! # Threading //! //! libgit2 calls are blocking and `git2::Repository` is not `Sync`. Callers on an //! async runtime **MUST** run these methods on `tokio::task::spawn_blocking` (or a //! bounded blocking pool); [`Repo`] is opened per operation, which is acceptable //! for the MVP. Nothing here spawns threads or touches the network. //! //! # Layout //! //! Repositories live at `{repo_dir}/{id[0..2]}/{id}.git` keyed by ULID, never by //! name, so renames and group moves are metadata-only. The database is the sole //! name→path authority; [`repo_path`] is the one place that mapping is computed. mod attributes; pub mod merge; pub mod mirror; pub mod pack; mod pubkey; mod repo; mod signature; mod types; use std::fs; use std::io; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use git2::{Repository, RepositoryInitOptions}; pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet}; pub use crate::pubkey::{ParsedKey, parse_public_key}; pub use crate::repo::Repo; pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_challenge}; pub use crate::types::{ Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, TreeAnnotations, TreeEntry, }; /// An error from the git layer. #[derive(Debug, thiserror::Error)] pub enum GitError { /// An error surfaced by libgit2. #[error(transparent)] Libgit2(#[from] git2::Error), /// A filesystem operation failed, with the path that was being touched. #[error("git io error at {path}: {source}")] Io { /// The path involved. path: PathBuf, /// The underlying I/O error. source: io::Error, }, /// A repository id was too short to shard (ids are 26-char ULIDs). #[error("repository id {0:?} is too short")] BadId(String), /// A revision string did not resolve to any ref or object. #[error("revision {0:?} not found")] RevNotFound(String), /// A path was not present in the given tree. #[error("path {0:?} not found")] PathNotFound(String), /// A path resolved to something other than a file where a file was required. #[error("path {0:?} is not a file")] NotAFile(String), /// A public key could not be parsed as the declared kind. #[error("invalid {kind} public key: {reason}")] BadPublicKey { /// The declared key kind. kind: model::KeyKind, /// A human-readable parse failure reason. reason: String, }, /// A server-side merge failed for a reason other than a conflict (a /// subprocess error, a lost compare-and-swap race, malformed output). #[error("merge failed: {0}")] Merge(String), /// A server-side merge could not apply cleanly: the three-way merge or the /// rebase replay hit conflicts. The string names the conflicting paths (or /// the git output) when available. #[error("merge has conflicts: {0}")] MergeConflict(String), /// `git init` (used for SHA-256 repositories) failed. #[error("git init failed: {0}")] Init(String), } /// Compute the on-disk path of a repository from its id: `{root}/{id[0..2]}/{id}.git`. /// /// # Errors /// /// Returns [`GitError::BadId`] if `id` is shorter than the two-character shard /// prefix. pub fn repo_path(root: &Path, id: &str) -> Result { if id.len() < 2 { return Err(GitError::BadId(id.to_string())); } Ok(root.join(&id[0..2]).join(format!("{id}.git"))) } /// Create a bare repository for `id` under `root` and return an open handle. /// /// Performs the filesystem half of repo creation (the caller inserts the database /// row): `init_bare` with `HEAD` pointing at `default_branch`; the config the /// server relies on (`core.logAllRefUpdates=true`, `receive.denyNonFastForwards= /// false`, `gc.auto=0` — maintenance is fabrica-driven, not push-driven); and the /// `post-receive`/`pre-receive` hooks that shell out to `hook_binary`. /// /// On any failure after the directory is created, the partially built directory /// is removed so a retry starts clean. /// /// # Errors /// /// Returns [`GitError`] if the id is invalid, the directory already exists, or any /// libgit2/filesystem step fails. pub fn create_bare( root: &Path, id: &str, default_branch: &str, hook_binary: &Path, ) -> Result { let path = repo_path(root, id)?; if path.exists() { return Err(GitError::Io { path: path.clone(), source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"), }); } if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|source| GitError::Io { path: parent.to_path_buf(), source, })?; } // Any error past this point should not leave a half-built repo behind. match init_inner(&path, default_branch, hook_binary) { Ok(()) => Repo::open_path(&path), Err(err) => { let _ = fs::remove_dir_all(&path); Err(err) } } } /// The hash algorithm a repository's objects are named with. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ObjectFormat { /// SHA-1 (the historical default). #[default] Sha1, /// SHA-256 (git's newer, collision-resistant format). Sha256, } impl ObjectFormat { /// The stable token stored on the repo row and passed to `git init`. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::Sha1 => "sha1", Self::Sha256 => "sha256", } } /// Parse a stored/submitted token, defaulting to SHA-1. #[must_use] pub fn from_token(token: &str) -> Self { if token.eq_ignore_ascii_case("sha256") { Self::Sha256 } else { Self::Sha1 } } } /// Create a bare repository with a chosen object format. /// /// SHA-1 uses the in-process [`create_bare`]. SHA-256 is created by the `git` /// binary (`git init --object-format=sha256`), because libgit2's SHA-256 support /// is experimental and often unavailable — the resulting repository is still a /// fully valid git repo served over the pack transport, though in-process /// browsing depends on the local libgit2 build. /// /// # Errors /// /// Returns [`GitError`] if the id is invalid, the directory already exists, or a /// git/filesystem step fails. pub fn create_bare_with_format( root: &Path, id: &str, default_branch: &str, hook_binary: &Path, format: ObjectFormat, ) -> Result<(), GitError> { match format { ObjectFormat::Sha1 => create_bare(root, id, default_branch, hook_binary).map(|_| ()), ObjectFormat::Sha256 => create_bare_sha256(root, id, default_branch, hook_binary), } } /// Create a SHA-256 bare repository via the `git` binary, then apply the same /// server config and hooks [`create_bare`] installs. fn create_bare_sha256( root: &Path, id: &str, default_branch: &str, hook_binary: &Path, ) -> Result<(), GitError> { let path = repo_path(root, id)?; if path.exists() { return Err(GitError::Io { path: path.clone(), source: io::Error::new(io::ErrorKind::AlreadyExists, "repository directory exists"), }); } if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|source| GitError::Io { path: parent.to_path_buf(), source, })?; } match init_sha256_inner(&path, default_branch, hook_binary) { Ok(()) => Ok(()), Err(err) => { let _ = fs::remove_dir_all(&path); Err(err) } } } /// The fallible body of [`create_bare_sha256`]. fn init_sha256_inner( path: &Path, default_branch: &str, hook_binary: &Path, ) -> Result<(), GitError> { let run = |args: &[&str]| -> Result<(), GitError> { let out = Command::new("git") .args(args) .output() .map_err(|source| GitError::Io { path: path.to_path_buf(), source, })?; if out.status.success() { Ok(()) } else { Err(GitError::Init( String::from_utf8_lossy(&out.stderr).trim().to_string(), )) } }; let path_str = path.to_string_lossy(); run(&[ "init", "--bare", "--object-format=sha256", "--initial-branch", default_branch, &path_str, ])?; // Mirror the server config `create_bare` sets (§3.2). for (key, value) in [ ("core.logallrefupdates", "true"), ("receive.denyNonFastForwards", "false"), ("gc.auto", "0"), ] { run(&["--git-dir", &path_str, "config", key, value])?; } install_hooks(path, hook_binary)?; Ok(()) } /// The fallible body of [`create_bare`], separated so the caller can clean up on /// any error. fn init_inner(path: &Path, default_branch: &str, hook_binary: &Path) -> Result<(), GitError> { let mut opts = RepositoryInitOptions::new(); opts.bare(true).initial_head(default_branch).mkpath(true); let repo = Repository::init_opts(path, &opts)?; let mut config = repo.config()?; config.set_bool("core.logallrefupdates", true)?; config.set_bool("receive.denyNonFastForwards", false)?; config.set_i32("gc.auto", 0)?; install_hooks(path, hook_binary)?; Ok(()) } /// Write the `post-receive` and `pre-receive` hooks. `post-receive` dispatches to /// `fabrica hook post-receive`; `pre-receive` is a reserved no-op. Both are made /// executable. fn install_hooks(repo_path: &Path, hook_binary: &Path) -> Result<(), GitError> { let hooks_dir = repo_path.join("hooks"); fs::create_dir_all(&hooks_dir).map_err(|source| GitError::Io { path: hooks_dir.clone(), source, })?; let binary = hook_binary.display(); write_hook( &hooks_dir.join("post-receive"), &format!("#!/bin/sh\nexec \"{binary}\" hook post-receive\n"), )?; // Reserved for future branch protection; accepts every push for now. write_hook(&hooks_dir.join("pre-receive"), "#!/bin/sh\nexit 0\n")?; Ok(()) } /// Write a single hook file and mark it executable (`0o755`). fn write_hook(path: &Path, contents: &str) -> Result<(), GitError> { fs::write(path, contents).map_err(|source| GitError::Io { path: path.to_path_buf(), source, })?; fs::set_permissions(path, fs::Permissions::from_mode(0o755)).map_err(|source| { GitError::Io { path: path.to_path_buf(), source, } })?; Ok(()) } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; #[test] fn repo_path_shards_by_id_prefix() { let root = Path::new("/srv/repos"); let path = repo_path(root, "01hzxk9m2n8p7q6r5s4t3v2w1x").unwrap(); assert_eq!( path, Path::new("/srv/repos/01/01hzxk9m2n8p7q6r5s4t3v2w1x.git") ); } #[test] fn repo_path_rejects_short_ids() { assert!(matches!( repo_path(Path::new("/srv"), "a"), Err(GitError::BadId(_)) )); } #[test] fn create_bare_sets_head_config_and_hooks() { let dir = tempfile::tempdir().unwrap(); let repo = create_bare( dir.path(), "01hzxk9m2n8p7q6r5s4t3v2w1x", "trunk", Path::new("/usr/bin/fabrica"), ) .unwrap(); // HEAD points at the requested initial branch. assert_eq!(repo.head_branch().unwrap().as_deref(), Some("trunk")); let path = repo_path(dir.path(), "01hzxk9m2n8p7q6r5s4t3v2w1x").unwrap(); // The hook exists, is executable, and dispatches to `fabrica hook`. let hook = path.join("hooks").join("post-receive"); let body = std::fs::read_to_string(&hook).unwrap(); assert!(body.contains("hook post-receive"), "body: {body}"); let mode = std::fs::metadata(&hook).unwrap().permissions().mode(); assert_eq!(mode & 0o111, 0o111, "hook must be executable"); } #[test] fn create_bare_is_not_clobbering() { let dir = tempfile::tempdir().unwrap(); let id = "01hzxk9m2n8p7q6r5s4t3v2w1x"; create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap(); // A second create at the same id fails (the directory already exists). assert!(create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).is_err()); } #[test] fn create_bare_sha256_uses_the_requested_format() { // SHA-256 creation shells out to `git`; skip cleanly where it is absent. if Command::new("git").arg("--version").output().is_err() { return; } let dir = tempfile::tempdir().unwrap(); let id = "01hzxk9m2n8p7q6r5s4t3v2w1y"; create_bare_with_format( dir.path(), id, "main", Path::new("/usr/bin/fabrica"), ObjectFormat::Sha256, ) .unwrap(); let path = repo_path(dir.path(), id).unwrap(); // git records the object format under extensions.objectformat. let out = Command::new("git") .args(["--git-dir"]) .arg(&path) .args(["rev-parse", "--show-object-format"]) .output() .unwrap(); assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "sha256"); // The server hooks are installed as for a SHA-1 repo. assert!(path.join("hooks").join("post-receive").exists()); } }