🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Git over HTTP, read-only.
//!
//! A visitor who is not signed in cannot push - writing is SSH's alone - so
//! the only thing HTTP can offer is a fetch. That is what this serves: git's
//! *smart* HTTP, where `info/refs` answers with the advertisement
//! `git upload-pack --advertise-refs` would write, and `git-upload-pack` is
//! the negotiation itself, piped through the same binary the SSH server runs.
//!
//! Pushing, therefore, is refused twice over: a client that asks for the
//! receive-pack service is refused outright, and a client that posts to
//! `git-receive-pack` anyway is refused before git is ever started.
//!
//! A private repository is not served here at all. It has no session to speak
//! of over the HTTP git protocol - a `git` client does not carry a browser's
//! cookie - so the only reader a private repository could have is one who
//! already knows too much. It answers as though it does not exist, exactly as
//! its web page does.

use std::path::PathBuf;
use std::sync::Arc;

use actix_web::{HttpRequest, HttpResponse, get, post, web};
use tokio::sync::RwLock;
use tracing::{error, info};

use crate::repositories::{self, RepositoryPath, store};
use crate::{AppState, state_read};

/// The only service worth advertising: fetching. `git-receive-pack` is
/// refused, since pushing over HTTP is not something this forge offers.
const UPLOAD_PACK: &str = "git-upload-pack";

/// The content types git's smart HTTP recognises, which it checks rather than
/// treats as a hint.
const ADVERTISEMENT: &str = "application/x-git-upload-pack-advertisement";
const RESULT: &str = "application/x-git-upload-pack-result";

/// An advertisement of the refs a repository may be fetched from.
#[get("/{owner}/{repo}/info/refs")]
#[tracing::instrument(
    skip_all,
    fields(
        network.transport = "git-http",
        owner = tracing::field::Empty,
        repo = tracing::field::Empty,
        service = tracing::field::Empty,
    )
)]
async fn info_refs(
    data: web::Data<Arc<RwLock<AppState>>>,
    req: HttpRequest,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (owner, repo) = path.into_inner();
    let span = tracing::Span::current();
    span.record("owner", owner.as_str());
    span.record("repo", repo.as_str());

    let Some(service) = query_service(req.query_string()) else {
        info!(owner = %owner, repo = %repo, "a git request naming no service");
        return HttpResponse::Forbidden().body("only git over SSH: clone with an SSH remote\n");
    };
    span.record("service", service);

    // A push would ask for git-receive-pack; writes are SSH's alone. Refusing
    // before the repository is looked up means the answer is the same whether
    // or not the repository named exists, so it reveals nothing.
    if service != UPLOAD_PACK {
        info!(%owner, %repo, %service, "refusing a write over HTTP");
        return HttpResponse::Forbidden().body("pushing is only allowed over SSH\n");
    }

    let bare = match read_only_repo(&data, &owner, &repo).await {
        Ok(bare) => bare,
        Err(refusal) => return refusal_response(refusal, &owner, &repo),
    };

    let protocol = git_protocol(&req);

    let advertisement =
        match repositories::git::upload_pack(&bare, &[], protocol.as_deref(), true).await {
            Ok(advertisement) => advertisement,
            Err(error) => {
                error!(%error, path = %bare.display(), "advertising refs over HTTP failed");
                return HttpResponse::InternalServerError().finish();
            }
        };

    // The service announcement is what tells the client which protocol this
    // is; git writes it by hand and it is the one part of the answer git does
    // not get from `upload-pack`.
    let mut body = service_announcement(UPLOAD_PACK);
    body.extend_from_slice(&advertisement);

    HttpResponse::Ok()
        .content_type(ADVERTISEMENT)
        .insert_header(("Cache-Control", "no-cache"))
        .body(body)
}

