🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! One SSH connection: who is connecting, and running git for them.
//!
//! The connection is authenticated by a public key that belongs to a registered
//! user. The name in the clone URL - conventionally `git` - is ignored, because
//! it says nothing about who is connecting; the key is the identity, and the
//! repository path names whose it is.

use std::net::SocketAddr;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;

use russh::keys::ssh_key;
use russh::server::{Auth, ChannelOpenHandle, Handle, Msg, Session};
use russh::{Channel, ChannelId, ChannelOpenFailure, Disconnect, Sig};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::process::{Child, ChildStdin};
use tokio::sync::{RwLock, Semaphore, watch};
use tracing::{Instrument, Span, debug, error, info, warn};

use crate::entities::{ssh_keys, users};
use crate::repositories::store::find_repository_by_path;
use crate::repositories::{GitService, RepositoryPath, may_read, parse_git_command};
use crate::state_read;
use crate::{AppState, FROGUE_PUBLIC_URL_VAR_KEY, SshThrottleConfig};

/// Buffer size for child communication. Needs to be small-ish because it stays on the child's stack
const BUFFER: usize = 32 * 1024;

/// Yeah, so this one (pun intended) is weiiiird. It has only one use but i was feeling weird just puting "1" as arg. 1 is the file descriptor of stderr.
const STDERR: u32 = 1;

/// A connection that has been authenticated by a key belonging to a user.
struct Authenticated {
    user_id: i64,
    name: String,
}

/// The git process this connection is running, if any.
struct Git {
    /// The child's standard input, taken - and so closed - when the client
    /// sends EOF, which is what tells `upload-pack` and `receive-pack` that
    /// the request is complete.
    ///
    /// Kept separate from the handle below rather than dropped with it: the
    /// process is still running at EOF, with its answer still to write.
    stdin: Option<ChildStdin>,
    /// Flipped to tell the process to stop. The child itself is awaited by a
    /// task of its own, so it outlives this handle, and dropping the sender is
    /// what ends a connection that went away without saying so.
    kill: watch::Sender<bool>,
}

/// Why a command was refused, which the client is told nothing beyond "failure"
/// about - the reasons are ours to log, not a stranger's to probe.
enum Refusal {
    /// No such repository, or no such owner.
    Missing,
    /// The key's owner may not do this to that repository.
    Forbidden,
    /// The database could not answer, which is the client's problem least of
    /// all.
    Unavailable,
}

/// An SSH public key that was offered but not registered yet.
///
/// Remembered so the registration TUI can pre-fill the key field with proof
/// of possession: the client signed with it, so we know they hold the private half.
#[derive(Clone)]
struct OfferedKey {
    /// The key itself, in the form russh understands.
    public_key: ssh_key::PublicKey,
    /// The fingerprint, for matching against the database.
    fingerprint: String,
}

/// The per-connection handler.
pub struct SessionHandler {
    state: Arc<RwLock<AppState>>,
    peer: Option<SocketAddr>,
    /// The connection's root span. The accept loop enters it for the whole
    /// connection; this handle is how fields that only become known partway
    /// through - the user, once a key is verified - are recorded on it.
    span: Span,
    /// Set once a key belonging to a user has been verified.
    user: Option<Authenticated>,
    /// Whether a session channel has been opened, so that only one ever is.
    opened: bool,
    /// `GIT_PROTOCOL`, the one environment variable a git client asks for.
    protocol: Option<String>,
    /// The running git process, once a command has been accepted.
    git: Option<Git>,
    /// Keys that were offered but not registered yet. Remembered so the
    /// registration TUI can show them pre-filled with proof of possession.
    offered_keys: Vec<OfferedKey>,
    /// A key that was verified (the client signed it) but belongs to no user yet.
    /// This is the registration candidate.
    verified_key: Option<OfferedKey>,
    /// Whether this is a guest session (no authenticated user yet).
    guest: bool,
    /// Input channel for the TUI, when running.
    tui_input: Option<tokio::sync::mpsc::UnboundedSender<Vec<u8>>>,
    /// Semaphore limiting concurrent guest connections.
    guest_semaphore: Arc<Semaphore>,
    /// SSH throttle configuration.
    throttle_config: SshThrottleConfig,
    /// Permit held by this guest session, if any.
    guest_permit: Option<tokio::sync::OwnedSemaphorePermit>,
    /// Notified when the TUI starts, to cancel the guest idle timeout.
    tui_started: Arc<tokio::sync::Notify>,
}

