git-ents.gitmain
⌘K
foforge
commit cafc1d4
refactor: split git-ents into a core crate so the server can be a subcommand

git-ents-server already depended on git-ents for the shared domain types (account/checks/members/etc), so embedding git-ents-server CLI type into git-ents own Top enum would have made git-ents depend back on git-ents-server, a manifest-level cycle. Pulling the domain modules out into their own git-ents-core crate breaks it: both the CLI and the server now depend on git-ents-core instead of on each other.

refactor: extract account/checks/component/config/issues/members/revocations into a new git-ents-core crate refactor: convert git-ents-server CLI from clap to figue and expose Args/Command as pub feat: add git ents server, embedding git-ents-server::Args as a subcommand chore: drop --generate-man/clap_mangen and the CI man-generation job Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -116,56 +116,6 @@ "vte", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.103" @@ -718,56 +668,6 @@ "windows-link", ] -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "clap_mangen" -version = "0.2.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" -dependencies = [ - "clap", - "roff", -] - [[package]] name = "clru" version = "0.6.3" @@ -777,12 +677,6 @@ "hashbrown 0.16.1", ] -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "comfy-table" version = "7.2.2" @@ -1551,18 +1445,28 @@ "futures-util", "git-anchor", "git-comment", + "git-ents-core", + "git-ents-server", "git-store", - "gix", - "iddqd", "inquire", "signal-hook 0.3.18", "tempfile", - "thiserror 2.0.18", "tokio", "tokio-tungstenite", "ureq", ] +[[package]] +name = "git-ents-core" +version = "0.0.0" +dependencies = [ + "facet", + "git-store", + "gix", + "iddqd", + "thiserror 2.0.18", +] + [[package]] name = "git-ents-server" version = "0.0.0" @@ -1573,14 +1477,13 @@ "arborium", "askama", "axum", - "clap", - "clap_mangen", "facet", + "figue", "form_urlencoded", "getrandom 0.4.3", "git-anchor", "git-comment", - "git-ents", + "git-ents-core", "git-store", "gix-actor", "gix-date", @@ -2866,12 +2769,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itoa" version = "1.0.18" @@ -3165,12 +3062,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "onig" version = "6.5.3" @@ -3548,12 +3439,6 @@ "windows-sys 0.52.0", ] -[[package]] -name = "roff" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" - [[package]] name = "rstest" version = "0.26.1" @@ -4415,12 +4300,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" version = "1.23.4"
Cargo.toml @@ -4,6 +4,7 @@ "crates/git-anchor", "crates/git-comment", "crates/git-ents", + "crates/git-ents-core", "crates/git-ents-server", "crates/git-store", ] @@ -42,8 +43,6 @@ ] } askama = "0.16" axum = "0.8" -clap = { version = "4.5.60", features = ["derive"] } -clap_mangen = "0.2.31" figue = "5.0.0-rc.5" facet = { version = "0.50.0-rc.0", features = ["reflect"] } facet-pretty = "0.50.0-rc.5" @@ -52,6 +51,8 @@ form_urlencoded = "1" git-anchor = { path = "crates/git-anchor" } git-comment = { path = "crates/git-comment" } +git-ents-core = { path = "crates/git-ents-core" } +git-ents-server = { path = "crates/git-ents-server" } git-store = { path = "crates/git-store" } gix = "0.84" gix-hash = { version = "0.25", features = ["sha1"] }
.github/workflows/CI.yml @@ -125,44 +125,10 @@ - name: Run doctests run: cargo test --doc --workspace --all-features - man: - name: man-generation - runs-on: ubuntu-24.04 - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) }} - steps: - - uses: actions/checkout@v6 - - uses: mlugg/setup-zig@v2 - with: - version: 0.15.2 - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - cache: true - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-registry- - - name: Cache Cargo build - uses: actions/cache@v4 - with: - path: target - key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-build- - - name: Generate man pages - run: | - mkdir -p target/debug/man - cargo run --package git-ents-server -- --generate-man target/debug/man - ci: name: CI if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false)) }} - needs: [linux, musllinux, windows, macos, man] + needs: [linux, musllinux, windows, macos] runs-on: ubuntu-latest steps: - run: |
crates/git-ents-server/Cargo.toml @@ -6,16 +6,15 @@ license.workspace = true [dependencies] -git-ents = { path = "../git-ents" } +git-ents-core = { workspace = true } acdc-parser = { workspace = true } askama = { workspace = true } acdc-converters-core = { workspace = true } acdc-converters-html = { workspace = true } arborium = { workspace = true } axum = { workspace = true, features = ["ws"] } -clap = { workspace = true } -clap_mangen = { workspace = true } facet = { workspace = true } +figue = { workspace = true } form_urlencoded = { workspace = true } getrandom = { workspace = true } git-anchor = { workspace = true }
crates/git-ents/Cargo.toml @@ -14,19 +14,15 @@ futures-util = { version = "0.3.32", default-features = false, features = ["sink", "std"] } git-anchor = { workspace = true } git-comment = { workspace = true } +git-ents-core = { workspace = true } +git-ents-server = { workspace = true } git-store = { workspace = true } -gix = { workspace = true } -iddqd = { workspace = true } inquire = { version = "0.9.4", default-features = false, features = ["crossterm"] } signal-hook = { version = "0.3", features = ["iterator"] } tempfile = { workspace = true } -thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] } ureq = "3.3.0" -[dev-dependencies] -git-store = { workspace = true, features = ["test-support"] } - [lints] workspace = true
crates/git-ents-server/src/checks.rs @@ -30,7 +30,7 @@ use std::sync::{Arc, Mutex as StdMutex, PoisonError}; use std::time::{Duration, Instant}; -use git_ents::checks::{self, Check, RunOutcome, Status}; +use git_ents_core::checks::{self, Check, RunOutcome, Status}; use gix_hash::ObjectId; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use tokio::sync::Mutex; @@ -739,7 +739,7 @@ {new} {zero} refs/heads/old\n\ {new} {new} refs/meta/checks\n\ {new} {new} refs/heads/feature\n", - zero = git_ents::ZERO_OID, + zero = git_ents_core::ZERO_OID, ); let updates = parse_updates(&input); let refs: Vec<&str> = updates.iter().map(|u| u.ref_name).collect();
crates/git-ents-server/src/main.rs @@ -1,192 +1,46 @@ -//! Git Ents server — helpful guardians of your git trees. +//! `git-ents-server` — the standalone binary; see [`git_ents_server`] for the +//! `pub` `Args`/`Command` also embedded as `git ents server`. -mod asciidoc; -mod checks; -mod http; -mod markdown; -mod verify; -mod web; - -use std::net::SocketAddr; -use std::path::PathBuf; use std::process::ExitCode; -use std::sync::Arc; -use axum::Router; -use axum::extract::DefaultBodyLimit; -use axum::routing::get; -use clap::{CommandFactory, Parser, Subcommand}; -use tokio::sync::Mutex; +use facet::Facet; +use figue::FigueBuiltins; -#[derive(Parser)] -#[command( - name = "git-ents-server", - about = "Helpful guardians of your git trees." -)] -struct Args { - #[command(subcommand)] - command: Option<Command>, - - /// Generate man pages into the given directory. - #[arg(long, value_name = "DIR")] - generate_man: Option<PathBuf>, - - /// Port to listen on. - #[arg(long, env = "PORT", default_value = "8080")] - port: u16, - - /// Directory holding the bare repositories served over HTTP. - #[arg(long, env = "GIT_PROJECT_ROOT", default_value = "/data/repos")] - data_dir: PathBuf, - - /// Secret seed for signed-push nonces. Setting it requires pushes to carry - /// a signed-push certificate, enabling authentication against the signers. - #[arg(long, env = "CERT_NONCE_SEED")] - cert_nonce_seed: Option<String>, - - /// Directory of git hooks (a `pre-receive`) applied to every served repo. - #[arg(long, env = "GIT_ENTS_HOOKS_DIR")] - hooks_dir: Option<PathBuf>, - - /// The server's own SSH private key, used to sign browser-made edits. Its - /// public half must be a member of any repo edited through the web. Editing - /// is disabled unless this is set. - #[arg(long, env = "GIT_ENTS_WEB_SIGNING_KEY")] - web_signing_key: Option<PathBuf>, - - /// Directory where the `post-receive` hook queues pushes for the check - /// worker to run asynchronously. - #[arg( - long, - env = "GIT_ENTS_CHECKS_QUEUE", - default_value = "/data/checks-queue" - )] - checks_queue: PathBuf, -} - -/// Subcommands that run instead of serving HTTP. -#[derive(Subcommand)] -enum Command { - /// Verify a signed push against the authorized signers (a git `pre-receive` - /// hook). - PreReceive, - /// Run the configured checks against a push in a Sprite (a git - /// `post-receive` hook). - PostReceive, -} - -/// Shared handler state: where the bare repositories live, plus a lock that -/// serializes repository creation so concurrent first pushes cannot race. -#[derive(Clone)] -pub(crate) struct AppState { - pub(crate) data_dir: PathBuf, - pub(crate) init_lock: Arc<Mutex<()>>, - /// When set, injected as `receive.certNonceSeed` so the backend demands a - /// signed-push certificate the `pre-receive` hook can verify. - pub(crate) cert_nonce_seed: Option<String>, - /// When set, injected as `core.hooksPath` so every served repo runs the - /// bundled `pre-receive` verifier. - pub(crate) hooks_dir: Option<PathBuf>, - /// Directory the `post-receive` hook queues pushes into and the check - /// worker drains; passed down to the hook via [`checks::QUEUE_ENV`]. - pub(crate) checks_queue: PathBuf, - /// In-memory web sessions: a browser's signed-in public key, held for the - /// life of the process and never persisted. - pub(crate) sessions: web::Sessions, - /// Outstanding one-time sign-in challenges awaiting a signature. - pub(crate) challenges: web::Challenges, - /// The server's own signing key for browser-made edits; `None` disables - /// editing. - pub(crate) web_signing_key: Option<PathBuf>, - /// Live output for checks the worker currently has running, polled by the - /// Checks tab's live view. - pub(crate) live_runs: checks::LiveRegistry, +#[derive(Facet)] +struct Cli { + #[facet(flatten)] + args: git_ents_server::Args, + #[facet(flatten)] + builtins: FigueBuiltins, } fn main() -> ExitCode { - let args = Args::parse(); - - if let Some(Command::PreReceive) = args.command { - return match verify::pre_receive() { - Ok(()) => ExitCode::SUCCESS, - Err(reason) => { - eprintln!("error: {reason}"); - ExitCode::FAILURE + let config = match figue::builder::<Cli>() { + Ok(builder) => builder, + Err(error) => { + eprintln!("{error}"); + return ExitCode::FAILURE; + } + } + .cli(|cli| cli.args(std::env::args().skip(1))) + .help(|help| { + help.program_name("git-ents-server") + .version(env!("CARGO_PKG_VERSION")) + }) + .build(); + let cli: Cli = match figue::Driver::new(config).run().into_result() { + Ok(output) => output.get(), + Err(figue::DriverError::Help { + text, + suggestion: suggestion @ Some(_), + }) => { + println!("{text}"); + if let Some(s) = suggestion { + println!("{}", s.render_pretty()); } - }; - } - - if let Some(Command::PostReceive) = args.command { - // A post-receive failure cannot undo the push; report and exit clean so - // a runner hiccup never looks like a rejected push. - if let Err(reason) = checks::post_receive() { - eprintln!("checks: {reason}"); - } - return ExitCode::SUCCESS; - } - - if let Some(dir) = args.generate_man { - let cmd = Args::command(); - if let Err(e) = clap_mangen::generate_to(cmd, dir) { - eprintln!("error: {e}"); - return ExitCode::FAILURE; - } - return ExitCode::SUCCESS; - } - - let runtime = match tokio::runtime::Runtime::new() { - Ok(runtime) => runtime, - Err(e) => { - eprintln!("error: failed to start runtime: {e}"); return ExitCode::FAILURE; } + Err(error) => figue::DriverOutcome::<Cli>::err(error).unwrap(), }; - runtime.block_on(serve(args)) -} - -/// Bind the listener and serve until shutdown. -async fn serve(args: Args) -> ExitCode { - let state = AppState { - data_dir: args.data_dir, - init_lock: Arc::new(Mutex::new(())), - cert_nonce_seed: args.cert_nonce_seed, - hooks_dir: args.hooks_dir, - checks_queue: args.checks_queue, - sessions: web::new_sessions(), - challenges: web::new_challenges(), - web_signing_key: args.web_signing_key, - live_runs: checks::new_live_registry(), - }; - - // Drain queued pushes and run their checks for the life of the server. - tokio::spawn(checks::worker( - state.checks_queue.clone(), - state.live_runs.clone(), - )); - - // The git smart-HTTP protocol streams whole packfiles through the request - // body, so the default 2 MiB cap would reject any non-trivial push. - let app = Router::new() - .route("/healthz", get(http::health)) - .route("/", get(http::get_request)) - .route("/_debug/{*path}", get(web::handshake)) - .route("/{*path}", get(http::get_request).post(http::post_request)) - .layer(DefaultBodyLimit::disable()) - .with_state(state); - - let addr = SocketAddr::from(([0, 0, 0, 0], args.port)); - let listener = match tokio::net::TcpListener::bind(addr).await { - Ok(listener) => listener, - Err(e) => { - eprintln!("error: failed to bind to port {}: {e}", args.port); - return ExitCode::FAILURE; - } - }; - - if let Err(e) = axum::serve(listener, app).await { - eprintln!("error: {e}"); - return ExitCode::FAILURE; - } - - ExitCode::SUCCESS + git_ents_server::run(cli.args) }
crates/git-ents-server/src/verify.rs @@ -14,9 +14,9 @@ use std::path::Path; use std::process::{Command, Stdio}; -use git_ents::config; -use git_ents::members::{self, Member}; -use git_ents::revocations; +use git_ents_core::config; +use git_ents_core::members::{self, Member}; +use git_ents_core::revocations; /// Verify the push git is about to apply, returning `Ok(())` to accept it or /// `Err(reason)` to reject it. The push certificate is read from the
crates/git-ents/src/main.rs @@ -7,7 +7,7 @@ //! and the client setup that produces the signed pushes the server //! requires. The member commands read and write a remote's set by fetching the //! `refs/meta/member/*` refs into the local repository, editing them through -//! [`git_ents::members`], and pushing them back. +//! [`git_ents_core::members`], and pushing them back. mod debug_session; mod interactive; @@ -21,11 +21,11 @@ use figue::{self as args, FigueBuiltins}; use git_anchor::{LineRange, Projection}; use git_comment::{COMMENTS_NS, Comment}; -use git_ents::account::{self, Account}; -use git_ents::checks::{self, CHECKS_REF, Check}; -use git_ents::component::{self, Component, MapDocument}; -use git_ents::members::{self, MEMBER_NS, Member, Trust, member_ref}; -use git_ents::revocations::{self, REVOKED_REF, Revocation}; +use git_ents_core::account::{self, Account}; +use git_ents_core::checks::{self, CHECKS_REF, Check}; +use git_ents_core::component::{self, Component, MapDocument}; +use git_ents_core::members::{self, MEMBER_NS, Member, Trust, member_ref}; +use git_ents_core::revocations::{self, REVOKED_REF, Revocation}; /// Helpful guardians of your git trees. #[derive(Facet)] @@ -71,6 +71,10 @@ #[facet(args::named)] key: Option<PathBuf>, }, + /// Run the bundled server: serve HTTP, or (via its own subcommand) run + /// the `pre-receive`/`post-receive` hooks. Also shipped as the standalone + /// `git-ents-server` binary. + Server(git_ents_server::Args), } #[derive(Facet)] @@ -284,13 +288,19 @@ Err(error) => figue::DriverOutcome::<Cli>::err(error).unwrap(), }; let remote = cli.remote; - let result = match cli.command { - Top::Members { action } => run_members(action, &remote), - Top::Account { action } => run_account(action, &remote), - Top::Checks { action } => run_checks(action, &remote), - Top::Comment { action } => run_comment(action, &remote), - Top::Login { key } => login(&remote, key.as_deref()), - }; + match cli.command { + Top::Members { action } => exit_code(run_members(action, &remote)), + Top::Account { action } => exit_code(run_account(action, &remote)), + Top::Checks { action } => exit_code(run_checks(action, &remote)), + Top::Comment { action } => exit_code(run_comment(action, &remote)), + Top::Login { key } => exit_code(login(&remote, key.as_deref())), + Top::Server(args) => git_ents_server::run(args), + } +} + +/// Translate a porcelain command's result into a process exit code, printing +/// an error to stderr on failure. +fn exit_code(result: Result<(), String>) -> ExitCode { match result { Ok(()) => ExitCode::SUCCESS, Err(message) => { @@ -1459,7 +1469,7 @@ fn push_signed(remote: &str, refname: &str, expected: Option<&str>) -> Result<(), String> { let lease = format!( "--force-with-lease={refname}:{}", - expected.unwrap_or(git_ents::ZERO_OID) + expected.unwrap_or(git_ents_core::ZERO_OID) ); git_run(&["push", "--force-if-includes", &lease, remote, refname]) }
crates/git-ents-server/src/web/component.rs @@ -15,13 +15,13 @@ use std::path::Path; -use git_ents::component::Component; +use git_ents_core::component::Component; use maud::{Markup, html}; use super::render::Render; /// A meta-ref component whose items load off the async runtime. Sync — -/// `git_ents::*::load`/`list` shell out to git and read the object database +/// `git_ents_core::*::load`/`list` shell out to git and read the object database /// synchronously — so callers wrap it in exactly one [`load`]. pub(super) trait Loadable: Send + Sized + 'static { /// The component's items. @@ -38,7 +38,7 @@ } /// A [`Loadable`] component whose items also render as a generic [`card`]: -/// identity metadata and a [`Render`] impl (both from `git_ents::component`), +/// identity metadata and a [`Render`] impl (both from `git_ents_core::component`), /// plus a title and what the card shows when there are no items yet. pub(super) trait WebComponent: Loadable + Component + Render { /// The card title.
crates/git-ents-server/src/web/mod.rs @@ -521,9 +521,9 @@ } /// Load the repository's `refs/meta/config` document off the async runtime. -async fn load_config(repo: &Path) -> Option<git_ents::config::Config> { +async fn load_config(repo: &Path) -> Option<git_ents_core::config::Config> { let repo = repo.to_owned(); - tokio::task::spawn_blocking(move || git_ents::config::load(&repo)) + tokio::task::spawn_blocking(move || git_ents_core::config::load(&repo)) .await .ok()? .ok() @@ -532,7 +532,7 @@ /// Count the repository's open issues off the async runtime. async fn open_issue_count(repo: &Path) -> usize { let repo = repo.to_owned(); - tokio::task::spawn_blocking(move || git_ents::issues::open_count(&repo)) + tokio::task::spawn_blocking(move || git_ents_core::issues::open_count(&repo)) .await .ok() .and_then(Result::ok)
crates/git-ents-server/src/web/pages.rs @@ -854,7 +854,7 @@ /// the full history and the raw set, as before. pub(super) async fn checks_page(repo: &Path, meta: &RepoMeta) -> Markup { let rel = &meta.rel; - let checks = component::load::<git_ents::checks::Check>(repo).await; + let checks = component::load::<git_ents_core::checks::Check>(repo).await; let runs = load_runs(repo).await; let head = git_output(repo, &["rev-parse", "HEAD"]) .await @@ -932,8 +932,8 @@ fn head_check_row( rel: &str, head: &str, - check: &git_ents::checks::Check, - head_run: Option<&git_ents::checks::Run>, + check: &git_ents_core::checks::Check, + head_run: Option<&git_ents_core::checks::Run>, ) -> Markup { let outcome = head_run.and_then(|run| run.results.iter().find(|result| result.name == check.name)); @@ -952,7 +952,7 @@ repo: &Path, commit_oid: ObjectId, name: &str, -) -> Option<git_ents::checks::RunOutcome> { +) -> Option<git_ents_core::checks::RunOutcome> { load_runs(repo) .await .ok()? @@ -1088,9 +1088,9 @@ } /// Load the recorded runs off the async runtime, like [`component::load`]. -async fn load_runs(repo: &Path) -> Result<Vec<git_ents::checks::CommitRuns>, String> { +async fn load_runs(repo: &Path) -> Result<Vec<git_ents_core::checks::CommitRuns>, String> { let repo = repo.to_owned(); - tokio::task::spawn_blocking(move || git_ents::checks::runs(&repo)) + tokio::task::spawn_blocking(move || git_ents_core::checks::runs(&repo)) .await .map_err(|err| err.to_string())? .map_err(|err| err.to_string()) @@ -1101,7 +1101,7 @@ /// derived from the labels that exist. Issue creation is a write path that does /// not exist yet, so the "New issue" button stays disabled. pub(super) async fn issues_page(repo: &Path, meta: &RepoMeta) -> Markup { - let tpl = match component::load::<git_ents::issues::Issue>(repo).await { + let tpl = match component::load::<git_ents_core::issues::Issue>(repo).await { Err(err) => IssuesTemplate { icons: Icons, error: Some(err), @@ -1111,7 +1111,7 @@ closed_count: 0, }, Ok(issues) => { - let open: Vec<&git_ents::issues::Issue> = + let open: Vec<&git_ents_core::issues::Issue> = issues.iter().filter(|issue| issue.is_open()).collect(); let closed = issues.len().saturating_sub(open.len()); let mut labels: Vec<String> = issues @@ -1159,8 +1159,8 @@ auth: Option<&super::Auth>, editing: bool, ) -> Markup { - let members = component::load::<git_ents::members::Member>(repo).await; - let checks = component::load::<git_ents::checks::Check>(repo).await; + let members = component::load::<git_ents_core::members::Member>(repo).await; + let checks = component::load::<git_ents_core::checks::Check>(repo).await; let config = load_repo_config(repo).await; repo_shell( meta, @@ -1223,9 +1223,9 @@ } /// Load `refs/meta/config` off the async runtime, like [`component::load`]. -async fn load_repo_config(repo: &Path) -> Result<git_ents::config::Config, String> { +async fn load_repo_config(repo: &Path) -> Result<git_ents_core::config::Config, String> { let repo = repo.to_owned(); - tokio::task::spawn_blocking(move || git_ents::config::load(&repo)) + tokio::task::spawn_blocking(move || git_ents_core::config::load(&repo)) .await .map_err(|err| err.to_string())? .map_err(|err| err.to_string())
crates/git-ents-server/src/web/render.rs @@ -13,10 +13,10 @@ use facet::{Def, Facet, Peek, Type, UserType}; use maud::{Markup, PreEscaped, html}; -use git_ents::checks::{Check, Run, RunOutcome, Status}; -use git_ents::config::{Config, RoleRules}; -use git_ents::issues::Issue; -use git_ents::members::Member; +use git_ents_core::checks::{Check, Run, RunOutcome, Status}; +use git_ents_core::config::{Config, RoleRules}; +use git_ents_core::issues::Issue; +use git_ents_core::members::Member; use super::component::{Loadable, WebComponent}; use crate::asciidoc; @@ -53,7 +53,7 @@ impl Loadable for Check { fn load(repo: &Path) -> Result<Vec<Self>, String> { - git_ents::checks::load(repo).map_err(|err| err.to_string()) + git_ents_core::checks::load(repo).map_err(|err| err.to_string()) } } @@ -113,7 +113,7 @@ impl Loadable for Issue { fn load(repo: &Path) -> Result<Vec<Self>, String> { - Ok(git_ents::issues::list(repo) + Ok(git_ents_core::issues::list(repo) .map_err(|err| err.to_string())? .into_iter() .map(|(_id, issue)| issue) @@ -142,7 +142,7 @@ impl Loadable for Member { fn load(repo: &Path) -> Result<Vec<Self>, String> { - git_ents::members::load_all(repo).map_err(|err| err.to_string()) + git_ents_core::members::load_all(repo).map_err(|err| err.to_string()) } }
crates/git-ents-server/src/web/write.rs @@ -185,8 +185,8 @@ /// self-attested member typically has no push key to gate — so the web write /// path is the enforcement point. fn require_admin_registered(store: &git_store::Store, username: &str) -> Result<(), String> { - use git_ents::members::Provenance; - let member = git_ents::members::load_with(store, username) + use git_ents_core::members::Provenance; + let member = git_ents_core::members::load_with(store, username) .map_err(|e| format!("could not read member: {e}"))? .ok_or_else(|| "your web key is not a member of this repository".to_owned())?; match member.provenance { @@ -223,15 +223,15 @@ .ok_or_else(|| "your web key is not a member of this repository".to_owned())?; require_admin_registered(&store, &username)?; - let mut config = - git_ents::config::load_with(&store).map_err(|e| format!("could not read config: {e}"))?; + let mut config = git_ents_core::config::load_with(&store) + .map_err(|e| format!("could not read config: {e}"))?; config.description = edit.description.clone(); config.homepage = edit.homepage.clone(); config.topics = edit.topics.clone(); signed_edit( repo, - git_ents::config::CONFIG_REF, + git_ents_core::config::CONFIG_REF, &config, "Update configuration", &username, @@ -467,7 +467,7 @@ public_key: &str, ) -> Option<String> { let wanted = normalize_key(public_key); - let members = git_ents::members::load_all_with(store).ok()?; + let members = git_ents_core::members::load_all_with(store).ok()?; members.into_iter().find_map(|member| { member .keys()
crates/git-ents/src/checks.rs → crates/git-ents-core/src/checks.rs
crates/git-ents/src/issues.rs → crates/git-ents-core/src/issues.rs
crates/git-ents/src/revocations.rs → crates/git-ents-core/src/revocations.rs
crates/git-ents/src/testutil.rs → crates/git-ents-core/src/testutil.rs
crates/git-ents/src/component.rs → crates/git-ents-core/src/component.rs
crates/git-ents/src/account.rs → crates/git-ents-core/src/account.rs
crates/git-ents/src/config.rs → crates/git-ents-core/src/config.rs
crates/git-ents/src/members.rs → crates/git-ents-core/src/members.rs
crates/git-ents/src/lib.rs → crates/git-ents-core/src/lib.rs @@ -1,4 +1,5 @@ -//! Git Ents — helpful guardians of your git trees. +//! Git Ents core — the shared domain types read and written through +//! `git_store`, common to the CLI porcelain and the server. pub mod account; pub mod checks;
crates/git-ents/tests/cert_authority.rs → crates/git-ents-core/tests/cert_authority.rs @@ -7,7 +7,7 @@ )] //! The Phase 3 CA-pin gate: a certificate the pinned CA issued verifies against -//! the `allowed_signers` file `git_ents::members` renders, and a certificate +//! the `allowed_signers` file `git_ents_core::members` renders, and a certificate //! from an unpinned CA does not. The cert-embedded signature is produced the way //! a real client would — through an `ssh-agent` holding the key and its //! certificate — since `ssh-keygen -Y sign` only embeds a certificate when the @@ -17,7 +17,7 @@ use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; -use git_ents::members::{Member, allowed_signers}; +use git_ents_core::members::{Member, allowed_signers}; /// The principal the CA certifies and the verifier checks — the pusher identity. const PRINCIPAL: &str = "tester@example.com"; @@ -60,7 +60,7 @@ std::fs::remove_dir_all(&dir).ok(); } -/// Write the `allowed_signers` file `git_ents::members` renders for a member +/// Write the `allowed_signers` file `git_ents_core::members` renders for a member /// whose trust is the CA `ca`, and return its path. fn render_ca_allowed_signers(dir: &Path, name: &str, ca: &Key) -> PathBuf { let ca_pubkey = std::fs::read_to_string(&ca.public)
crates/git-ents-core/Cargo.toml @@ -1,0 +1,19 @@ +[package] +name = "git-ents-core" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +facet = { workspace = true } +git-store = { workspace = true } +gix = { workspace = true } +iddqd = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +git-store = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true
crates/git-ents-server/src/lib.rs @@ -1,0 +1,210 @@ +//! Git Ents server — helpful guardians of your git trees. +//! +//! [`Args`] is `pub` so `git ents` can embed this server as its own `server` +//! subcommand, alongside the standalone `git-ents-server` binary. + +mod asciidoc; +mod checks; +mod http; +mod markdown; +mod verify; +mod web; + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::Arc; + +use axum::Router; +use axum::extract::DefaultBodyLimit; +use axum::routing::get; +use facet::Facet; +use figue::{self as args}; +use tokio::sync::Mutex; + +/// Command-line arguments, layered over the matching environment variables +/// (the flag always wins) since that is how this server is configured on +/// Fly.io. +#[derive(Facet, Debug)] +pub struct Args { + /// Subcommand that runs instead of serving HTTP. + #[facet(args::subcommand)] + pub command: Option<Command>, + + /// Port to listen on ($PORT, default 8080). + #[facet(args::named)] + pub port: Option<u16>, + + /// Directory holding the bare repositories served over HTTP + /// ($GIT_PROJECT_ROOT, default `/data/repos`). + #[facet(args::named)] + pub data_dir: Option<PathBuf>, + + /// Secret seed for signed-push nonces ($CERT_NONCE_SEED). Setting it + /// requires pushes to carry a signed-push certificate, enabling + /// authentication against the signers. + #[facet(args::named)] + pub cert_nonce_seed: Option<String>, + + /// Directory of git hooks (a `pre-receive`) applied to every served repo + /// ($GIT_ENTS_HOOKS_DIR). + #[facet(args::named)] + pub hooks_dir: Option<PathBuf>, + + /// The server's own SSH private key, used to sign browser-made edits + /// ($GIT_ENTS_WEB_SIGNING_KEY). Its public half must be a member of any + /// repo edited through the web. Editing is disabled unless this is set. + #[facet(args::named)] + pub web_signing_key: Option<PathBuf>, + + /// Directory where the `post-receive` hook queues pushes for the check + /// worker to run asynchronously ($GIT_ENTS_CHECKS_QUEUE, default + /// `/data/checks-queue`). + #[facet(args::named)] + pub checks_queue: Option<PathBuf>, +} + +/// Subcommands that run instead of serving HTTP. +#[derive(Facet, Debug)] +#[repr(u8)] +pub enum Command { + /// Verify a signed push against the authorized signers (a git + /// `pre-receive` hook). + PreReceive, + /// Run the configured checks against a push in a Sprite (a git + /// `post-receive` hook). + PostReceive, +} + +/// Shared handler state: where the bare repositories live, plus a lock that +/// serializes repository creation so concurrent first pushes cannot race. +#[derive(Clone)] +pub(crate) struct AppState { + pub(crate) data_dir: PathBuf, + pub(crate) init_lock: Arc<Mutex<()>>, + /// When set, injected as `receive.certNonceSeed` so the backend demands a + /// signed-push certificate the `pre-receive` hook can verify. + pub(crate) cert_nonce_seed: Option<String>, + /// When set, injected as `core.hooksPath` so every served repo runs the + /// bundled `pre-receive` verifier. + pub(crate) hooks_dir: Option<PathBuf>, + /// Directory the `post-receive` hook queues pushes into and the check + /// worker drains; passed down to the hook via [`checks::QUEUE_ENV`]. + pub(crate) checks_queue: PathBuf, + /// In-memory web sessions: a browser's signed-in public key, held for the + /// life of the process and never persisted. + pub(crate) sessions: web::Sessions, + /// Outstanding one-time sign-in challenges awaiting a signature. + pub(crate) challenges: web::Challenges, + /// The server's own signing key for browser-made edits; `None` disables + /// editing. + pub(crate) web_signing_key: Option<PathBuf>, + /// Live output for checks the worker currently has running, polled by the + /// Checks tab's live view. + pub(crate) live_runs: checks::LiveRegistry, +} + +/// The non-empty value of the environment variable `key`, or `None`. +fn env_var(key: &str) -> Option<String> { + std::env::var(key).ok().filter(|value| !value.is_empty()) +} + +/// Run the server: dispatch `pre-receive`/`post-receive`, or serve HTTP. +/// `args`' flags win over their matching environment variable, which in turn +/// wins over the hardcoded default. +pub fn run(args: Args) -> ExitCode { + if let Some(Command::PreReceive) = args.command { + return match verify::pre_receive() { + Ok(()) => ExitCode::SUCCESS, + Err(reason) => { + eprintln!("error: {reason}"); + ExitCode::FAILURE + } + }; + } + + if let Some(Command::PostReceive) = args.command { + // A post-receive failure cannot undo the push; report and exit clean so + // a runner hiccup never looks like a rejected push. + if let Err(reason) = checks::post_receive() { + eprintln!("checks: {reason}"); + } + return ExitCode::SUCCESS; + } + + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(e) => { + eprintln!("error: failed to start runtime: {e}"); + return ExitCode::FAILURE; + } + }; + runtime.block_on(serve(args)) +} + +/// Bind the listener and serve until shutdown. +async fn serve(args: Args) -> ExitCode { + let port = args + .port + .or_else(|| env_var("PORT").and_then(|value| value.parse().ok())) + .unwrap_or(8080); + let data_dir = args + .data_dir + .or_else(|| env_var("GIT_PROJECT_ROOT").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from("/data/repos")); + let checks_queue = args + .checks_queue + .or_else(|| env_var("GIT_ENTS_CHECKS_QUEUE").map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from("/data/checks-queue")); + let cert_nonce_seed = args.cert_nonce_seed.or_else(|| env_var("CERT_NONCE_SEED")); + let hooks_dir = args + .hooks_dir + .or_else(|| env_var("GIT_ENTS_HOOKS_DIR").map(PathBuf::from)); + let web_signing_key = args + .web_signing_key + .or_else(|| env_var("GIT_ENTS_WEB_SIGNING_KEY").map(PathBuf::from)); + + let state = AppState { + data_dir, + init_lock: Arc::new(Mutex::new(())), + cert_nonce_seed, + hooks_dir, + checks_queue, + sessions: web::new_sessions(), + challenges: web::new_challenges(), + web_signing_key, + live_runs: checks::new_live_registry(), + }; + + // Drain queued pushes and run their checks for the life of the server. + tokio::spawn(checks::worker( + state.checks_queue.clone(), + state.live_runs.clone(), + )); + + // The git smart-HTTP protocol streams whole packfiles through the request + // body, so the default 2 MiB cap would reject any non-trivial push. + let app = Router::new() + .route("/healthz", get(http::health)) + .route("/", get(http::get_request)) + .route("/_debug/{*path}", get(web::handshake)) + .route("/{*path}", get(http::get_request).post(http::post_request)) + .layer(DefaultBodyLimit::disable()) + .with_state(state); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(listener) => listener, + Err(e) => { + eprintln!("error: failed to bind to port {port}: {e}"); + return ExitCode::FAILURE; + } + }; + + if let Err(e) = axum::serve(listener, app).await { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + + ExitCode::SUCCESS +}