//! The "server under test" abstraction. //! //! The differential oracle talks to Git servers only over HTTP, through stock //! `git` clients. A server must be able to create an empty repository, say //! which URL to use for each branch, and list every ref it holds. //! [`StockServer`] is the reference implementation (stock //! `git http-backend`). [`ScopedServer`] is stock Git dressed in Aspen's URL //! layout, which proves the oracle copes with servers that hide branches. //! [`AspenServer`](crate::AspenServer) is a real Aspen (node L0 in //! `docs/PLAN.md`). use std::collections::BTreeMap; use std::fmt; use std::io; use std::path::{Path, PathBuf}; use tempfile::TempDir; use crate::git::{GitClient, init_bare}; use crate::http_backend::{GitHttpBackend, RefVisibility, SCOPE_SEGMENT}; /// Every ref of a repository: full ref name (`refs/heads/main`) to object ID. pub type RefMap = BTreeMap; /// A Git HTTP server the oracle can drive. /// /// Aspen does not advertise every branch on one URL: the repository URL /// shows only the trunk, and each branch is pushed and fetched through its /// own branch-scoped URL (spike S2). So the oracle never assumes that one URL /// shows everything. Every push or fetch of a branch goes through /// [`GitServer::branch_url`], and the server's full state is read through /// [`GitServer::list_refs`]. /// /// Implementations must be safe to call from several threads: the oracle /// creates repositories for many sequences, and drives the two servers it /// compares in parallel. pub trait GitServer: Send + Sync + fmt::Debug { /// A short label for reports, such as `git-http-backend` or `aspen`. fn name(&self) -> &str; /// Creates an empty repository called `name` (for example `seq-1.git`) /// that accepts anonymous pushes, and returns its URL. `name` is unique /// for the lifetime of the server. The trunk branch is `main`. /// /// The server must advertise the `push-options` capability on push: /// force-pushes carry `-o force` (Aspen's force intent, spike S2), and /// other servers ignore it. fn create_repo(&self, name: &str) -> io::Result; /// The URL through which clients push and fetch `refname` (a full name /// such as `refs/heads/topic-1`) of the repository at `repo_url`. It must /// advertise that ref, if it exists, with its current object ID, and /// accept pushes that create, update or delete it. A server that shows /// every ref everywhere returns `repo_url`. fn branch_url(&self, repo_url: &str, refname: &str) -> String; /// Every ref of the repository at `repo_url`, hidden or not, with its /// object ID. `HEAD` and peeled tags are not refs. This is the oracle's /// view of the server's true state, so it must read the server's /// committed ref state (for Aspen, its full ref listing) rather than any /// one client's view. fn list_refs(&self, repo_url: &str) -> io::Result; } /// Stock `git http-backend` serving bare repositories from a temporary /// directory. Stopped, and its repositories deleted, when dropped. #[derive(Debug)] pub struct StockServer { // Field order matters: stop the server before deleting its files. backend: GitHttpBackend, root: TempDir, name: String, } impl StockServer { /// Starts a server on an ephemeral port of `127.0.0.1`. pub fn start() -> io::Result { Self::start_named("git-http-backend") } /// Like [`StockServer::start`], with a custom label for reports. pub fn start_named(name: &str) -> io::Result { Self::start_with(name, RefVisibility::All) } fn start_with(name: &str, visibility: RefVisibility) -> io::Result { let root = tempfile::Builder::new().prefix("aspen-stock-").tempdir()?; let backend = GitHttpBackend::start_with(root.path(), visibility)?; Ok(Self { backend, root, name: name.to_owned(), }) } /// The directory holding the served bare repositories. pub fn root(&self) -> &Path { self.root.path() } /// The on-disk path of repository `name`. pub fn repo_path(&self, name: &str) -> PathBuf { self.root.path().join(name) } /// The server's base URL. pub fn base_url(&self) -> String { self.backend.base_url() } /// Creates repository `name` on disk and returns its URL. pub(crate) fn init(&self, name: &str) -> io::Result { let path = init_bare(self.root.path(), name)?; GitClient::new(self.root.path()) .run(&path, ["config", "receive.advertisePushOptions", "true"])?; Ok(format!("{}/{name}", self.backend.base_url())) } /// The on-disk path of the repository at `repo_url`. pub(crate) fn path_of(&self, repo_url: &str) -> io::Result { repo_url .strip_prefix(&self.backend.base_url()) .and_then(|rest| rest.strip_prefix('/')) .map(|name| self.repo_path(name)) .ok_or_else(|| io::Error::other(format!("{repo_url} is not served here"))) } } impl GitServer for StockServer { fn name(&self) -> &str { &self.name } fn create_repo(&self, name: &str) -> io::Result { self.init(name) } fn branch_url(&self, repo_url: &str, _refname: &str) -> String { repo_url.to_owned() } /// `git ls-remote --refs` of the repository URL, which shows every ref. fn list_refs(&self, repo_url: &str) -> io::Result { let out = GitClient::new(self.root.path()) .run(self.root.path(), ["ls-remote", "--refs", repo_url])?; parse_ref_lines(&out) } } /// Stock `git http-backend` with Aspen's ref visibility (spike S2): the /// repository URL advertises only `main`, and /// `/-/refs/heads/` advertises `main` plus that branch. Built /// with `transfer.hideRefs` (see [`RefVisibility::BranchScoped`]). /// /// It behaves like [`StockServer`] for any client that uses /// [`GitServer::branch_url`], so the oracle must report no divergence /// between the two. Its refs are listed from disk, standing in for the full /// ref listing Aspen exposes. #[derive(Debug)] pub struct ScopedServer { inner: StockServer, } impl ScopedServer { /// Starts a server on an ephemeral port of `127.0.0.1`. pub fn start() -> io::Result { Ok(Self { inner: StockServer::start_with( "branch-scoped", RefVisibility::BranchScoped { trunk: "main".to_owned(), }, )?, }) } } impl GitServer for ScopedServer { fn name(&self) -> &str { self.inner.name() } fn create_repo(&self, name: &str) -> io::Result { self.inner.init(name) } fn branch_url(&self, repo_url: &str, refname: &str) -> String { format!("{repo_url}{SCOPE_SEGMENT}{refname}") } fn list_refs(&self, repo_url: &str) -> io::Result { let path = self.inner.path_of(repo_url)?; let out = GitClient::new(self.inner.root()).run( &path, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/"], )?; parse_ref_lines(&out) } } /// Parses ` ` lines (tab or space separated). pub(crate) fn parse_ref_lines(out: &str) -> io::Result { out.lines() .filter(|l| !l.is_empty()) .map(|line| { line.split_once(['\t', ' ']) .map(|(oid, name)| (name.trim().to_owned(), oid.to_owned())) .ok_or_else(|| io::Error::other(format!("bad ref line {line:?}"))) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn ref_lines_parse() { let refs = parse_ref_lines("1111\trefs/heads/main\n2222 refs/heads/b\n").unwrap(); assert_eq!(refs["refs/heads/main"], "1111"); assert_eq!(refs["refs/heads/b"], "2222"); assert!(parse_ref_lines("garbage").is_err()); assert!(parse_ref_lines("").unwrap().is_empty()); } }