impl SessionHandler {
    pub fn new(
        state: Arc<RwLock<AppState>>,
        peer: Option<SocketAddr>,
        guest_semaphore: Arc<Semaphore>,
        throttle_config: SshThrottleConfig,
    ) -> Self {
        Self {
            state,
            peer,
            span: super::connection_span(peer),
            user: None,
            opened: false,
            protocol: None,
            git: None,
            offered_keys: Vec::new(),
            verified_key: None,
            guest: false,
            tui_input: None,
            guest_semaphore,
            throttle_config,
            guest_permit: None,
            tui_started: Arc::new(tokio::sync::Notify::new()),
        }
    }

    /// The connection's root span, for the accept loop to enter around the
    /// whole session.
    pub fn connection_span(&self) -> Span {
        self.span.clone()
    }

    /// The registered key matching `public_key`, with the user it belongs to.
    ///
    /// A lookup that fails is logged and treated as "no such key": the
    /// connection is refused either way, and a database hiccup should not end
    /// the server.
    async fn lookup_key(
        &self,
        public_key: &ssh_key::PublicKey,
    ) -> Option<(ssh_keys::Model, users::Model)> {
        let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
        let db = { state_read(&self.state).await.db.clone() };

        let key = match ssh_keys::Entity::find()
            .filter(ssh_keys::Column::Fingerprint.eq(&fingerprint))
            .one(&db)
            .await
        {
            Ok(key) => key,
            Err(error) => {
                error!(%error, "looking up an SSH key failed");
                return None;
            }
        };
        let key = key?;

        let owner = match users::Entity::find_by_id(key.user_id).one(&db).await {
            Ok(owner) => owner,
            Err(error) => {
                error!(%error, "looking up the owner of an SSH key failed");
                return None;
            }
        };

        owner.map(|owner| (key, owner))
    }

    /// Notes that `key` was just used to connect.
    ///
    /// Best effort: failing to record it costs only the timestamp, and must not
    /// turn a working key into a refused one.
    async fn record_use(&self, key: ssh_keys::Model) {
        let now = chrono::Utc::now().fixed_offset();

        let mut key: ssh_keys::ActiveModel = key.into();
        key.last_used_at = Set(Some(now));

        let db = { state_read(&self.state).await.db.clone() };
        if let Err(error) = key.update(&db).await {
            debug!(%error, "recording when an SSH key was last used failed");
        }
    }
}

impl russh::server::Handler for SessionHandler {
    type Error = russh::Error;

    /// Called before the signature is checked, to spare the work of verifying
    /// one we would refuse anyway.
    ///
    /// For unregistered keys, we accept the offer so the client signs, then
    /// verify the signature in auth_publickey and remember it for registration.
    #[tracing::instrument(parent = &self.span, skip_all)]
    async fn auth_publickey_offered(
        &mut self,
        _user: &str,
        public_key: &ssh_key::PublicKey,
    ) -> Result<Auth, Self::Error> {
        let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();

        // Remember this key for potential registration, regardless of whether
        // it's already registered. The auth_publickey step will verify possession.
        if !self
            .offered_keys
            .iter()
            .any(|k| k.fingerprint == fingerprint)
        {
            self.offered_keys.push(OfferedKey {
                public_key: public_key.clone(),
                fingerprint: fingerprint.clone(),
            });
            debug!(%fingerprint, "remembered an offered SSH key");
        }

        // Always accept so the client signs - we verify possession later
        Ok(Auth::Accept)
    }

