radicle-heartwood-lfs/radicle-remote-helper/src/lib.rs

138 lines
5.1 KiB
Rust

#![allow(clippy::collapsible_if)]
use std::path::PathBuf;
use std::{env, io, process};
use thiserror::Error;
use radicle::crypto::PublicKey;
use radicle::node::Handle;
use radicle::storage::git::transport::local::{Url, UrlError};
use radicle::storage::{ReadRepository, WriteRepository, WriteStorage};
/// The service invoked by git on the remote repository, during a push.
const GIT_RECEIVE_PACK: &str = "git-receive-pack";
/// The service invoked by git on the remote repository, during a fetch.
const GIT_UPLOAD_PACK: &str = "git-upload-pack";
#[derive(Debug, Error)]
pub enum Error {
/// Remote repository not found (or empty).
#[error("remote repository `{0}` not found")]
RepositoryNotFound(PathBuf),
/// Secret key is not registered, eg. with ssh-agent.
#[error("public key `{0}` is not registered with ssh-agent")]
KeyNotRegistered(PublicKey),
/// Public key doesn't match the remote namespace we're pushing to.
#[error("public key `{0}` does not match remote namespace")]
KeyMismatch(PublicKey),
/// Invalid command received.
#[error("invalid command `{0}`")]
InvalidCommand(String),
/// Invalid arguments received.
#[error("invalid arguments: {0:?}")]
InvalidArguments(Vec<String>),
/// Error with the remote url.
#[error("invalid remote url: {0}")]
RemoteUrl(#[from] UrlError),
}
/// Run the radicle remote helper using the given profile.
pub fn run(profile: radicle::Profile) -> Result<(), Box<dyn std::error::Error + 'static>> {
// `GIT_DIR` is expected to be set, though we aren't using it right now.
let _git_dir = env::var("GIT_DIR").map(PathBuf::from)?;
let url: Url = {
let args = env::args().skip(1).take(2).collect::<Vec<_>>();
match args.as_slice() {
[url] => url.parse(),
[_, url] => url.parse(),
_ => {
return Err(Error::InvalidArguments(args).into());
}
}
}?;
// Default to profile key.
let namespace = url.namespace.unwrap_or(profile.public_key);
let proj = profile.storage.repository_mut(url.repo)?;
if proj.is_empty()? {
return Err(Error::RepositoryNotFound(proj.path().to_path_buf()).into());
}
let stdin = io::stdin();
loop {
let mut line = String::new();
let read = stdin.read_line(&mut line)?;
if read == 0 {
break;
}
let tokens = line.trim().split(' ').collect::<Vec<_>>();
match tokens.as_slice() {
// First we are asked about capabilities.
["capabilities"] => {
println!("connect");
println!();
}
// Since we send a `connect` back, this is what is requested next.
["connect", service] => {
// Don't allow push if either of these conditions is true:
//
// 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
// 2. Our key is not the one loaded in the profile, which means that the signed refs
// won't match the remote we're pushing to.
let signer = if *service == GIT_RECEIVE_PACK {
if profile.public_key != namespace {
return Err(Error::KeyMismatch(profile.public_key).into());
}
let signer = profile.signer()?;
Some(signer)
} else {
None
};
if *service == GIT_UPLOAD_PACK {
// TODO: Fetch from network.
}
println!(); // Empty line signifies connection is established.
let mut child = process::Command::new(service)
.arg(proj.path())
.env("GIT_DIR", proj.path())
.env("GIT_NAMESPACE", namespace.to_string())
.stdout(process::Stdio::inherit())
.stderr(process::Stdio::inherit())
.stdin(process::Stdio::inherit())
.spawn()?;
if child.wait()?.success() && *service == GIT_RECEIVE_PACK {
if let Some(signer) = signer {
proj.sign_refs(&signer)?;
proj.set_head()?;
// Connect to local node and announce refs to the network.
// If our node is not running, we simply skip this step, as the
// refs will be announced eventually, when the node restarts.
let mut node = radicle::Node::new(profile.socket());
if node.is_running() {
eprint!("Announcing refs... ");
node.announce_refs(url.repo)?;
eprintln!("ok");
}
}
}
}
// An empty line means end of input.
[] => {
break;
}
_ => {
return Err(Error::InvalidCommand(line).into());
}
}
}
Ok(())
}