/// The negotiation a fetch sends, and what git answers back.
#[post("/{owner}/{repo}/git-upload-pack")]
#[tracing::instrument(
    skip_all,
    fields(network.transport = "git-http", owner = tracing::field::Empty, repo = tracing::field::Empty)
)]
async fn upload_pack(
    data: web::Data<Arc<RwLock<AppState>>>,
    req: HttpRequest,
    path: web::Path<(String, String)>,
    request: web::Bytes,
) -> HttpResponse {
    let (owner, repo) = path.into_inner();
    let span = tracing::Span::current();
    span.record("owner", owner.as_str());
    span.record("repo", repo.as_str());

    let bare = match read_only_repo(&data, &owner, &repo).await {
        Ok(bare) => bare,
        Err(refusal) => return refusal_response(refusal, &owner, &repo),
    };

    let protocol = git_protocol(&req);

    match repositories::git::upload_pack(&bare, &request, protocol.as_deref(), false).await {
        Ok(answer) => HttpResponse::Ok()
            .content_type(RESULT)
            .insert_header(("Cache-Control", "no-cache"))
            .body(answer),
        Err(error) => {
            error!(%error, path = %bare.display(), "serving a fetch over HTTP failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// A push over HTTP, refused. Registered so a client that insists on trying is
/// answered with a reason rather than the silence of a `404`.
#[post("/{owner}/{repo}/git-receive-pack")]
#[tracing::instrument(
    skip_all,
    fields(network.transport = "git-http", owner = tracing::field::Empty, repo = tracing::field::Empty)
)]
async fn receive_pack(path: web::Path<(String, String)>) -> HttpResponse {
    let (owner, repo) = path.into_inner();
    let span = tracing::Span::current();
    span.record("owner", owner.as_str());
    span.record("repo", repo.as_str());

    // The body is not read: there is nothing to do with a push but refuse it,
    // and refusing before reading it means a large one is not buffered first.
    info!(%owner, %repo, "refusing a push over HTTP");
    HttpResponse::Forbidden().body("pushing is only allowed over SSH\n")
}

/// Why a repository could not be read without a session.
enum Refusal {
    /// It does not exist, or the caller may not know that it does. The two are
    /// deliberately the same so that a `404` reveals nothing.
    Unavailable,
    /// The server could not answer.
    Fault,
}

/// The bare repository `owner/repo` names, when it exists and may be read
/// without a session.
///
/// A repository that does not exist and one the caller may not read are the
/// same [`Refusal::Unavailable`]: whoever is asking learns nothing about which
/// it was. A database that cannot be read is a [`Refusal::Fault`] instead,
/// since that is the server's fault and not something to disguise as a missing
/// repository.
async fn read_only_repo(
    data: &web::Data<Arc<RwLock<AppState>>>,
    owner: &str,
    repo: &str,
) -> Result<PathBuf, Refusal> {
    // The `.git` the URL carries is part of the address, and the rules that
    // say what a repository is called require it - so parsing `owner/repo.git`
    // is what proves this names a repository rather than some other path.
    let Some(path) = RepositoryPath::parse(&format!("{owner}/{repo}")) else {
        return Err(Refusal::Unavailable);
    };

    let (repo_root, found) = {
        let state = state_read(data).await;
        match store::find_repository_by_path(&state.db, &path.owner, &path.slug).await {
            Ok(found) => (state.repo_root.clone(), found),
            Err(error) => {
                error!(%error, owner = %path.owner, slug = %path.slug, "looking up a repository failed");
                return Err(Refusal::Fault);
            }
        }
    };

    let Some((_owner, repository)) = found else {
        return Err(Refusal::Unavailable);
    };

    // A git client carries no session, so this is always somebody signed out
    // as far as the repository's owner is concerned.
    if !repositories::may_read(repository.visibility, repository.owner_id, None) {
        return Err(Refusal::Unavailable);
    }

    Ok(path.bare_path(&repo_root))
}

/// The response for a refused read.
fn refusal_response(refusal: Refusal, owner: &str, repo: &str) -> HttpResponse {
    match refusal {
        Refusal::Unavailable => {
            HttpResponse::NotFound().body(format!("Repository `{owner}/{repo}` could not be found"))
        }
        Refusal::Fault => HttpResponse::InternalServerError().finish(),
    }
}

/// The service a git request asks for, from its `service=` query parameter,
/// when it is one this server runs.
fn query_service(query: &str) -> Option<&str> {
    query.split('&').find_map(|part| {
        part.strip_prefix("service=")
            .map(str::trim)
            .filter(|service| !service.is_empty())
    })
}

/// The `GIT_PROTOCOL` a client asked for, so that a version 2 client gets a
/// version 2 server, the way the SSH server passes the same header through.
fn git_protocol(req: &HttpRequest) -> Option<String> {
    req.headers()
        .get("Git-Protocol")
        .and_then(|value| value.to_str().ok())
        .map(str::to_string)
}

/// The pkt-line git expects to open an `info/refs` answer with: the service
/// name, then the flush that says the announcement is done.
fn service_announcement(service: &str) -> Vec<u8> {
    let line = format!("# service={service}\n");
    let mut announcement = format!("{:04x}", line.len() + 4).into_bytes();
    announcement.extend_from_slice(line.as_bytes());
    announcement.extend_from_slice(b"0000");
    announcement
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn service_is_read_from_the_query() {
        assert_eq!(query_service("service=git-upload-pack"), Some(UPLOAD_PACK));
        assert_eq!(
            query_service("service=git-upload-pack&other=1"),
            Some(UPLOAD_PACK)
        );
        assert_eq!(
            query_service("service=git-receive-pack"),
            Some("git-receive-pack")
        );
        assert_eq!(query_service("nothing"), None);
        assert_eq!(query_service(""), None);
        assert_eq!(query_service("service="), None);
        assert_eq!(
            query_service("other=1&service=git-upload-pack"),
            Some(UPLOAD_PACK)
        );
    }

    #[test]
    fn the_announcement_is_the_shape_git_expects() {
        // 26 bytes of text plus the 4-byte length prefix, then the flush.
        let announcement = service_announcement("git-upload-pack");
        assert_eq!(
            String::from_utf8_lossy(&announcement),
            "001e# service=git-upload-pack\n0000"
        );
    }
}