    /// Reached only once the client has proven it holds the private half of the
    /// key it offered.
    #[tracing::instrument(parent = &self.span, skip_all)]
    async fn auth_publickey(
        &mut self,
        _user: &str,
        public_key: &ssh_key::PublicKey,
    ) -> Result<Auth, Self::Error> {
        // The `user` argument is the name in the clone URL, which for a forge
        // is conventionally `git` and carries no information about who is
        // connecting. It is ignored rather than trusted.
        let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();

        match self.lookup_key(public_key).await {
            Some((key, owner)) => {
                // Key is registered - this is an authenticated user
                self.record_use(key).await;

                info!(
                    user = %owner.name,
                    user_id = owner.id,
                    peer = ?self.peer,
                    "authenticated an SSH connection by key"
                );

                self.span.record("user.name", owner.name.as_str());

                self.user = Some(Authenticated {
                    user_id: owner.id,
                    name: owner.name,
                });

                Ok(Auth::Accept)
            }
            None => {
                // Key is not registered - this is a guest with a verified key
                // Try to acquire a guest permit
                let permit = match self.guest_semaphore.clone().try_acquire_owned() {
                    Ok(permit) => permit,
                    Err(_) => {
                        info!(
                            fingerprint = %fingerprint,
                            peer = ?self.peer,
                            "rejecting guest: too many concurrent guest connections"
                        );
                        self.span.record("ssh.refusal", "too_many_guests");
                        return Ok(Auth::reject());
                    }
                };

                // Accept authentication but mark as guest
                self.verified_key = Some(OfferedKey {
                    public_key: public_key.clone(),
                    fingerprint: fingerprint.clone(),
                });
                self.guest = true;
                self.guest_permit = Some(permit);

                info!(
                    fingerprint = %fingerprint,
                    peer = ?self.peer,
                    "accepted unregistered key as guest for registration"
                );

                Ok(Auth::Accept)
            }
        }
    }

