Re-work git transports

The changes in this commit are all intertwined and it was hard to
split them into smaller commits:

* Use custom transports everywhere

Instead of `file://` and instead of using a child process for fetching
and pushing; we go through our custom transports, which use the `rad://`
and `heartwood://` schemes.

* Introduce a 'mock' remote transport

To test the remote transport without needing to spawn TCP streams,
we add a mock transport that works between storages on the file
system.

* Get rid of node's `git-url`

Instead of git urls, nodes simply use their node-ids to replicate.
This commit is contained in:
Alexis Sellier 2022-10-25 16:46:04 +02:00
parent 3407c6c017
commit 2a1154ef87
No known key found for this signature in database
22 changed files with 483 additions and 396 deletions

View File

@ -5,7 +5,7 @@ use anyhow::Context as _;
use radicle_node::logger;
use radicle_node::prelude::Address;
use radicle_node::{client, control, git, service};
use radicle_node::{client, control, service};
type Reactor = nakamoto_net_poll::Reactor<net::TcpStream>;
@ -13,7 +13,6 @@ type Reactor = nakamoto_net_poll::Reactor<net::TcpStream>;
struct Options {
connect: Vec<Address>,
listen: Vec<net::SocketAddr>,
git_url: git::Url,
}
impl Options {
@ -22,7 +21,6 @@ impl Options {
let mut parser = lexopt::Parser::from_env();
let mut connect = Vec::new();
let mut listen = Vec::new();
let mut git_url = None;
while let Some(arg) = parser.next()? {
match arg {
@ -34,11 +32,6 @@ impl Options {
let addr = parser.value()?.parse()?;
listen.push(addr);
}
Long("git-url") => {
let url = git::Url::from_bytes(parser.value()?.into_string()?.as_bytes())
.map_err(|e| format!("invalid URL: {}", e))?;
git_url = Some(url);
}
Long("help") => {
println!("usage: radicle-node [--connect <addr>]..");
process::exit(0);
@ -46,11 +39,7 @@ impl Options {
_ => return Err(arg.unexpected()),
}
}
Ok(Self {
connect,
listen,
git_url: git_url.ok_or("a Git URL must be specified with `--git-url`")?,
})
Ok(Self { connect, listen })
}
}
@ -65,7 +54,6 @@ fn main() -> anyhow::Result<()> {
let config = client::Config {
service: service::Config {
connect: options.connect,
git_url: options.git_url,
..service::Config::default()
},
listen: options.listen,

View File

@ -19,7 +19,7 @@ use nakamoto_net as nakamoto;
use nakamoto_net::Link;
use nonempty::NonEmpty;
use radicle::node::Features;
use radicle::storage::ReadStorage;
use radicle::storage::{Namespaces, ReadStorage};
use crate::address;
use crate::address::AddressBook;
@ -27,7 +27,6 @@ use crate::clock::{RefClock, Timestamp};
use crate::crypto;
use crate::crypto::{Signer, Verified};
use crate::git;
use crate::git::Url;
use crate::identity::{Doc, Id};
use crate::node;
use crate::service::config::ProjectTracking;
@ -75,7 +74,7 @@ pub type NodeId = crypto::PublicKey;
#[derive(Debug, Clone)]
pub enum Event {
RefsFetched {
from: Url,
from: NodeId,
project: Id,
updated: Vec<RefUpdate>,
},
@ -447,15 +446,8 @@ where
.ok();
// TODO: Limit the number of seeds we fetch from? Randomize?
for (_, peer) in seeds {
match repo.fetch(&Url {
scheme: git::url::Scheme::Git,
host: Some(peer.addr.ip().to_string()),
port: Some(peer.addr.port()),
// TODO: Fix upstream crate so that it adds a `/` when needed.
path: format!("/{}", id).into(),
..Url::default()
}) {
for (peer_id, peer) in seeds {
match repo.fetch(&peer_id, Namespaces::default()) {
Ok(updated) => {
results_
.send(FetchResult::Fetched {
@ -609,7 +601,6 @@ where
pub fn handle_announcement(
&mut self,
session: &NodeId,
git: &git::Url,
announcement: &Announcement,
) -> Result<bool, peer::SessionError> {
if !announcement.verify() {
@ -635,7 +626,7 @@ where
return Ok(false);
}
if let Err(err) = self.process_inventory(&message.inventory, *node, git) {
if let Err(err) = self.process_inventory(&message.inventory, *node) {
error!("Error processing inventory from {}: {}", node, err);
if let Error::Fetch(storage::FetchError::Verify(err)) = err {
@ -665,11 +656,25 @@ where
// Refs are only supposed to be relayed by peers who are tracking
// the resource. Therefore, it's safe to fetch from the remote
// peer, even though it isn't the announcer.
let updated = self.storage.fetch(message.id, git).unwrap();
let updated = match self
.storage
.repository(message.id)
.map_err(storage::FetchError::from)
.and_then(|mut r| r.fetch(session, Namespaces::default()))
{
Ok(updated) => updated,
Err(err) => {
error!(
"Error fetching repository {} from {}: {}",
message.id, session, err
);
return Ok(false);
}
};
let is_updated = !updated.is_empty();
self.reactor.event(Event::RefsFetched {
from: git.clone(),
from: *session,
project: message.id,
updated,
});
@ -748,15 +753,7 @@ where
debug!("Received {:?} from {}", &message, peer.ip());
match (&mut peer.state, message) {
(
SessionState::Initial,
Message::Initialize {
id,
version,
addrs,
git,
},
) => {
(SessionState::Initial, Message::Initialize { id, version, addrs }) => {
if version != PROTOCOL_VERSION {
return Err(SessionError::WrongVersion(version));
}
@ -780,7 +777,6 @@ where
id,
since: self.clock.local_time(),
addrs,
git,
ping: Default::default(),
};
}
@ -792,12 +788,11 @@ where
return Err(SessionError::Misbehavior);
}
// Process a peer announcement.
(SessionState::Negotiated { id, git, .. }, Message::Announcement(ann)) => {
let git = git.clone();
(SessionState::Negotiated { id, .. }, Message::Announcement(ann)) => {
let id = *id;
// Returning true here means that the message should be relayed.
if self.handle_announcement(&id, &git, &ann)? {
if self.handle_announcement(&id, &ann)? {
self.gossip.received(ann.clone(), ann.message.timestamp());
// Choose peers we should relay this message to.
@ -857,12 +852,7 @@ where
}
/// Process a peer inventory announcement by updating our routing table.
fn process_inventory(
&mut self,
inventory: &Inventory,
from: NodeId,
remote: &Url,
) -> Result<(), Error> {
fn process_inventory(&mut self, inventory: &Inventory, from: NodeId) -> Result<(), Error> {
for proj_id in inventory {
// TODO: Fire an event on routing update.
// FIXME: The timestamp we insert should be the announcement timestamp.
@ -871,7 +861,7 @@ where
.insert(*proj_id, from, self.clock.timestamp())?
&& self.config.is_tracking(proj_id)
{
self.storage.fetch(*proj_id, remote)?;
log::info!("Routing table updated for {} with seed {}", proj_id, from);
}
}
Ok(())
@ -1198,7 +1188,6 @@ mod gossip {
signer: &G,
config: &Config,
) -> [Message; 4] {
let git = config.git_url.clone();
let inventory = match storage.inventory() {
Ok(i) => i,
Err(e) => {
@ -1210,7 +1199,7 @@ mod gossip {
};
[
Message::init(*signer.public_key(), config.listen.clone(), git),
Message::init(*signer.public_key(), config.listen.clone()),
Message::node(gossip::node(timestamp, config), signer),
Message::inventory(gossip::inventory(timestamp, inventory), signer),
Message::subscribe(config.filter(), timestamp, Timestamp::MAX),

View File

@ -1,6 +1,4 @@
use crate::collections::HashSet;
use crate::git;
use crate::git::Url;
use crate::identity::{Id, PublicKey};
use crate::service::filter::Filter;
use crate::service::message::Address;
@ -58,8 +56,6 @@ pub struct Config {
pub relay: bool,
/// List of addresses to listen on for protocol connections.
pub listen: Vec<Address>,
/// Our Git URL for fetching projects.
pub git_url: Url,
}
impl Default for Config {
@ -71,11 +67,6 @@ impl Default for Config {
remote_tracking: RemoteTracking::default(),
relay: true,
listen: vec![],
git_url: Url {
scheme: git::url::Scheme::File,
path: "/dev/null".to_owned().into(),
..Url::default()
},
}
}
}

View File

@ -4,7 +4,6 @@ use std::{fmt, io, mem, net};
use thiserror::Error;
use crate::crypto;
use crate::git;
use crate::identity::Id;
use crate::node;
use crate::service::filter::Filter;
@ -297,7 +296,6 @@ pub enum Message {
id: NodeId,
version: u32,
addrs: Vec<Address>,
git: git::Url,
},
/// Subscribe to gossip messages matching the filter and time range.
@ -321,11 +319,10 @@ pub enum Message {
}
impl Message {
pub fn init(id: NodeId, addrs: Vec<Address>, git: git::Url) -> Self {
pub fn init(id: NodeId, addrs: Vec<Address>) -> Self {
Self::Initialize {
id,
version: PROTOCOL_VERSION,
git,
addrs,
}
}

View File

@ -28,7 +28,6 @@ pub enum SessionState {
since: LocalTime,
/// Addresses this peer is reachable on.
addrs: Vec<Address>,
git: Url,
ping: PingState,
},
/// When a peer is disconnected.

View File

@ -9,8 +9,6 @@ use log::*;
use crate::address;
use crate::clock::{RefClock, Timestamp};
use crate::crypto::Signer;
use crate::git;
use crate::git::Url;
use crate::identity::Id;
use crate::node;
use crate::prelude::NodeId;
@ -19,7 +17,8 @@ use crate::service::config::*;
use crate::service::message::*;
use crate::service::reactor::Io;
use crate::service::*;
use crate::storage::WriteStorage;
use crate::storage::git::transport::remote;
use crate::storage::{RemoteId, WriteStorage};
use crate::test::arbitrary;
use crate::test::signer::MockSigner;
use crate::test::simulator;
@ -73,26 +72,10 @@ where
S: WriteStorage + 'static,
{
pub fn new(name: &'static str, ip: impl Into<net::IpAddr>, storage: S) -> Self {
let git_url = Url {
scheme: git::url::Scheme::File,
path: storage.path().to_string_lossy().to_string().into(),
..git::Url::default()
};
let mut rng = fastrand::Rng::new();
let signer = MockSigner::new(&mut rng);
Self::config(
name,
Config {
git_url,
..Config::default()
},
ip,
storage,
signer,
rng,
)
Self::config(name, Config::default(), ip, storage, signer, rng)
}
}
@ -145,8 +128,12 @@ where
self.service.clock().timestamp()
}
pub fn git_url(&self) -> Url {
self.config().git_url.clone()
pub fn git_url(&self, repo: Id, namespace: Option<RemoteId>) -> remote::Url {
remote::Url {
node: self.node_id(),
repo,
namespace,
}
}
pub fn node_id(&self) -> NodeId {
@ -197,14 +184,12 @@ where
pub fn connect_from(&mut self, peer: &Self) {
let remote = simulator::Peer::<S, G>::addr(peer);
let local = net::SocketAddr::new(self.ip, self.rng.u16(..));
let git = format!("file:///{}.git", remote.ip());
let git = Url::from_bytes(git.as_bytes()).unwrap();
self.initialize();
self.service.connected(remote, &local, Link::Inbound);
self.receive(
&remote,
Message::init(peer.node_id(), vec![Address::from(remote)], git),
Message::init(peer.node_id(), vec![Address::from(remote)]),
);
let mut msgs = self.messages(&remote);
@ -244,10 +229,9 @@ where
})
.expect("`inventory-announcement` is sent");
let git = peer.config().git_url.clone();
self.receive(
&remote,
Message::init(peer.node_id(), peer.config().listen.clone(), git),
Message::init(peer.node_id(), peer.config().listen.clone()),
);
}

View File

@ -13,6 +13,7 @@ use crate::service::peer::*;
use crate::service::reactor::Io;
use crate::service::ServiceState as _;
use crate::service::*;
use crate::storage::git::transport::{local, remote};
use crate::storage::git::Storage;
use crate::storage::ReadStorage;
use crate::test::arbitrary;
@ -223,16 +224,6 @@ fn test_inventory_sync() {
let seeds = alice.routing().get(proj).unwrap();
assert!(seeds.contains(&bob.node_id()));
}
let a = alice
.storage()
.inventory()
.unwrap()
.into_iter()
.collect::<HashSet<_>>();
let b = projs.into_iter().collect::<HashSet<_>>();
assert_eq!(a, b);
}
#[test]
@ -441,22 +432,7 @@ fn test_refs_announcement_relay() {
let signer = MockSigner::new(&mut rng);
let storage = fixtures::storage(tmp.path().join("bob"), &signer).unwrap();
Peer::config(
"bob",
Config {
git_url: git::Url {
scheme: git::url::Scheme::File,
path: storage.path().to_string_lossy().to_string().into(),
..git::Url::default()
},
..Config::default()
},
[9, 9, 9, 9],
storage,
signer,
rng,
)
Peer::config("bob", Config::default(), [9, 9, 9, 9], storage, signer, rng)
};
let bob_inv = bob.inventory().unwrap();
@ -687,6 +663,11 @@ fn test_push_and_pull() {
let storage_eve = Storage::open(tempdir.path().join("eve").join("storage")).unwrap();
let mut eve = Peer::new("eve", [9, 9, 9, 9], storage_eve);
remote::mock::register(&alice.node_id(), alice.storage().path());
remote::mock::register(&eve.node_id(), eve.storage().path());
remote::mock::register(&bob.node_id(), bob.storage().path());
local::register(alice.storage().clone());
// Alice and Bob connect to Eve.
alice.command(service::Command::Connect(eve.addr()));
bob.command(service::Command::Connect(eve.addr()));
@ -744,7 +725,7 @@ fn test_push_and_pull() {
assert_matches!(
sim.events(&bob.ip).next(),
Some(service::Event::RefsFetched { from, .. })
if from == eve.git_url(),
if from == eve.node_id(),
"Bob fetched from Eve"
);
}

View File

@ -348,16 +348,6 @@ impl Decode for String {
}
}
impl Decode for git::Url {
fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, Error> {
let url = String::decode(reader)?;
let url = Self::from_bytes(url.as_bytes())
.map_err(|error| Error::InvalidGitUrl { url, error })?;
Ok(url)
}
}
impl Decode for Id {
fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, Error> {
let oid: git::Oid = Decode::decode(reader)?;
@ -648,18 +638,6 @@ mod tests {
);
}
#[test]
fn test_git_url() {
let url = git::Url {
scheme: git::url::Scheme::Https,
path: "/git".to_owned().into(),
host: Some("seed.radicle.xyz".to_owned()),
port: Some(8888),
..git::Url::default()
};
assert_eq!(deserialize::<git::Url>(&serialize(&url)).unwrap(), url);
}
#[test]
fn test_filter_invalid() {
let b = bloomy::BloomFilter::with_size(filter::FILTER_SIZE_M / 3);

View File

@ -2,7 +2,6 @@ use std::{io, mem, net};
use byteorder::{NetworkEndian, ReadBytesExt};
use crate::git;
use crate::prelude::*;
use crate::service::message::*;
use crate::wire;
@ -169,16 +168,10 @@ impl wire::Encode for Message {
let mut n = self.type_id().encode(writer)?;
match self {
Self::Initialize {
id,
version,
addrs,
git,
} => {
Self::Initialize { id, version, addrs } => {
n += id.encode(writer)?;
n += version.encode(writer)?;
n += addrs.as_slice().encode(writer)?;
n += git.encode(writer)?;
}
Self::Subscribe(Subscribe {
filter,
@ -226,14 +219,8 @@ impl wire::Decode for Message {
let id = NodeId::decode(reader)?;
let version = u32::decode(reader)?;
let addrs = Vec::<Address>::decode(reader)?;
let git = git::Url::decode(reader)?;
Ok(Self::Initialize {
id,
version,
addrs,
git,
})
Ok(Self::Initialize { id, version, addrs })
}
Ok(MessageType::Subscribe) => {
let filter = Filter::decode(reader)?;

View File

@ -22,8 +22,8 @@ pub use git_ref_format::{
lit, name, qualified, refname, Component, Namespaced, Qualified, RefStr, RefString,
};
pub use git_url as url;
pub use git_url::Url;
pub use radicle_git_ext as ext;
pub use storage::git::transport::local::Url;
pub use storage::BranchName;
/// Default port of the `git` transport protocol.
@ -265,43 +265,25 @@ pub fn configure_remote<'r>(
Ok(remote)
}
/// Fetch from the given `remote` using the provided `namespace`.
///
/// This uses [`Command`] under the hood and is the equivalent to:
///
/// `GIT_NAMESPACE=<namespace> git fetch <remote>`
pub fn fetch(repo: &git2::Repository, remote: &str, namespace: &RemoteId) -> io::Result<String> {
run(
&repo.path(),
["fetch", remote],
[("GIT_NAMESPACE", Component::from(namespace).as_str())],
)
/// Fetch from the given `remote`.
pub fn fetch(repo: &git2::Repository, remote: &str) -> Result<(), git2::Error> {
repo.find_remote(remote)?.fetch::<&str>(&[], None, None)
}
/// Push `refspecs` to the given `remote` using the provided `namespace`.
///
/// This uses [`Command`] under the hood and is the equivalent to:
///
/// `GIT_NAMESPACE=<namespace> git push <remote> [<refspecs>]`
pub fn push<Ref>(
pub fn push<'a>(
repo: &git2::Repository,
remote: &str,
namespace: &RemoteId,
refspecs: impl IntoIterator<Item = (Ref, Ref)>,
) -> io::Result<String>
where
Ref: AsRef<RefStr>,
{
let mut args = vec!["push".to_owned(), remote.to_owned()];
refspecs: impl IntoIterator<Item = (&'a Qualified<'a>, &'a Qualified<'a>)>,
) -> Result<(), git2::Error> {
let refspecs = refspecs
.into_iter()
.map(|(src, dst)| format!("{}:{}", src.as_ref().as_str(), dst.as_ref().as_str()));
args.extend(refspecs);
run(
&repo.path(),
args,
[("GIT_NAMESPACE", Component::from(namespace).as_str())],
)
.map(|(src, dst)| format!("{}:{}", src.as_str(), dst.as_str()));
repo.find_remote(remote)?
.push(refspecs.collect::<Vec<_>>().as_slice(), None)?;
Ok(())
}
/// Set the upstream of the given branch to the given remote.

View File

@ -16,6 +16,7 @@ use std::{env, io};
use crate::crypto::{KeyPair, PublicKey, SecretKey, Signature, Signer};
use crate::keystore::{Error, UnsafeKeystore};
use crate::node;
use crate::storage::git::transport;
use crate::storage::git::Storage;
#[derive(Debug)]
@ -52,6 +53,7 @@ impl Profile {
};
let storage = Storage::open(&home.join("storage"))?;
transport::local::register(storage.clone());
keystore.put(&signer.public, &signer.secret)?;
Ok(Profile {
@ -67,6 +69,8 @@ impl Profile {
let signer = UnsafeSigner { public, secret };
let storage = Storage::open(&home.join("storage"))?;
transport::local::register(storage.clone());
Ok(Profile {
home,
signer,

View File

@ -11,7 +11,8 @@ use crate::git;
use crate::identity::project::DocError;
use crate::identity::Id;
use crate::node;
use crate::storage::git::ProjectError;
use crate::storage::git::transport::{self, remote};
use crate::storage::git::{ProjectError, Storage};
use crate::storage::refs::SignedRefs;
use crate::storage::{BranchName, ReadRepository as _, RemoteId, WriteRepository as _};
use crate::{identity, storage};
@ -41,13 +42,13 @@ pub enum InitError {
}
/// Initialize a new radicle project from a git repository.
pub fn init<G: Signer, S: storage::WriteStorage>(
pub fn init<G: Signer>(
repo: &git2::Repository,
name: &str,
description: &str,
default_branch: BranchName,
signer: G,
storage: &S,
storage: &Storage,
) -> Result<(Id, SignedRefs<Verified>), InitError> {
// TODO: Better error when project id already exists in storage, but remote doesn't.
let pk = signer.public_key();
@ -65,7 +66,7 @@ pub fn init<G: Signer, S: storage::WriteStorage>(
.verified()?;
let (id, _, project) = doc.create(pk, "Initialize Radicle\n", storage)?;
let url = storage.url(&id);
let url = git::Url::from(id).with_namespace(*pk);
git::set_upstream(
repo,
@ -75,7 +76,14 @@ pub fn init<G: Signer, S: storage::WriteStorage>(
)?;
git::configure_remote(repo, &REMOTE_NAME, &url)?;
git::push(repo, &REMOTE_NAME, pk, [(&default_branch, &default_branch)])?;
git::push(
repo,
&REMOTE_NAME,
[(
&git::fmt::lit::refs_heads(&default_branch).into(),
&git::fmt::lit::refs_heads(&default_branch).into(),
)],
)?;
let signed = project.sign_refs(signer)?;
let _head = project.set_head()?;
@ -205,6 +213,8 @@ pub fn clone<P: AsRef<Path>, G: Signer, S: storage::WriteStorage, H: node::Handl
#[derive(Error, Debug)]
pub enum CloneUrlError {
#[error("missing namespace in url")]
MissingNamespace,
#[error("storage: {0}")]
Storage(#[from] storage::Error),
#[error("fetch: {0}")]
@ -216,16 +226,16 @@ pub enum CloneUrlError {
}
pub fn clone_url<P: AsRef<Path>, G: Signer, S: storage::WriteStorage>(
proj: Id,
url: &git::Url,
url: &remote::Url,
path: P,
signer: &G,
storage: &S,
) -> Result<git2::Repository, CloneUrlError> {
let mut project = storage.repository(proj)?;
let _updates = project.fetch(url)?;
let _ = fork(proj, signer, storage)?;
let working = checkout(proj, signer.public_key(), path, storage)?;
let namespace = url.namespace.ok_or(CloneUrlError::MissingNamespace)?;
let mut project = storage.repository(url.repo)?;
let _updates = project.fetch(&url.node, namespace)?;
let _ = fork(url.repo, signer, storage)?;
let working = checkout(url.repo, signer.public_key(), path, storage)?;
Ok(working)
}
@ -233,7 +243,7 @@ pub fn clone_url<P: AsRef<Path>, G: Signer, S: storage::WriteStorage>(
#[derive(Error, Debug)]
pub enum CheckoutError {
#[error("failed to fetch to working copy")]
Fetch(#[source] io::Error),
Fetch(#[source] git2::Error),
#[error("git: {0}")]
Git(#[from] git2::Error),
#[error("storage: {0}")]
@ -262,11 +272,11 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
opts.no_reinit(true).description(&project.description);
let repo = git2::Repository::init_opts(path.as_ref().join(&project.name), &opts)?;
let url = storage.url(&proj);
let url = git::Url::from(proj).with_namespace(*remote);
// Configure and fetch all refs from remote.
git::configure_remote(&repo, &REMOTE_NAME, &url)?;
git::fetch(&repo, &REMOTE_NAME, remote).map_err(CheckoutError::Fetch)?;
git::fetch(&repo, &REMOTE_NAME).map_err(CheckoutError::Fetch)?;
{
// Setup default branch.
@ -293,23 +303,18 @@ pub enum RemoteError {
#[error("git: {0}")]
Git(#[from] git2::Error),
#[error("invalid remote url: {0}")]
Url(#[from] git::url::parse::Error),
#[error("remote url doesn't have an id: `{0}`")]
MissingId(git::Url),
#[error("identifier error: {0}")]
InvalidId(#[from] identity::IdError),
Url(#[from] transport::local::UrlError),
#[error("invalid utf-8 string")]
InvalidUtf8,
}
/// Get the radicle ("rad") remote of a repository, and return the associated project id.
pub fn remote(repo: &git2::Repository) -> Result<(git2::Remote<'_>, Id), RemoteError> {
let remote = repo.find_remote(&REMOTE_NAME)?;
let url = remote.url_bytes();
let url = git::Url::from_bytes(url)?;
let path = url.path.to_string();
let id = path.split('/').last().ok_or(RemoteError::MissingId(url))?;
let id = Id::from_str(id)?;
let url = remote.url().ok_or(RemoteError::InvalidUtf8)?;
let url = git::Url::from_str(url)?;
Ok((remote, id))
Ok((remote, url.repo))
}
#[cfg(test)]
@ -319,6 +324,7 @@ mod tests {
use super::*;
use crate::git::fmt::refname;
use crate::identity::{Delegate, Did};
use crate::storage::git::transport;
use crate::storage::git::Storage;
use crate::storage::{ReadStorage, WriteStorage};
use crate::test::{fixtures, signer::MockSigner};
@ -329,8 +335,10 @@ mod tests {
let signer = MockSigner::default();
let public_key = *signer.public_key();
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
transport::local::register(storage.clone());
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
let (proj, refs) = init(
&repo,
"acme",
@ -384,9 +392,11 @@ mod tests {
let bob = MockSigner::new(&mut rng);
let bob_id = bob.public_key();
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
let (original, _) = fixtures::repository(tempdir.path().join("original"));
transport::local::register(storage.clone());
// Alice creates a project.
let (original, _) = fixtures::repository(tempdir.path().join("original"));
let (id, alice_refs) = init(
&original,
"acme",
@ -415,8 +425,10 @@ mod tests {
let signer = MockSigner::default();
let remote_id = signer.public_key();
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
let (original, _) = fixtures::repository(tempdir.path().join("original"));
transport::local::register(storage.clone());
let (original, _) = fixtures::repository(tempdir.path().join("original"));
let (id, _) = init(
&original,
"acme",

View File

@ -15,7 +15,6 @@ use crate::collections::HashMap;
use crate::crypto;
use crate::crypto::{PublicKey, Signer, Unverified, Verified};
use crate::git::ext as git_ext;
use crate::git::Url;
use crate::git::{Qualified, RefError, RefString};
use crate::identity;
use crate::identity::{Id, IdError};
@ -26,6 +25,22 @@ use self::refs::SignedRefs;
pub type BranchName = git::RefString;
pub type Inventory = Vec<Id>;
/// Describes one or more namespaces.
#[derive(Default, Debug)]
pub enum Namespaces {
/// All namespaces.
#[default]
All,
/// A single namespace, by public key.
One(PublicKey),
}
impl From<PublicKey> for Namespaces {
fn from(pk: PublicKey) -> Self {
Self::One(pk)
}
}
/// Storage error.
#[derive(Error, Debug)]
pub enum Error {
@ -217,7 +232,6 @@ impl Remote<Verified> {
pub trait ReadStorage {
fn path(&self) -> &Path;
fn url(&self, proj: &Id) -> Url;
fn get(
&self,
remote: &RemoteId,
@ -230,7 +244,6 @@ pub trait WriteStorage: ReadStorage {
type Repository: WriteRepository;
fn repository(&self, proj: Id) -> Result<Self::Repository, Error>;
fn fetch(&self, proj_id: Id, remote: &Url) -> Result<Vec<RefUpdate>, FetchError>;
}
pub trait ReadRepository {
@ -286,7 +299,11 @@ pub trait ReadRepository {
}
pub trait WriteRepository: ReadRepository {
fn fetch(&mut self, url: &Url) -> Result<Vec<RefUpdate>, FetchError>;
fn fetch(
&mut self,
node: &RemoteId,
namespaces: impl Into<Namespaces>,
) -> Result<Vec<RefUpdate>, FetchError>;
fn set_head(&self) -> Result<Oid, ProjectError>;
fn sign_refs<G: Signer>(&self, signer: G) -> Result<SignedRefs<Verified>, Error>;
fn raw(&self) -> &git2::Repository;
@ -301,10 +318,6 @@ where
self.deref().path()
}
fn url(&self, proj: &Id) -> Url {
self.deref().url(proj)
}
fn inventory(&self) -> Result<Inventory, Error> {
self.deref().inventory()
}
@ -328,10 +341,6 @@ where
fn repository(&self, proj: Id) -> Result<Self::Repository, Error> {
self.deref().repository(proj)
}
fn fetch(&self, proj_id: Id, remote: &Url) -> Result<Vec<RefUpdate>, FetchError> {
self.deref().fetch(proj_id, remote)
}
}
#[cfg(test)]

View File

@ -2,7 +2,7 @@ pub mod transport;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::{fmt, fs, io};
use std::{fs, io};
use git_ref_format::refspec;
use once_cell::sync::Lazy;
@ -21,7 +21,8 @@ use crate::storage::{
pub use crate::git::*;
use super::{RefUpdate, RemoteId};
use super::{Namespaces, RefUpdate, RemoteId};
use transport::remote;
pub static REMOTES_GLOB: Lazy<refspec::PatternString> =
Lazy::new(|| refspec::pattern!("refs/namespaces/*"));
@ -59,32 +60,16 @@ impl ProjectError {
}
}
#[derive(Debug, Clone)]
pub struct Storage {
path: PathBuf,
}
impl fmt::Debug for Storage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Storage(..)")
}
}
impl ReadStorage for Storage {
fn path(&self) -> &Path {
self.path.as_path()
}
fn url(&self, proj: &Id) -> Url {
let path = paths::repository(self, proj);
Url {
scheme: git_url::Scheme::File,
path: path.to_string_lossy().to_string().into(),
..git::Url::default()
}
}
fn get(&self, remote: &RemoteId, proj: Id) -> Result<Option<Doc<Verified>>, ProjectError> {
// TODO: Don't create a repo here if it doesn't exist?
// Perhaps for checking we could have a `contains` method?
@ -107,19 +92,6 @@ impl WriteStorage for Storage {
fn repository(&self, proj: Id) -> Result<Self::Repository, Error> {
Repository::open(paths::repository(self, &proj), proj)
}
fn fetch(&self, proj_id: Id, remote: &Url) -> Result<Vec<RefUpdate>, FetchError> {
let mut repo = self.repository(proj_id)?;
let mut path = remote.path.clone();
path.push(b'/');
path.extend(proj_id.to_string().into_bytes());
repo.fetch(&Url {
path,
..remote.clone()
})
}
}
impl Storage {
@ -200,7 +172,7 @@ impl Repository {
let backend = match git2::Repository::open_bare(path.as_ref()) {
Err(e) if ext::is_not_found_err(&e) => {
let backend = git2::Repository::init_opts(
path,
&path,
git2::RepositoryInitOptions::new()
.bare(true)
.no_reinit(true)
@ -546,9 +518,11 @@ impl WriteRepository for Repository {
/// with pruning *on*, and discard the staging copy. If it fails, we just discard the
/// staging copy.
///
fn fetch(&mut self, url: &git::Url) -> Result<Vec<RefUpdate>, FetchError> {
// TODO: Have function to fetch specific remotes.
//
fn fetch(
&mut self,
node: &RemoteId,
namespaces: impl Into<Namespaces>,
) -> Result<Vec<RefUpdate>, FetchError> {
// The steps are summarized in the following diagram:
//
// staging <- git-clone -- local (canonical) # create staging copy
@ -558,8 +532,12 @@ impl WriteRepository for Repository {
//
// local <- git-fetch -- staging # fetch from staging copy
//
let url = url.to_string();
let refs: &[&str] = &["refs/namespaces/*:refs/namespaces/*"];
let namespace = match namespaces.into() {
Namespaces::All => None,
Namespaces::One(ns) => Some(ns),
};
let mut updates = Vec::new();
let mut callbacks = git2::RemoteCallbacks::new();
let tempdir = tempfile::tempdir()?;
@ -574,10 +552,10 @@ impl WriteRepository for Repository {
// TODO: Due to this, I think we'll have to run GC when there is a failure.
.clone_local(git2::build::CloneLocal::Local)
.clone(
&git::Url {
&git::url::Url {
scheme: git::url::Scheme::File,
path: self.backend.path().to_string_lossy().to_string().into(),
..git::Url::default()
..git::url::Url::default()
}
.to_string(),
&path,
@ -589,8 +567,16 @@ impl WriteRepository for Repository {
// Fetch from the remote into the staging copy.
staging_repo
.remote_anonymous(&url)?
.fetch(refs, Some(&mut opts), None)?;
.remote_anonymous(
remote::Url {
node: *node,
repo: self.id,
namespace,
}
.to_string()
.as_str(),
)?
.fetch(&["refs/*:refs/*"], Some(&mut opts), None)?;
// Verify the staging copy as if it was the canonical copy.
Repository {
@ -617,21 +603,26 @@ impl WriteRepository for Repository {
{
let mut remote = self.backend.remote_anonymous(
&git::Url {
&git::url::Url {
scheme: git::url::Scheme::File,
path: staging.to_string_lossy().to_string().into(),
..git::Url::default()
..git::url::Url::default()
}
.to_string(),
)?;
let mut opts = git2::FetchOptions::default();
opts.remote_callbacks(callbacks);
let refspec = if let Some(namespace) = namespace {
format!("refs/namespaces/{namespace}/refs/*:refs/namespaces/{namespace}/refs/*")
} else {
"refs/namespaces/*:refs/namespaces/*".to_owned()
};
// TODO: Make sure we verify before pruning, as pruning may get us into
// a state we can't roll back.
opts.prune(git2::FetchPrune::On);
// Fetch from the staging copy into the canonical repo.
remote.fetch(refs, Some(&mut opts), None)?;
remote.fetch(&[refspec], Some(&mut opts), None)?;
}
// Set repository HEAD for git cloning support.
self.set_head()?;
@ -742,15 +733,7 @@ mod tests {
let storage = fixtures::storage(dir.path(), &signer).unwrap();
let inv = storage.inventory().unwrap();
let proj = inv.first().unwrap();
let mut refs = git::remote_refs(&git::Url {
scheme: git_url::Scheme::File,
path: paths::repository(&storage, proj)
.to_string_lossy()
.into_owned()
.into(),
..git::Url::default()
})
.unwrap();
let mut refs = git::remote_refs(&git::Url::from(*proj)).unwrap();
let project = storage.repository(*proj).unwrap();
let remotes = project.remotes().unwrap();
@ -773,6 +756,7 @@ mod tests {
fn test_fetch() {
let tmp = tempfile::tempdir().unwrap();
let alice_signer = MockSigner::default();
let alice_pk = *alice_signer.public_key();
let alice = fixtures::storage(tmp.path().join("alice"), alice_signer).unwrap();
let bob = Storage::open(tmp.path().join("bob")).unwrap();
let inventory = alice.inventory().unwrap();
@ -785,17 +769,10 @@ mod tests {
let updates = bob
.repository(proj)
.unwrap()
.fetch(&git::Url {
scheme: git_url::Scheme::File,
path: paths::repository(&alice, &proj)
.to_string_lossy()
.into_owned()
.into(),
..git::Url::default()
})
.fetch(&alice_pk, alice_pk)
.unwrap();
// Four refs are created for each remote.
// Three refs are created for each remote.
assert_eq!(updates.len(), remotes.len() * 3);
for update in updates {
@ -831,24 +808,20 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let alice = Storage::open(tmp.path().join("alice/storage")).unwrap();
let bob = Storage::open(tmp.path().join("bob/storage")).unwrap();
let alice_signer = MockSigner::new(&mut fastrand::Rng::new());
let alice_id = alice_signer.public_key();
let (proj_id, _, proj_repo, alice_head) =
fixtures::project(tmp.path().join("alice/project"), &alice, &alice_signer).unwrap();
let refname = Qualified::from_refstr(git::refname!("refs/heads/master")).unwrap();
let alice_url = git::Url {
scheme: git_url::Scheme::File,
path: paths::repository(&alice, &proj_id)
.to_string_lossy()
.into_owned()
.into(),
..git::Url::default()
};
transport::remote::mock::register(alice_id, alice.path());
// Have Bob fetch Alice's refs.
let updates = bob.repository(proj_id).unwrap().fetch(&alice_url).unwrap();
let updates = bob
.repository(proj_id)
.unwrap()
.fetch(alice_signer.public_key(), *alice_signer.public_key())
.unwrap();
// Three refs are created: the branch, the signature and the id.
assert_eq!(updates.len(), 3);
@ -857,12 +830,16 @@ mod tests {
let alice_head = git::commit(&proj_repo, &alice_head, &refname, "Making changes", "Alice")
.unwrap()
.id();
git::push(&proj_repo, "rad", alice_id, [(&refname, &refname)]).unwrap();
git::push(&proj_repo, "rad", [(&refname, &refname)]).unwrap();
alice_proj_storage.sign_refs(&alice_signer).unwrap();
alice_proj_storage.set_head().unwrap();
// Have Bob fetch Alice's new commit.
let updates = bob.repository(proj_id).unwrap().fetch(&alice_url).unwrap();
let updates = bob
.repository(proj_id)
.unwrap()
.fetch(alice_signer.public_key(), *alice_signer.public_key())
.unwrap();
// The branch and signature refs are updated.
assert_matches!(
updates.as_slice(),
@ -881,6 +858,9 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let signer = MockSigner::default();
let storage = Storage::open(tmp.path().join("storage")).unwrap();
transport::local::register(storage.clone());
let (id, _, _, _) =
fixtures::project(tmp.path().join("project"), &storage, &signer).unwrap();
let proj = storage.repository(id).unwrap();
@ -897,6 +877,10 @@ mod tests {
}
#[test]
#[ignore]
// Test the remote transport using `git-upload-pack` and TCP streams.
// Must be run on its own, since it tries to register the remote transport, which
// will fail if the mock transport was already registered.
fn test_upload_pack() {
let tmp = tempfile::tempdir().unwrap();
let signer = MockSigner::default();
@ -907,6 +891,9 @@ mod tests {
let source_path = tmp.path().join("source");
let target_path = tmp.path().join("target");
let (source, _) = fixtures::repository(&source_path);
transport::local::register(storage.clone());
let (proj, _) = rad::init(
&source,
"radicle",
@ -966,14 +953,11 @@ mod tests {
});
opts.remote_callbacks(callbacks);
// Register the `heartwood://` transport.
transport::remote::register().unwrap();
let target = git2::Repository::init_bare(target_path).unwrap();
let stream = net::TcpStream::connect(addr).unwrap();
let smart = transport::remote::Smart::singleton();
smart.insert(proj, Box::new(stream.try_clone().unwrap()));
// Register the `heartwood://` transport for this stream.
transport::remote::register(remote, stream.try_clone().unwrap());
// Fetch with the `heartwood://` transport.
target

View File

@ -1,2 +1,29 @@
pub mod local;
pub mod remote;
use std::{io, process};
/// A wrapper around a child process' stdin and stdout,
/// making it [`io::Read`] and [`io::Write`].
///
/// Used for some of the git transports.
pub(crate) struct ChildStream {
pub stdin: process::ChildStdin,
pub stdout: process::ChildStdout,
}
impl io::Read for ChildStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.stdout.read(buf)
}
}
impl io::Write for ChildStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.stdin.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.stdin.flush()
}
}

View File

@ -1,2 +1,112 @@
//! The local git transport protocol.
pub mod url;
pub use url::{Url, UrlError};
use std::cell::RefCell;
use std::process;
use std::str::FromStr;
use std::sync::Once;
use once_cell::sync::OnceCell;
use crate::storage;
use crate::storage::git::Storage;
use super::ChildStream;
thread_local! {
/// Stores a storage instance per thread.
/// This avoids race conditions when used in a multi-threaded context.
static THREAD_STORAGE: OnceCell<Storage> = OnceCell::default();
}
/// Local git transport over the filesystem.
#[derive(Default)]
struct Local {
/// The child process we spawn.
child: RefCell<Option<process::Child>>,
}
impl git2::transport::SmartSubtransport for Local {
fn action(
&self,
url: &str,
service: git2::transport::Service,
) -> Result<Box<dyn git2::transport::SmartSubtransportStream>, git2::Error> {
let url = Url::from_str(url).map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
let service: &str = match service {
git2::transport::Service::UploadPack | git2::transport::Service::UploadPackLs => {
"upload-pack"
}
git2::transport::Service::ReceivePack | git2::transport::Service::ReceivePackLs => {
"receive-pack"
}
};
let git_dir = THREAD_STORAGE
.with(|t| {
t.get()
.map(|s| storage::git::paths::repository(&s, &url.repo))
})
.ok_or_else(|| git2::Error::from_str("local transport storage was not registered"))?;
let mut cmd = process::Command::new("git");
if let Some(ns) = url.namespace {
cmd.env("GIT_NAMESPACE", ns.to_string());
}
let mut child = cmd
.arg(service)
.arg(&git_dir)
.stdin(process::Stdio::piped())
.stdout(process::Stdio::piped())
.stderr(process::Stdio::inherit())
.spawn()
.map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
let stdin = child.stdin.take().expect("taking stdin is safe");
let stdout = child.stdout.take().expect("taking stdout is safe");
self.child.replace(Some(child));
Ok(Box::new(ChildStream { stdout, stdin }))
}
fn close(&self) -> Result<(), git2::Error> {
if let Some(mut child) = self.child.take() {
let result = child
.wait()
.map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
if !result.success() {
return if let Some(code) = result.code() {
Err(git2::Error::from_str(
format!("transport: child process exited with error code {code}").as_str(),
))
} else {
Err(git2::Error::from_str(
"transport: child process exited with unknown error",
))
};
}
}
Ok(())
}
}
// TODO: Instead of taking a storage here, we should take something that can return a storage path.
/// Register a storage with the local transport protocol.
pub fn register(storage: Storage) {
static REGISTER: Once = Once::new();
THREAD_STORAGE.with(|s| {
s.set(storage).ok();
});
REGISTER.call_once(|| unsafe {
git2::transport::register(Url::SCHEME, move |remote| {
git2::transport::Transport::smart(remote, false, Local::default())
})
.expect("local transport registration");
});
}

View File

@ -35,7 +35,7 @@ pub enum UrlError {
///
/// `rad://<repo>[/<namespace>]`
///
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Url {
/// Repository identifier.
pub repo: Id,
@ -46,6 +46,21 @@ pub struct Url {
impl Url {
/// URL scheme.
pub const SCHEME: &str = "rad";
/// Return this URL with the given namespace added.
pub fn with_namespace(mut self, namespace: Namespace) -> Self {
self.namespace = Some(namespace);
self
}
}
impl From<Id> for Url {
fn from(repo: Id) -> Self {
Self {
repo,
namespace: None,
}
}
}
impl fmt::Display for Url {

View File

@ -3,72 +3,46 @@
//! 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 choose `heartwood` as the URL scheme for this custom transport, and include the node we'd
//! like to fetch from, as well as the repository. We expect the TCP stream to already be
//! established when this transport is called.
//!
//! 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.
//! We then maintain a map from node identifier to stream, for all active TCP connections. When a
//! URL is requested, we lookup the associated 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`].
//! This module is meant to be used by registering streams with [`register`].
pub mod mock;
pub mod url;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::atomic;
use std::sync::{Arc, Mutex};
use std::sync::Mutex;
use std::sync::Once;
use git2::transport::SmartSubtransportStream;
use once_cell::sync::Lazy;
use crate::identity::Id;
use crate::storage::RemoteId;
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<Arc<Mutex<HashMap<Id, Stream>>>> = Lazy::new(Default::default);
static STREAMS: Lazy<Mutex<HashMap<RemoteId, Stream>>> = Lazy::new(Default::default);
/// The stream associated with a repository.
type Stream = Box<dyn SmartSubtransportStream>;
/// Git transport protocol over an I/O stream.
#[derive(Clone)]
pub struct Smart {
/// The underlying active streams, keyed by repository identifier.
streams: Arc<Mutex<HashMap<Id, Stream>>>,
}
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<Stream> {
#[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);
}
}
struct Smart;
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`,
/// Based on the URL, which must be a valid [`Url`],
/// we retrieve an underlying stream and return it.
///
/// We only support the upload-pack service, since only fetches are authorized by the
@ -79,17 +53,12 @@ impl git2::transport::SmartSubtransport for Smart {
action: git2::transport::Service,
) -> Result<Box<dyn git2::transport::SmartSubtransportStream>, git2::Error> {
let url = Url::from_str(url).map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
let mut streams = STREAMS.lock().expect("lock isn't poisoned");
if let Some(stream) = self.take(&url.repo) {
if let Some(stream) = streams.remove(&url.node) {
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 => {
git2::transport::Service::UploadPackLs | git2::transport::Service::UploadPack => {}
git2::transport::Service::ReceivePack | git2::transport::Service::ReceivePackLs => {
return Err(git2::Error::from_str(
"git-receive-pack is not supported with the custom transport",
));
@ -98,8 +67,8 @@ impl git2::transport::SmartSubtransport for Smart {
Ok(stream)
} else {
Err(git2::Error::from_str(&format!(
"repository {} does not have an associated stream",
url.repo
"node {} does not have an associated stream",
url.node
)))
}
}
@ -111,21 +80,21 @@ impl git2::transport::SmartSubtransport for Smart {
/// Register the radicle transport with `git`.
///
/// Returns an error if called more than once.
/// This function can be called more than once. Only one transport will be registered.
///
pub fn register() -> Result<(), git2::Error> {
static REGISTERED: atomic::AtomicBool = atomic::AtomicBool::new(false);
pub fn register(node: RemoteId, stream: impl SmartSubtransportStream) {
static REGISTER: Once = Once::new();
// 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",
))
}
REGISTER.call_once(|| unsafe {
git2::transport::register(Url::SCHEME, move |remote| {
git2::transport::Transport::smart(remote, false, Smart)
})
.expect("remote transport registration");
});
STREAMS
.lock()
.expect("lock isn't poisoned")
.insert(node, Box::new(stream));
}

View File

@ -0,0 +1,85 @@
//! Mock git transport used for mocking the remote transport in tests.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Mutex, Once};
use std::{process, thread};
use once_cell::sync::Lazy;
use super::Url;
use crate::storage::git::transport::ChildStream;
use crate::storage::RemoteId;
/// Nodes registered with the mock transport.
static NODES: Lazy<Mutex<HashMap<RemoteId, PathBuf>>> = Lazy::new(|| Mutex::new(HashMap::new()));
/// The mock transport.
#[derive(Default)]
struct MockTransport;
impl git2::transport::SmartSubtransport for MockTransport {
fn action(
&self,
url: &str,
service: git2::transport::Service,
) -> Result<Box<dyn git2::transport::SmartSubtransportStream>, git2::Error> {
let url = Url::from_str(url).map_err(|e| git2::Error::from_str(e.to_string().as_str()))?;
let nodes = NODES.lock().expect("lock cannot be poisoned");
let storage = if let Some(storage) = nodes.get(&url.node) {
match service {
git2::transport::Service::ReceivePack | git2::transport::Service::ReceivePackLs => {
return Err(git2::Error::from_str(
"git-receive-pack is not supported with the mock transport",
));
}
_ => {}
}
storage
} else {
return Err(git2::Error::from_str(&format!(
"node {} was not registered with the mock transport",
url.node
)));
};
let git_dir = storage.join(url.repo.to_human());
let mut cmd = process::Command::new("git");
let mut child = cmd
.arg("upload-pack")
.arg("--strict")
.arg(&git_dir)
.stdin(process::Stdio::piped())
.stdout(process::Stdio::piped())
.stderr(process::Stdio::inherit())
.spawn()
.expect("the `git` command is available");
let stdin = child.stdin.take().expect("stdin is safe to take");
let stdout = child.stdout.take().expect("stdout is safe to take");
thread::spawn(move || child.wait());
Ok(Box::new(ChildStream { stdout, stdin }))
}
fn close(&self) -> Result<(), git2::Error> {
Ok(())
}
}
/// Register a new node with the given storage path.
pub fn register(node: &RemoteId, path: &Path) {
static REGISTER: Once = Once::new();
REGISTER.call_once(|| unsafe {
git2::transport::register(Url::SCHEME, move |remote| {
git2::transport::Transport::smart(remote, false, MockTransport::default())
})
.expect("transport registration is successful");
});
NODES
.lock()
.expect("the lock isn't poisoned")
.insert(*node, path.to_owned());
}

View File

@ -21,13 +21,13 @@ pub enum UrlError {
#[error("unsupported scheme: expected `heartwood://`")]
UnsupportedScheme,
/// Invalid node identifier.
#[error("node: {0}")]
#[error("invalid node: {0}")]
InvalidNode(#[source] crypto::PublicKeyError),
/// Invalid repository identifier.
#[error("repo: {0}")]
#[error("invalid repo: {0}")]
InvalidRepository(#[source] IdError),
/// Invalid namespace.
#[error("namespace: {0}")]
#[error("invalid namespace: {0}")]
InvalidNamespace(#[source] crypto::PublicKeyError),
}
@ -35,7 +35,7 @@ pub enum UrlError {
///
/// `heartwood://<node>/<repo>[/<namespace>]`
///
#[derive(Debug)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Url {
/// Node identifier.
pub node: NodeId,

View File

@ -4,15 +4,18 @@ use crate::crypto::{Signer, Verified};
use crate::git;
use crate::identity::Id;
use crate::rad;
use crate::storage::git::transport;
use crate::storage::git::Storage;
use crate::storage::refs::SignedRefs;
use crate::storage::WriteStorage;
/// Create a new storage with a project.
pub fn storage<P: AsRef<Path>, G: Signer>(path: P, signer: G) -> Result<Storage, rad::InitError> {
let path = path.as_ref();
let storage = Storage::open(path.join("storage"))?;
transport::local::register(storage.clone());
transport::remote::mock::register(signer.public_key(), storage.path());
for (name, desc) in [
("acme", "Acme's repository"),
("vim", "A text editor"),
@ -33,11 +36,13 @@ pub fn storage<P: AsRef<Path>, G: Signer>(path: P, signer: G) -> Result<Storage,
}
/// Create a new repository at the given path, and initialize it into a project.
pub fn project<P: AsRef<Path>, S: WriteStorage, G: Signer>(
pub fn project<P: AsRef<Path>, G: Signer>(
path: P,
storage: &S,
storage: &Storage,
signer: G,
) -> Result<(Id, SignedRefs<Verified>, git2::Repository, git2::Oid), rad::InitError> {
transport::local::register(storage.clone());
let (repo, head) = repository(path);
let (id, refs) = rad::init(
&repo,

View File

@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use git_ref_format as fmt;
use git_url::Url;
use radicle_git_ext as git_ext;
use crate::crypto::{Signer, Verified};
@ -38,14 +37,6 @@ impl ReadStorage for MockStorage {
self.path.as_path()
}
fn url(&self, _proj: &Id) -> Url {
Url {
scheme: git_url::Scheme::Radicle,
host: Some("mock".to_string()),
..Url::default()
}
}
fn get(
&self,
_remote: &RemoteId,
@ -65,10 +56,6 @@ impl WriteStorage for MockStorage {
fn repository(&self, _proj: Id) -> Result<Self::Repository, Error> {
Ok(MockRepository {})
}
fn fetch(&self, _proj_id: Id, _remote: &Url) -> Result<Vec<RefUpdate>, FetchError> {
Ok(vec![])
}
}
pub struct MockRepository {}
@ -146,7 +133,11 @@ impl ReadRepository for MockRepository {
}
impl WriteRepository for MockRepository {
fn fetch(&mut self, _url: &Url) -> Result<Vec<RefUpdate>, FetchError> {
fn fetch(
&mut self,
_node: &RemoteId,
_namespaces: impl Into<Namespaces>,
) -> Result<Vec<RefUpdate>, FetchError> {
Ok(vec![])
}