config.rs raw

use std::path::PathBuf;

use anyhow::{Error, bail};
use clap::Parser;

/// Runtime configuration, from command line flags or the corresponding
/// environment variables.
#[derive(Parser, Debug, Clone)]
#[command(name = "mugit", version, about)]
pub struct Config {
    /// Directory holding the bare repositories, one <name>.git each
    #[arg(long, env = "MUGIT_REPOS", default_value = "/srv/git")]
    pub repos: PathBuf,

    /// Address for plain HTTP. When a TLS domain is configured, this
    /// listener only redirects to HTTPS.
    #[arg(long, env = "MUGIT_LISTEN", default_value = "0.0.0.0:8000")]
    pub listen: String,

    /// Domain to obtain a Let's Encrypt certificate for. Setting it enables
    /// HTTPS on the TLS listener.
    #[arg(long, env = "MUGIT_TLS_DOMAIN")]
    pub tls_domain: Option<String>,

    /// Address for HTTPS
    #[arg(long, env = "MUGIT_TLS_LISTEN", default_value = "0.0.0.0:443")]
    pub tls_listen: String,

    /// Directory where the ACME account key and certificates are kept
    #[arg(long, env = "MUGIT_ACME_CACHE", default_value = "/var/lib/mugit/acme")]
    pub acme_cache: PathBuf,

    /// Contact email registered with the certificate authority
    #[arg(long, env = "MUGIT_ACME_CONTACT")]
    pub acme_contact: Option<String>,

    /// Use the Let's Encrypt staging environment, for testing
    #[arg(long, env = "MUGIT_ACME_STAGING")]
    pub acme_staging: bool,

    /// Site name shown in page headers and titles
    #[arg(long, env = "MUGIT_SITE_NAME", default_value = "mugit")]
    pub site_name: String,

    /// Number of repositories per page of the index
    #[arg(long, env = "MUGIT_PAGE_SIZE", default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..))]
    pub page_size: u32,

    /// Base URL that clone URLs are built from. Defaults to
    /// https://<tls-domain>, or without TLS to wherever each request
    /// arrived.
    #[arg(long, env = "MUGIT_PUBLIC_URL")]
    pub public_url: Option<String>,

    /// Number of git processes serving clones, fetches and raw files at
    /// once; further requests wait for a free one, then are refused
    #[arg(long, env = "MUGIT_GIT_PROCESSES", default_value_t = 8, value_parser = clap::value_parser!(u32).range(1..))]
    pub git_processes: u32,

    /// Seconds a clone, fetch or raw download may stall before it is
    /// dropped, and the longest a request waits for a free git process
    /// before it is refused
    #[arg(long, env = "MUGIT_GIT_IDLE_TIMEOUT", default_value_t = 60, value_parser = clap::value_parser!(u64).range(1..))]
    pub git_idle_timeout: u64,

    /// Seconds a connection may carry no traffic at all before it is
    /// closed, bounding how many idle clients can hold file descriptors.
    /// A request may legitimately be silent while it waits for a git
    /// process and again while that process stalls, so this must leave
    /// room for both: it has to exceed twice the git idle timeout.
    #[arg(long, env = "MUGIT_HTTP_IDLE_TIMEOUT", default_value_t = 300, value_parser = clap::value_parser!(u64).range(1..))]
    pub http_idle_timeout: u64,
}

impl Config {
    /// Checks the invariants that span more than one option, which clap
    /// cannot express on its own.
    pub fn validate(&self) -> Result<(), Error> {
        let floor = 2 * self.git_idle_timeout;
        if self.http_idle_timeout <= floor {
            bail!(
                "--http-idle-timeout is {}s, which does not exceed twice \
                 --git-idle-timeout ({}s); a clone that waits {}s for a git \
                 process and then stalls another {}s is still healthy, and \
                 closing the connection first would cut it off",
                self.http_idle_timeout,
                self.git_idle_timeout,
                self.git_idle_timeout,
                self.git_idle_timeout,
            );
        }
        Ok(())
    }

    /// The externally visible base URL of the site, without a trailing
    /// slash, when the configuration knows it.
    pub fn public_url(&self) -> Option<String> {
        let url = match (&self.public_url, &self.tls_domain) {
            (Some(url), _) => url.clone(),
            (None, Some(domain)) => format!("https://{domain}"),
            (None, None) => return None,
        };
        Some(url.trim_end_matches('/').to_owned())
    }
}

#[cfg(test)]
mod tests {
    use assert2::check;
    use clap::Parser;

    use super::Config;

    fn config(args: &[&str]) -> Config {
        Config::parse_from(std::iter::once("mugit").chain(args.iter().copied()))
    }

    #[test]
    fn the_default_http_idle_timeout_outlasts_a_slow_clone() {
        check!(config(&[]).validate().is_ok());
    }

    #[test]
    fn an_http_idle_timeout_that_could_cut_off_a_healthy_clone_is_refused() {
        // A request may wait a whole git idle timeout for a process and
        // then stall for another, so 2n is the shortest silence that is
        // still healthy and the connection has to outlast it.
        let args = ["--git-idle-timeout", "60", "--http-idle-timeout", "120"];
        check!(config(&args).validate().is_err());

        let args = ["--git-idle-timeout", "60", "--http-idle-timeout", "121"];
        check!(config(&args).validate().is_ok());
    }
}