    /// Reject "none" authentication to force public key verification.
    ///
    /// SSH clients typically try "none" first to discover available methods.
    /// By rejecting it, we force the client to try publickey authentication,
    /// which allows us to verify the key signature (proof of possession) before
    /// allowing guest access for registration.
    #[tracing::instrument(parent = &self.span, skip_all)]
    async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
        debug!("rejecting auth_none to force publickey authentication");
        Ok(Auth::reject())
    }

    /// Called when authentication succeeds. For guest sessions, this spawns a
    /// watchdog that disconnects the connection if the guest doesn't open the
    /// TUI within the idle timeout.
    async fn auth_succeeded(&mut self, session: &mut Session) -> Result<(), Self::Error> {
        if !self.guest {
            // Not a guest, nothing to do
            return Ok(());
        }

        // Spawn a watchdog for the guest idle timeout
        let handle = session.handle();
        let tui_started = self.tui_started.clone();
        let idle_timeout_secs = self.throttle_config.guest_idle_timeout_secs;
        let peer = self.peer;

        tokio::spawn(async move {
            tokio::select! {
                _ = tui_started.notified() => {
                    // TUI started, watchdog cancelled
                    debug!(?peer, "guest opened TUI, idle watchdog cancelled");
                }
                _ = tokio::time::sleep(std::time::Duration::from_secs(idle_timeout_secs)) => {
                    // Timeout expired, disconnect
                    warn!(
                        ?peer,
                        idle_timeout_secs,
                        "guest idle timeout expired, disconnecting"
                    );
                    let _ = handle.disconnect(
                        Disconnect::ByApplication,
                        "idle timeout".to_string(),
                        String::new(),
                    ).await;
                }
            }
        });

        Ok(())
    }

    /// Git uses a single session channel per command, so a second has nothing
    /// to be for.
    async fn channel_open_session(
        &mut self,
        _channel: Channel<Msg>,
        reply: ChannelOpenHandle,
        _session: &mut Session,
    ) -> Result<(), Self::Error> {
        if self.opened {
            reply
                .reject(ChannelOpenFailure::AdministrativelyProhibited)
                .await;
            return Ok(());
        }

        self.opened = true;
        reply.accept().await;
        Ok(())
    }

    /// Git uses this to say which protocol version it speaks. Anything else a
    /// client asks for is ignored rather than handed to the child.
    async fn env_request(
        &mut self,
        channel: ChannelId,
        variable_name: &str,
        variable_value: &str,
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        if variable_name == "GIT_PROTOCOL" {
            debug!(peer = ?self.peer, "the client speaks {variable_value}");
            self.protocol = Some(variable_value.to_string());
        }

        session.channel_success(channel)?;
        Ok(())
    }

    /// The command git runs over the connection, and the whole of what we
    /// honour: a service, a path, and nothing else.
    ///
    /// Gives the command a span of its own, under the connection's: it is the
    /// unit a clone or a push is actually measured in, and the child process
    /// and its pumps are hung off it so the whole exchange is one trace.
    #[tracing::instrument(
        name = "git.command",
        parent = &self.span,
        skip_all,
        fields(
            git.service = tracing::field::Empty,
            repository.owner = tracing::field::Empty,
            repository.slug = tracing::field::Empty,
            git.exit_status = tracing::field::Empty,
            git.refusal = tracing::field::Empty,
        )
    )]
    async fn exec_request(
        &mut self,
        channel: ChannelId,
        data: &[u8],
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        // One command per connection: git opens a channel, asks for one
        // service, and closes it. A second exec has nothing to attach to.
        if self.git.is_some() {
            session.channel_failure(channel)?;
            return Ok(());
        }

        let Some(authenticated) = self.user.as_ref() else {
            // Unreachable through a conforming client, but the channel is a
            // request surface of its own and should not assume otherwise.
            warn!(peer = ?self.peer, "an unauthenticated connection tried to run a command");
            session.channel_failure(channel)?;
            return Ok(());
        };
        let user_id = authenticated.user_id;
        let user_name = authenticated.name.clone();

        let Ok(command) = std::str::from_utf8(data) else {
            warn!(peer = ?self.peer, "an SSH command was not UTF-8; refusing it");
            session.channel_failure(channel)?;
            return Ok(());
        };

        let (service, path) = match parse_git_command(command) {
            Ok(parsed) => parsed,
            Err(error) => {
                debug!(%command, ?error, peer = ?self.peer, "refusing an SSH command");
                session.channel_failure(channel)?;
                return Ok(());
            }
        };

        let span = Span::current();
        span.record("git.service", tracing::field::display(service));
        span.record("repository.owner", path.owner.as_str());
        span.record("repository.slug", path.slug.as_str());

        // Authorisation, and the directory to run git in, both come from the
        // database and the repository root.
        let (db, repo_root, hooks_dir, public_url) = {
            let state = state_read(&self.state).await;
            (
                state.db.clone(),
                state.repo_root.clone(),
                state.repo_root.join(".frogue-hooks"),
                state.public_url.clone(),
            )
        };

        let (repository, _owner) = match authorise(&db, service, &path, user_id).await {
            Ok(result) => result,
            Err(refusal) => {
                match refusal {
                    Refusal::Missing => {
                        span.record("git.refusal", "missing");
                        debug!(peer = ?self.peer, %path, "no such repository")
                    }
                    Refusal::Forbidden => {
                        span.record("git.refusal", "forbidden");
                        info!(%path, user = %user_name, peer = ?self.peer, "refusing a command on a repository this key may not reach")
                    }
                    Refusal::Unavailable => {
                        span.record("git.refusal", "unavailable");
                    }
                }
                session.channel_failure(channel)?;
                return Ok(());
            }
        };

        let repo_path = path.bare_path(&repo_root);

        // The row can outlive the directory if something removed it from under
        // us. Refusing is better than handing git a path to complain about.
        if !repo_path.is_dir() {
            span.record("git.refusal", "not-on-disk");
            debug!(path = %repo_path.display(), "the repository is not on disk");
            session.channel_failure(channel)?;
            return Ok(());
        }

        match spawn(
            service,
            &repo_path,
            self.protocol.as_deref(),
            user_id,
            &user_name,
            repository.id,
            &hooks_dir,
            &public_url,
        )
        .await
        {
            Ok(started) => {
                // The client is told the command started before the pumps are
                // set loose, so nothing it writes is dropped for want of a
                // place to put it.
                session.channel_success(channel)?;

                let Started {
                    git,
                    stdout,
                    stderr,
                    child,
                    killed,
                } = started;
                let handle = session.handle();

                // The pumps and the wait outlive this call, so they carry the
                // command span with them: the trace stays open until git has
                // finished answering, not merely until the command was
                // accepted.
                tokio::spawn(pump(handle.clone(), channel, stdout, false).instrument(span.clone()));
                tokio::spawn(pump(handle.clone(), channel, stderr, true).instrument(span.clone()));
                tokio::spawn(wait(handle, channel, child, killed).instrument(span.clone()));

                self.git = Some(git);
                Ok(())
            }
            Err(()) => {
                session.channel_failure(channel)?;
                Ok(())
            }
        }
    }

    /// Route incoming data to either git or the TUI, depending on what's active.
    async fn data(
        &mut self,
        _channel: ChannelId,
        data: &[u8],
        _session: &mut Session,
    ) -> Result<(), Self::Error> {
        // If TUI is active, send data to it
        if let Some(tui_input) = &self.tui_input {
            if let Err(error) = tui_input.send(data.to_vec()) {
                debug!(%error, "sending data to TUI failed");
            }
            return Ok(());
        }

        // Otherwise, send to git if running
        if let Some(stdin) = self.git.as_mut().and_then(|git| git.stdin.as_mut())
            && let Err(error) = stdin.write_all(data).await
        {
            debug!(%error, "writing to git's standard input failed");
        }

        Ok(())
    }

    /// Closing the child's standard input ends its request, which is what tells
    /// `upload-pack` and `receive-pack` the client has said all it intends to.
    ///
    /// The process is left alone: it still has an answer to write, and its exit
    /// status is what the client waits for. Dropping the whole handle here would
    /// drop the kill sender too, which the waiting task reads as "stop" - and
    /// that would cut the answer off mid-flight.
    async fn channel_eof(
        &mut self,
        _channel: ChannelId,
        _session: &mut Session,
    ) -> Result<(), Self::Error> {
        if let Some(git) = self.git.as_mut() {
            git.stdin = None;
        }
        Ok(())
    }

    async fn channel_close(
        &mut self,
        _channel: ChannelId,
        _session: &mut Session,
    ) -> Result<(), Self::Error> {
        if let Some(git) = self.git.take() {
            let _ = git.kill.send(true);
        }
        Ok(())
    }

    /// There is one process on this channel and nothing else to signal, so any
    /// signal the client sends means the same thing to us: stop it.
    async fn signal(
        &mut self,
        _channel: ChannelId,
        signal: Sig,
        _session: &mut Session,
    ) -> Result<(), Self::Error> {
        debug!(?signal, peer = ?self.peer, "stopping git on the client's signal");
        if let Some(git) = self.git.as_ref() {
            let _ = git.kill.send(true);
        }
        Ok(())
    }

    /// Accept PTY requests from guests for the registration TUI.
    async fn pty_request(
        &mut self,
        channel: ChannelId,
        _term: &str,
        _col_width: u32,
        _row_height: u32,
        _pix_width: u32,
        _pix_height: u32,
        _modes: &[(russh::Pty, u32)],
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        if self.user.is_some() {
            // Authenticated users don't need a PTY yet
            session.channel_failure(channel)?;
        } else {
            // Guests get a PTY for the registration TUI
            session.channel_success(channel)?;
        }
        Ok(())
    }

    /// Launch the registration TUI for guest users.
    async fn shell_request(
        &mut self,
        channel: ChannelId,
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        if self.user.is_some() {
            // Authenticated users can't request a shell yet
            debug!(peer = ?self.peer, "authenticated user requested shell; not yet supported");
            session.channel_failure(channel)?;
            return Ok(());
        }

        // Guest user - launch the registration TUI
        info!(peer = ?self.peer, "launching registration TUI for guest");

        // Notify the idle watchdog that the TUI is starting.
        // TODO: check that it really cuts the connection if we're still on the login after SSH_TUI_TIMEOUT_SECONDS_VAR_KEY
        self.tui_started.notify_one();

        // Create channels for TUI communication
        let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel();
        let (output_tx, mut output_rx) = tokio::sync::mpsc::unbounded_channel();

        // Spawn the TUI task with a timeout
        let verified_key = self.verified_key.as_ref().map(|k| k.public_key.clone());
        let state = self.state.clone();
        let handle = session.handle();
        let throttle_config = self.throttle_config.clone();
        let peer = self.peer;

        tokio::spawn(async move {
            let tui_timeout = std::time::Duration::from_secs(throttle_config.tui_timeout_secs);

            let result = tokio::time::timeout(
                tui_timeout,
                super::tui::run_registration(&mut input_rx, output_tx, verified_key, state),
            )
            .await;

            match result {
                Ok(Ok(Some(user))) => {
                    info!(user = %user.name, "user registered successfully via SSH TUI");
                    // Send success message
                    let msg = format!("\r\n✓ Registration successful! Welcome, {}.\r\n", user.name);
                    let _ = handle.data(channel, msg.into_bytes());
                }
                Ok(Ok(None)) => {
                    debug!("user cancelled registration");
                    let _ = handle.data(channel, b"\r\nRegistration cancelled.\r\n".to_vec());
                }
                Ok(Err(e)) => {
                    error!(%e, "registration TUI failed");
                    let _ = handle.data(
                        channel,
                        format!("\r\n✗ Registration failed: {}\r\n", e).into_bytes(),
                    );
                }
                Err(_) => {
                    // Timeout expired
                    warn!(
                        ?peer,
                        tui_timeout_secs = throttle_config.tui_timeout_secs,
                        "TUI registration timeout expired"
                    );
                    let _ = handle.data(
                        channel,
                        b"\r\n[X] Registration timed out. Please reconnect.\r\n".to_vec(),
                    );
                    let _ = handle
                        .disconnect(
                            Disconnect::ByApplication,
                            "registration timeout".to_string(),
                            String::new(),
                        )
                        .await;
                }
            }

            // Close the channel
            let _ = handle.close(channel).await;
        });

        // Spawn task to forward output from TUI to SSH channel
        let handle = session.handle();
        tokio::spawn(async move {
            while let Some(data) = output_rx.recv().await {
                if handle.data(channel, data).await.is_err() {
                    break;
                }
            }
        });

        // Store the input sender so we can route data events to the TUI
        self.tui_input = Some(input_tx);

        session.channel_success(channel)?;
        Ok(())
    }

    /// There is no subsystem, `sftp` or otherwise, either.
    async fn subsystem_request(
        &mut self,
        channel: ChannelId,
        name: &str,
        session: &mut Session,
    ) -> Result<(), Self::Error> {
        debug!(%name, peer = ?self.peer, "refusing a subsystem request");
        session.channel_failure(channel)?;
        Ok(())
    }
}

