git-remote-iroh 0.2.1

Git remote protocol support for https://www.iroh.computer
Documentation
use std::{fmt::Display, fs::create_dir_all, path::PathBuf, str::FromStr};

mod auth;
mod cli;
mod client;
mod keys;
mod server;

use anyhow::Context;

pub use cli::{Args, ClientArgs};
pub use client::Client;
pub use server::Server;
use tokio::io::{AsyncWrite, AsyncWriteExt};

/// ALPN for git-remote-iroh connections.
pub const GIT_IROH_ALPN: &[u8] = b"git/iroh/0";

fn config_dir() -> Result<PathBuf, anyhow::Error> {
    let config_dir = dirs::config_local_dir()
        .unwrap_or(PathBuf::from(
            std::env::var("HOME").context("HOME not set")?,
        ))
        .join("git-remote-iroh");
    create_dir_all(&config_dir)?;
    Ok(config_dir)
}

fn keys_dir(config_dir: &PathBuf) -> PathBuf {
    config_dir.join("keys")
}

/// Git operation client request.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GitOp {
    /// Client is doing a fetch. Server runs git-upload-pack.
    ClientFetchServerPush,
    /// Client is going a push. Server runs git-fetch-pack.
    ClientPushServerFetch,
}

impl GitOp {
    /// Return the resource ID that will be authorized for the operation on
    /// the git repository path. The operation must currently be bound to the
    /// authorized resource_id, otherwise the transfer function has no way to
    /// know what was authorized.
    pub fn resource_id<S: Display>(self, resource: S) -> Vec<u8> {
        format!(
            "{}:{}",
            match self {
                GitOp::ClientFetchServerPush => "fetch",
                GitOp::ClientPushServerFetch => "push",
            },
            resource
        )
        .as_bytes()
        .to_vec()
    }

    pub async fn write_request<W: AsyncWrite + Unpin, S: Display>(
        self,
        w: &mut W,
        resource: S,
    ) -> Result<(), anyhow::Error> {
        let resource_id = self.resource_id(resource);
        w.write_u16_le(resource_id.len().try_into()?).await?;
        w.write(resource_id.as_slice()).await?;
        w.write_u8(match self {
            // Client requesting a push is a read operation.
            GitOp::ClientFetchServerPush => 1u8,
            // Client requesting a fetch is a write operation.
            GitOp::ClientPushServerFetch => 2u8,
        })
        .await?;
        Ok(())
    }

    pub fn from_resource_id(resource_id: &[u8]) -> Result<GitOp, anyhow::Error> {
        Ok(match resource_id.split(|c| *c == b':').next() {
            Some(b"fetch") => GitOp::ClientFetchServerPush,
            Some(b"push") => GitOp::ClientPushServerFetch,
            _ => anyhow::bail!("invalid resource"),
        })
    }

    pub fn to_byte(self) -> u8 {
        match self {
            GitOp::ClientFetchServerPush => 1,
            GitOp::ClientPushServerFetch => 2,
        }
    }

    pub fn from_byte(b: u8) -> Option<Self> {
        match b {
            1 => Some(GitOp::ClientFetchServerPush),
            2 => Some(GitOp::ClientPushServerFetch),
            _ => None,
        }
    }
}

impl FromStr for GitOp {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(match s {
            "git-upload-pack" => GitOp::ClientFetchServerPush,
            "git-receive-pack" => GitOp::ClientPushServerFetch,
            _ => anyhow::bail!("unsupported command {s}"),
        })
    }
}

impl Display for GitOp {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            GitOp::ClientPushServerFetch => f.write_str("git-receive-pack"),
            GitOp::ClientFetchServerPush => f.write_str("git-upload-pack"),
        }
    }
}

/// Parses `iroh://<node-id>` URL.
fn parse_iroh_url(url: &str) -> Result<iroh::PublicKey, anyhow::Error> {
    let node_id_str = url.strip_prefix("iroh://").ok_or(anyhow::anyhow!(
        "invalid iroh URL (expected iroh://): {}",
        url
    ))?;

    Ok(node_id_str.parse::<iroh::PublicKey>()?)
}