From a2fc976ed6c290e4dc4f3283d0d7a382bf286a06 Mon Sep 17 00:00:00 2001 From: Alexis Sellier Date: Sun, 16 Oct 2022 17:57:57 +0200 Subject: [PATCH] remote-helper: Initialize Implement a `git-remote-rad` helper for `rad://` remotes. Signed-off-by: Alexis Sellier --- Cargo.lock | 8 + Cargo.toml | 2 +- radicle-remote-helper/Cargo.toml | 17 ++ radicle-remote-helper/src/git-remote-rad.rs | 14 ++ radicle-remote-helper/src/lib.rs | 203 ++++++++++++++++++++ radicle-ssh/src/agent/client.rs | 1 - radicle/src/node.rs | 8 +- radicle/src/profile.rs | 2 +- radicle/src/rad.rs | 1 + radicle/src/storage/git.rs | 1 + 10 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 radicle-remote-helper/Cargo.toml create mode 100644 radicle-remote-helper/src/git-remote-rad.rs create mode 100644 radicle-remote-helper/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 3c7f5841..2661ba96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -808,6 +808,14 @@ dependencies = [ "thiserror", ] +[[package]] +name = "radicle-remote-helper" +version = "0.2.0" +dependencies = [ + "radicle", + "thiserror", +] + [[package]] name = "radicle-ssh" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 13f533f9..36b5b6b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["radicle", "radicle-node", "radicle-tools", "radicle-ssh"] +members = ["radicle", "radicle-node", "radicle-tools", "radicle-ssh", "radicle-remote-helper"] [patch.crates-io.nakamoto-net] git = "https://github.com/cloudhead/nakamoto" diff --git a/radicle-remote-helper/Cargo.toml b/radicle-remote-helper/Cargo.toml new file mode 100644 index 00000000..b8bd653a --- /dev/null +++ b/radicle-remote-helper/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "radicle-remote-helper" +license = "MIT OR Apache-2.0" +version = "0.2.0" +authors = ["Alexis Sellier "] +edition = "2021" + +[dependencies] +thiserror = "1" + +[dependencies.radicle] +path = "../radicle" +version = "0" + +[[bin]] +name = "git-remote-rad" +path = "src/git-remote-rad.rs" diff --git a/radicle-remote-helper/src/git-remote-rad.rs b/radicle-remote-helper/src/git-remote-rad.rs new file mode 100644 index 00000000..f08f1e52 --- /dev/null +++ b/radicle-remote-helper/src/git-remote-rad.rs @@ -0,0 +1,14 @@ +fn main() { + let profile = match radicle::Profile::load() { + Ok(profile) => profile, + Err(err) => { + eprintln!("fatal: couldn't load profile: {}", err); + std::process::exit(1); + } + }; + + if let Err(err) = radicle_remote_helper::run(profile) { + eprintln!("fatal: {}", err); + std::process::exit(1); + } +} diff --git a/radicle-remote-helper/src/lib.rs b/radicle-remote-helper/src/lib.rs new file mode 100644 index 00000000..697543eb --- /dev/null +++ b/radicle-remote-helper/src/lib.rs @@ -0,0 +1,203 @@ +#![allow(clippy::collapsible_if)] +use std::path::PathBuf; +use std::str::FromStr; +use std::{env, io, process}; + +use thiserror::Error; + +use radicle::crypto::{PublicKey, Signer}; +use radicle::node::Handle; +use radicle::ssh; +use radicle::storage::{ReadRepository, WriteStorage}; + +/// The service invoked by git on the remote repository, during a push. +const GIT_RECEIVE_PACK: &str = "git-receive-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), + /// Error with the remote url. + #[error("invalid remote url: {0}")] + RemoteUrl(#[from] UrlError), +} + +#[derive(Debug, Error)] +pub enum UrlError { + /// Failed to parse. + #[error(transparent)] + Parse(#[from] radicle::git::url::parse::Error), + /// Unsupported URL scheme. + #[error("{0}: unsupported scheme: expected `rad://`")] + UnsupportedScheme(radicle::git::Url), + /// Missing host. + #[error("{0}: missing id")] + MissingId(radicle::git::Url), + /// Invalid remote repository identifier. + #[error("{0}: id: {1}")] + InvalidId(radicle::git::Url, radicle::identity::IdError), + /// Invalid public key. + #[error("{0}: key: {1}")] + InvalidKey(radicle::git::Url, radicle::crypto::PublicKeyError), +} + +/// A git remote URL. +/// +/// `rad:///[]` +/// +/// Eg. `rad://zUBDc1UdoEzbpaGcNXqauQkERJ8r` without the public key, +/// and `rad://zUBDc1UdoEzbpaGcNXqauQkERJ8r/zCQTxdZGCzQXWBV3XbY3fgkHM3gfkLGyYMd2nL5R2MxQv` with. +/// +#[derive(Debug)] +pub struct Url { + pub id: radicle::identity::Id, + pub public_key: Option, +} + +impl FromStr for Url { + type Err = UrlError; + + fn from_str(s: &str) -> Result { + let url: radicle::git::Url = s.as_bytes().try_into()?; + Url::try_from(url) + } +} + +impl TryFrom for Url { + type Error = UrlError; + + fn try_from(url: radicle::git::Url) -> Result { + if url.scheme != radicle::git::url::Scheme::Radicle { + return Err(Self::Error::UnsupportedScheme(url)); + } + + let id: radicle::identity::Id = url + .host + .as_ref() + .ok_or_else(|| Self::Error::MissingId(url.clone()))? + .parse() + .map_err(|e| Self::Error::InvalidId(url.clone(), e))?; + + let public_key: Option = if url.path.is_empty() { + Ok(None) + } else { + let path = url.path.to_string(); + + path.strip_prefix('/') + .unwrap_or(&path) + .parse() + .map(Some) + .map_err(|e| Self::Error::InvalidKey(url.clone(), e)) + }?; + + Ok(Url { id, public_key }) + } +} + +/// Run the radicle remote helper using the given profile. +pub fn run(profile: radicle::Profile) -> Result<(), Box> { + // `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::>(); + + match args.as_slice() { + [url] => url.parse(), + [_, url] => url.parse(), + + _ => { + return Err(Error::InvalidArguments(args).into()); + } + } + }?; + // Default to profile key. + let public_key = url + .public_key + .unwrap_or_else(|| *profile.signer.public_key()); + + let proj = profile.storage.repository(url.id)?; + 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::>(); + 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. + if *service == GIT_RECEIVE_PACK { + if profile.signer.public_key() != &public_key { + return Err(Error::KeyMismatch(public_key).into()); + } + if !ssh::agent::connect()? + .request_identities::()? + .contains(&public_key) + { + return Err(Error::KeyNotRegistered(public_key).into()); + } + } + 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", public_key.to_string()) + .stdout(process::Stdio::inherit()) + .stderr(process::Stdio::inherit()) + .stdin(process::Stdio::inherit()) + .spawn()?; + + if child.wait()?.success() { + if *service == GIT_RECEIVE_PACK { + profile.storage.sign_refs(&proj, &profile.signer)?; + // 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. + if let Ok(conn) = profile.node() { + conn.announce_refs(&url.id)?; + } + } + } + } + // An empty line means end of input. + [] => { + break; + } + _ => { + return Err(Error::InvalidCommand(line).into()); + } + } + } + + Ok(()) +} diff --git a/radicle-ssh/src/agent/client.rs b/radicle-ssh/src/agent/client.rs index 14c27fe5..73196b4a 100644 --- a/radicle-ssh/src/agent/client.rs +++ b/radicle-ssh/src/agent/client.rs @@ -210,7 +210,6 @@ impl AgentClient { self.buf.write_len(); self.stream.read_response(&mut self.buf)?; - debug!("identities: {:?}", &self.buf[..]); let mut keys = Vec::new(); if self.buf[0] == msg::IDENTITIES_ANSWER { diff --git a/radicle/src/node.rs b/radicle/src/node.rs index 0cebc2b3..c34fd20b 100644 --- a/radicle/src/node.rs +++ b/radicle/src/node.rs @@ -15,8 +15,8 @@ pub const DEFAULT_SOCKET_NAME: &str = "radicle.sock"; #[derive(thiserror::Error, Debug)] pub enum Error { - #[error("i/o error: {0}")] - Io(#[from] io::Error), + #[error("failed to connecto to node: {0}")] + Connect(#[from] io::Error), } pub trait Handle { @@ -40,8 +40,8 @@ pub struct Connection { } impl Connection { - pub fn connect>(path: P) -> Result { - let stream = UnixStream::connect(path)?; + pub fn connect>(path: P) -> Result { + let stream = UnixStream::connect(path).map_err(Error::Connect)?; Ok(Self { stream }) } diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs index 3f8feee9..24944984 100644 --- a/radicle/src/profile.rs +++ b/radicle/src/profile.rs @@ -75,7 +75,7 @@ impl Profile { } /// Return a connection to the locally running node. - pub fn node(&self) -> Result { + pub fn node(&self) -> Result { node::Connection::connect(self.socket()) } diff --git a/radicle/src/rad.rs b/radicle/src/rad.rs index d28f4076..315016b5 100644 --- a/radicle/src/rad.rs +++ b/radicle/src/rad.rs @@ -48,6 +48,7 @@ pub fn init( signer: G, storage: &S, ) -> Result<(Id, SignedRefs), InitError> { + // TODO: Better error when project id already exists in storage, but remote doesn't. let pk = signer.public_key(); let delegate = identity::Delegate { // TODO: Use actual user name. diff --git a/radicle/src/storage/git.rs b/radicle/src/storage/git.rs index b98f3080..7928ec07 100644 --- a/radicle/src/storage/git.rs +++ b/radicle/src/storage/git.rs @@ -130,6 +130,7 @@ impl WriteStorage for Storage { } impl Storage { + // TODO: Return a better error when not found. pub fn open>(path: P) -> Result { let path = path.as_ref().to_path_buf();