/// Everything a git child needs once it has been started: the pipes to move
/// data along, and the handle to move it with.
struct Started {
    git: Git,
    stdout: tokio::process::ChildStdout,
    stderr: tokio::process::ChildStderr,
    child: Child,
    killed: watch::Receiver<bool>,
}

/// Starts `git` for this connection, with its standard streams piped so they
/// can be moved across the SSH channel.
async fn spawn(
    service: GitService,
    repo_path: &Path,
    protocol: Option<&str>,
    user_id: i64,
    user_name: &str,
    repository_id: i64,
    hooks_dir: &Path,
    public_url: &str,
) -> Result<Started, ()> {
    let mut command = tokio::process::Command::new("git");
    command
        // Configure git to use our hooks directory and enable proc-receive
        .arg("-c")
        .arg(format!("core.hooksPath={}", hooks_dir.display()))
        .arg("-c")
        .arg("receive.procReceiveRefs=refs/patches")
        .arg("-c")
        .arg("receive.procReceiveRefs=refs/patches/*")
        .arg("-c")
        .arg("receive.advertisePushOptions=true")
        .arg(service.subcommand())
        .arg(repo_path)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        // If the connection drops, the child must not be left running: it is
        // only ever reachable through this channel.
        .kill_on_drop(true);

    if let Some(protocol) = protocol {
        command.env("GIT_PROTOCOL", protocol);
    }

    // Pass environment variables for the hooks
    command
        .env("FROGUE_PUSH_USER_ID", user_id.to_string())
        .env("FROGUE_PUSH_USER_NAME", user_name)
        .env("FROGUE_REPOSITORY_ID", repository_id.to_string())
        .env(FROGUE_PUBLIC_URL_VAR_KEY, public_url);

    let mut child = match command.spawn() {
        Ok(child) => child,
        Err(error) => {
            error!(%error, %service, path = %repo_path.display(), "starting git failed");
            return Err(());
        }
    };

    let (Some(stdin), Some(stdout), Some(stderr)) =
        (child.stdin.take(), child.stdout.take(), child.stderr.take())
    else {
        error!(%service, "git started without the standard streams this needs");
        let _ = child.kill().await;
        return Err(());
    };

    let (kill, killed) = watch::channel(false);

    debug!(%service, path = %repo_path.display(), user = %user_name, user_id, "started git");

    Ok(Started {
        git: Git {
            stdin: Some(stdin),
            kill,
        },
        stdout,
        stderr,
        child,
        killed,
    })
}

