diff --git a/radicle-remote-helper/src/lib.rs b/radicle-remote-helper/src/lib.rs index 838a2915..7f460969 100644 --- a/radicle-remote-helper/src/lib.rs +++ b/radicle-remote-helper/src/lib.rs @@ -7,7 +7,7 @@ use thiserror::Error; use radicle::crypto::{PublicKey, Signer}; use radicle::node::Handle; use radicle::ssh; -use radicle::storage::git::transport::{Url, UrlError}; +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. @@ -52,11 +52,11 @@ pub fn run(profile: radicle::Profile) -> Result<(), Box Result<(), Box()? - .contains(&public_key) + .contains(&namespace) { - return Err(Error::KeyNotRegistered(public_key).into()); + return Err(Error::KeyNotRegistered(namespace).into()); } } println!(); // Empty line signifies connection is established. @@ -99,7 +99,7 @@ pub fn run(profile: radicle::Profile) -> Result<(), Box Result<(), Box>>> = Lazy::new(Default::default); - -/// The stream associated with a repository. -type Stream = Box; - -/// Git transport protocol over an I/O stream. -#[derive(Clone)] -pub struct Smart { - /// The underlying active streams, keyed by repository identifier. - streams: Arc>>, -} - -impl Smart { - /// Get access to the radicle smart transport protocol. - /// The returned object has mutable access to the underlying stream map, and is safe to clone. - pub fn singleton() -> Self { - Self { - streams: STREAMS.clone(), - } - } - - /// Take a stream from the map. - /// This makes the stream unavailable until it is re-inserted. - pub fn take(&self, id: &Id) -> Option { - #[allow(clippy::unwrap_used)] - self.streams.lock().unwrap().remove(id) - } - - pub fn insert(&self, id: Id, stream: Stream) { - #[allow(clippy::unwrap_used)] - self.streams.lock().unwrap().insert(id, stream); - } -} - -impl git2::transport::SmartSubtransport for Smart { - /// Run a git service on this transport. - /// - /// Based on the URL, which must be of the form `rad://zP1GztjSdYNHK7jpdrXbaJ6Ki2Ke`, - /// we retrieve an underlying stream and return it. - /// - /// We only support the upload-pack service, since only fetches are authorized by the - /// remote. - fn action( - &self, - url: &str, - action: git2::transport::Service, - ) -> Result, git2::Error> { - let url = Url::from_str(url).map_err(|e| git2::Error::from_str(e.to_string().as_str()))?; - - if let Some(stream) = self.take(&url.id) { - match action { - git2::transport::Service::UploadPackLs => {} - git2::transport::Service::UploadPack => {} - git2::transport::Service::ReceivePack => { - return Err(git2::Error::from_str( - "git-receive-pack is not supported with the custom transport", - )); - } - git2::transport::Service::ReceivePackLs => { - return Err(git2::Error::from_str( - "git-receive-pack is not supported with the custom transport", - )); - } - } - Ok(stream) - } else { - Err(git2::Error::from_str(&format!( - "repository {} does not have an associated stream", - url.id - ))) - } - } - - fn close(&self) -> Result<(), git2::Error> { - Ok(()) - } -} - -/// Register the radicle transport with `git`. -/// -/// Returns an error if called more than once. -/// -pub fn register() -> Result<(), git2::Error> { - static REGISTERED: atomic::AtomicBool = atomic::AtomicBool::new(false); - - // Registration is not thread-safe, so make sure we prevent re-entrancy. - if !REGISTERED.swap(true, atomic::Ordering::SeqCst) { - unsafe { - let prefix = git::url::Scheme::Radicle.to_string(); - git2::transport::register(&prefix, move |remote| { - git2::transport::Transport::smart(remote, false, Smart::singleton()) - }) - } - } else { - Err(git2::Error::from_str( - "custom git transport is already registered", - )) - } -} +pub mod local; +pub mod remote; diff --git a/radicle/src/storage/git/transport/local.rs b/radicle/src/storage/git/transport/local.rs new file mode 100644 index 00000000..e8e9ab2b --- /dev/null +++ b/radicle/src/storage/git/transport/local.rs @@ -0,0 +1,2 @@ +pub mod url; +pub use url::{Url, UrlError}; diff --git a/radicle/src/storage/git/transport/local/url.rs b/radicle/src/storage/git/transport/local/url.rs new file mode 100644 index 00000000..4e86e511 --- /dev/null +++ b/radicle/src/storage/git/transport/local/url.rs @@ -0,0 +1,106 @@ +//! Git local transport URLs. +use std::str::FromStr; + +use thiserror::Error; + +use crate::{ + crypto, + identity::{Id, IdError}, +}; + +/// Repository namespace. +type Namespace = crypto::PublicKey; + +#[derive(Debug, Error)] +pub enum UrlError { + /// Invalid format. + #[error("invalid url format: expected `rad://[/]`")] + InvalidFormat, + /// Unsupported URL scheme. + #[error("unsupported scheme: expected `rad://`")] + UnsupportedScheme, + /// Invalid repository identifier. + #[error("repo: {0}")] + InvalidRepository(#[source] IdError), + /// Invalid namespace. + #[error("namespace: {0}")] + InvalidNamespace(#[source] crypto::PublicKeyError), +} + +/// A git local transport URL. +/// +/// * Used to content-address a repository, eg. when sharing projects. +/// * Used as a remore url in a git working copy. +/// +/// `rad://[/]` +/// +#[derive(Debug)] +pub struct Url { + /// Repository identifier. + pub repo: Id, + /// Repository sub-tree. + pub namespace: Option, +} + +impl Url { + /// URL scheme. + pub const SCHEME: &str = "rad"; +} + +impl FromStr for Url { + type Err = UrlError; + + fn from_str(s: &str) -> Result { + let rest = s + .strip_prefix("rad://") + .ok_or(UrlError::UnsupportedScheme)?; + let components = rest.split('/').collect::>(); + + let (resource, namespace) = match components.as_slice() { + [resource] => (resource, None), + [resource, namespace] => (resource, Some(namespace)), + _ => return Err(UrlError::InvalidFormat), + }; + + let resource = Id::from_str(resource).map_err(UrlError::InvalidRepository)?; + let namespace = namespace + .map(|pk| Namespace::from_str(pk).map_err(UrlError::InvalidNamespace)) + .transpose()?; + + Ok(Url { + repo: resource, + namespace, + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_url_parse() { + let repo = Id::from_str("z2w8RArM3gaBXZxXhQUswE3hhLcss").unwrap(); + let namespace = + Namespace::from_str("z6Mkifeb5NPS6j7JP72kEQEeuqMTpCAVcHsJi1C86jGTzHRi").unwrap(); + + let url = format!("rad://{repo}"); + let url = Url::from_str(&url).unwrap(); + + assert_eq!(url.repo, repo); + assert_eq!(url.namespace, None); + + let url = format!("rad://{repo}/{namespace}"); + let url = Url::from_str(&url).unwrap(); + + assert_eq!(url.repo, repo); + assert_eq!(url.namespace, Some(namespace)); + + assert!(format!("heartwood://{repo}").parse::().is_err()); + assert!(format!("git://{repo}").parse::().is_err()); + assert!(format!("rad://{namespace}").parse::().is_err()); + assert!(format!("rad://{repo}/{namespace}/fnord") + .parse::() + .is_err()); + } +} diff --git a/radicle/src/storage/git/transport/remote.rs b/radicle/src/storage/git/transport/remote.rs new file mode 100644 index 00000000..08ab71b4 --- /dev/null +++ b/radicle/src/storage/git/transport/remote.rs @@ -0,0 +1,131 @@ +//! Git sub-transport used for fetching radicle data. +//! +//! To have control over the communication, and to allow git streams to be multiplexed over +//! existing TCP connections, we implement the [`git2::transport::SmartSubtransport`] trait. +//! +//! We choose `rad` as the URL scheme for this custom transport, and include only the identity +//! of the repository we're looking to fetch, eg. `rad://zP1GztjSdYNHK7jpdrXbaJ6Ki2Ke`, since +//! we expect a connection to a host to already be established. +//! +//! We then maintain a map from identifier to stream, for all active streams, ie. streams that +//! are associated with an underlying TCP connection. When a URL is requested, we lookup +//! the stream and return it to the [`git2`] smart-protocol implementation, so that it can carry +//! out the git smart protocol. +//! +//! This module is meant to be used by first registering our transport with [`register`] and then +//! adding or removing streams through [`Smart`], which can be obtained via [`Smart::singleton`]. +pub mod url; + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::atomic; +use std::sync::{Arc, Mutex}; + +use git2::transport::SmartSubtransportStream; +use once_cell::sync::Lazy; + +use crate::identity::Id; + +pub use url::{Url, UrlError}; + +/// The map of git smart sub-transport streams. We keep a global map because we have +/// no control over how [`git2::transport::register`] instantiates our [`Smart`] transport +/// or its underlying streams. +static STREAMS: Lazy>>> = Lazy::new(Default::default); + +/// The stream associated with a repository. +type Stream = Box; + +/// Git transport protocol over an I/O stream. +#[derive(Clone)] +pub struct Smart { + /// The underlying active streams, keyed by repository identifier. + streams: Arc>>, +} + +impl Smart { + /// Get access to the radicle smart transport protocol. + /// The returned object has mutable access to the underlying stream map, and is safe to clone. + pub fn singleton() -> Self { + Self { + streams: STREAMS.clone(), + } + } + + /// Take a stream from the map. + /// This makes the stream unavailable until it is re-inserted. + pub fn take(&self, id: &Id) -> Option { + #[allow(clippy::unwrap_used)] + self.streams.lock().unwrap().remove(id) + } + + pub fn insert(&self, id: Id, stream: Stream) { + #[allow(clippy::unwrap_used)] + self.streams.lock().unwrap().insert(id, stream); + } +} + +impl git2::transport::SmartSubtransport for Smart { + /// Run a git service on this transport. + /// + /// Based on the URL, which must be of the form `rad://zP1GztjSdYNHK7jpdrXbaJ6Ki2Ke`, + /// we retrieve an underlying stream and return it. + /// + /// We only support the upload-pack service, since only fetches are authorized by the + /// remote. + fn action( + &self, + url: &str, + action: git2::transport::Service, + ) -> Result, git2::Error> { + let url = Url::from_str(url).map_err(|e| git2::Error::from_str(e.to_string().as_str()))?; + + if let Some(stream) = self.take(&url.repo) { + match action { + git2::transport::Service::UploadPackLs => {} + git2::transport::Service::UploadPack => {} + git2::transport::Service::ReceivePack => { + return Err(git2::Error::from_str( + "git-receive-pack is not supported with the custom transport", + )); + } + git2::transport::Service::ReceivePackLs => { + return Err(git2::Error::from_str( + "git-receive-pack is not supported with the custom transport", + )); + } + } + Ok(stream) + } else { + Err(git2::Error::from_str(&format!( + "repository {} does not have an associated stream", + url.repo + ))) + } + } + + fn close(&self) -> Result<(), git2::Error> { + Ok(()) + } +} + +/// Register the radicle transport with `git`. +/// +/// Returns an error if called more than once. +/// +pub fn register() -> Result<(), git2::Error> { + static REGISTERED: atomic::AtomicBool = atomic::AtomicBool::new(false); + + // Registration is not thread-safe, so make sure we prevent re-entrancy. + if !REGISTERED.swap(true, atomic::Ordering::SeqCst) { + unsafe { + git2::transport::register(Url::SCHEME, move |remote| { + git2::transport::Transport::smart(remote, false, Smart::singleton()) + }) + } + } else { + Err(git2::Error::from_str( + "custom git transport is already registered", + )) + } +} diff --git a/radicle/src/storage/git/transport/remote/url.rs b/radicle/src/storage/git/transport/remote/url.rs new file mode 100644 index 00000000..d6982c1a --- /dev/null +++ b/radicle/src/storage/git/transport/remote/url.rs @@ -0,0 +1,115 @@ +//! Git remote transport URLs. +use std::str::FromStr; + +use thiserror::Error; + +use crate::{ + crypto, + identity::{Id, IdError}, +}; + +type NodeId = crypto::PublicKey; +type Namespace = crypto::PublicKey; + +#[derive(Debug, Error)] +pub enum UrlError { + /// Invalid format. + #[error("invalid url format: expected `heartwood:///[/]`")] + InvalidFormat, + /// Unsupported URL scheme. + #[error("unsupported scheme: expected `heartwood://`")] + UnsupportedScheme, + /// Invalid node identifier. + #[error("node: {0}")] + InvalidNode(#[source] crypto::PublicKeyError), + /// Invalid repository identifier. + #[error("repo: {0}")] + InvalidRepository(#[source] IdError), + /// Invalid namespace. + #[error("namespace: {0}")] + InvalidNamespace(#[source] crypto::PublicKeyError), +} + +/// A git remote transport URL. +/// +/// `heartwood:///[/]` +/// +#[derive(Debug)] +pub struct Url { + /// Node identifier. + pub node: NodeId, + /// Repository identifier. + pub repo: Id, + /// Repository sub-tree. + pub namespace: Option, +} + +impl Url { + /// URL scheme. + pub const SCHEME: &str = "heartwood"; +} + +impl FromStr for Url { + type Err = UrlError; + + fn from_str(s: &str) -> Result { + let rest = s + .strip_prefix("heartwood://") + .ok_or(UrlError::UnsupportedScheme)?; + let components = rest.split('/').collect::>(); + + let (node, resource, namespace) = match components.as_slice() { + [node, resource] => (node, resource, None), + [node, resource, namespace] => (node, resource, Some(namespace)), + _ => return Err(UrlError::InvalidFormat), + }; + + let node = NodeId::from_str(node).map_err(UrlError::InvalidNode)?; + let resource = Id::from_str(resource).map_err(UrlError::InvalidRepository)?; + let namespace = namespace + .map(|pk| Namespace::from_str(pk).map_err(UrlError::InvalidNamespace)) + .transpose()?; + + Ok(Url { + node, + repo: resource, + namespace, + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_url_parse() { + let node = NodeId::from_str("z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK").unwrap(); + let repo = Id::from_str("z2w8RArM3gaBXZxXhQUswE3hhLcss").unwrap(); + let namespace = + Namespace::from_str("z6Mkifeb5NPS6j7JP72kEQEeuqMTpCAVcHsJi1C86jGTzHRi").unwrap(); + + let url = format!("heartwood://{node}/{repo}"); + let url = Url::from_str(&url).unwrap(); + + assert_eq!(url.node, node); + assert_eq!(url.repo, repo); + assert_eq!(url.namespace, None); + + let url = format!("heartwood://{node}/{repo}/{namespace}"); + let url = Url::from_str(&url).unwrap(); + + assert_eq!(url.node, node); + assert_eq!(url.repo, repo); + assert_eq!(url.namespace, Some(namespace)); + + assert!(format!("heartwood://{node}").parse::().is_err()); + assert!(format!("rad://{node}").parse::().is_err()); + assert!(format!("heartwood://{node}/{namespace}") + .parse::() + .is_err()); + assert!(format!("heartwood://{node}/{repo}/{namespace}/fnord") + .parse::() + .is_err()); + } +} diff --git a/radicle/src/storage/git/transport/url.rs b/radicle/src/storage/git/transport/url.rs deleted file mode 100644 index 08ee70f7..00000000 --- a/radicle/src/storage/git/transport/url.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Git transport URLs. -use std::str::FromStr; - -use thiserror::Error; - -use crate::crypto::PublicKey; -use crate::{crypto, git, identity}; - -#[derive(Debug, Error)] -pub enum UrlError { - /// Failed to parse. - #[error(transparent)] - Parse(#[from] git::url::parse::Error), - /// Unsupported URL scheme. - #[error("{0}: unsupported scheme: expected `rad://`")] - UnsupportedScheme(git::Url), - /// Missing host. - #[error("{0}: missing id")] - MissingId(git::Url), - /// Invalid remote repository identifier. - #[error("{0}: id: {1}")] - InvalidId(git::Url, identity::IdError), - /// Invalid public key. - #[error("{0}: key: {1}")] - InvalidKey(git::Url, 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: identity::Id, - pub public_key: Option, -} - -impl FromStr for Url { - type Err = UrlError; - - fn from_str(s: &str) -> Result { - let url: git::Url = s.as_bytes().try_into()?; - Url::try_from(url) - } -} - -impl TryFrom for Url { - type Error = UrlError; - - fn try_from(url: git::Url) -> Result { - if url.scheme != git::url::Scheme::Radicle { - return Err(Self::Error::UnsupportedScheme(url)); - } - - let id: 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 }) - } -}