/// Checks that `user_id` may run `service` on the repository `path` names.
/// Returns the repository and owner if authorized.
async fn authorise(
    db: &DatabaseConnection,
    service: GitService,
    path: &RepositoryPath,
    user_id: i64,
) -> Result<
    (
        crate::entities::repositories::Model,
        crate::entities::users::Model,
    ),
    Refusal,
> {
    let repository = find_repository_by_path(db, &path.owner, &path.slug)
        .await
        .map_err(|error| {
            error!(%error, "looking up a repository failed");
            Refusal::Unavailable
        })?;

    let Some((owner, repository)) = repository else {
        return Err(Refusal::Missing);
    };

    let allowed = match service {
        GitService::UploadPack => {
            // A connection only ever gets this far by proving a key, so the
            // viewer is always known here.
            may_read(repository.visibility, repository.owner_id, Some(user_id))
        }
        GitService::ReceivePack => {
            // Owner can always push. For public repos, any authenticated user can
            // run receive-pack; the pre-receive hook will restrict what refs they
            // may touch.
            repository.owner_id == user_id
                || repository.visibility == crate::entities::repositories::Visibility::Public
        }
    };

    if allowed {
        Ok((repository, owner))
    } else {
        Err(Refusal::Forbidden)
    }
}

/// Sends what git writes on one of its standard streams to the client.
///
/// Standard output is the git protocol itself and goes back over the channel.
/// Standard error is git's complaints - a rejected non-fast-forward push, a
/// missing object - which git clients render to the terminal, so it goes over
/// extended data the way SSH carries standard error.
async fn pump(
    handle: Handle,
    channel: ChannelId,
    mut stream: impl AsyncRead + Unpin,
    stderr: bool,
) {
    let mut buffer = vec![0u8; BUFFER];

    loop {
        match stream.read(&mut buffer).await {
            Ok(0) => break,
            Ok(read) => {
                let sent = if stderr {
                    handle
                        .extended_data(channel, STDERR, buffer[..read].to_vec())
                        .await
                } else {
                    handle.data(channel, buffer[..read].to_vec()).await
                };

                // The client is gone, so the channel is finished.
                if sent.is_err() {
                    break;
                }
            }
            Err(error) => {
                debug!(%error, stderr, "reading git's output failed");
                break;
            }
        }
    }
}

/// Waits for git to finish and tells the client how it went.
///
/// An exit status of 0 is what git treats as success, so anything the child is
/// killed by - the client disconnecting, a signal it was sent - is reported as
/// 1, the convention for "something went wrong".
async fn wait(
    handle: Handle,
    channel: ChannelId,
    mut child: Child,
    mut killed: watch::Receiver<bool>,
) {
    let status = tokio::select! {
        _ = killed.changed() => {
            let _ = child.kill().await;
            child.wait().await.ok()
        }
        status = child.wait() => status.ok(),
    };

    let exit_status = match status {
        Some(status) => status.code().unwrap_or(1) as u32,
        None => 1,
    };

    // Entered in the command's span, so this lands on it: how the command
    // ended is the last thing a trace needs before it can be read.
    Span::current().record("git.exit_status", exit_status as u64);

    let _ = handle.exit_status_request(channel, exit_status).await;
    let _ = handle.eof(channel).await;
    let _ = handle.close(channel).await;
}