Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
node: Add agent and version to node announcement
cloudhead committed 1 year ago
commit 76edc0c52357286ced937634e6dd022c230b3778
parent 5c0d1b10e0c99e763b377b0098ef3d893fdc8098
24 files changed +412 -82
modified radicle-cli/tests/commands.rs
@@ -8,6 +8,7 @@ use radicle::node::address::Store as _;
use radicle::node::config::seeds::{RADICLE_COMMUNITY_NODE, RADICLE_TEAM_NODE};
use radicle::node::routing::Store as _;
use radicle::node::Handle as _;
+
use radicle::node::UserAgent;
use radicle::node::{Address, Alias, DEFAULT_TIMEOUT};
use radicle::prelude::{NodeId, RepoId};
use radicle::profile;
@@ -21,6 +22,7 @@ use radicle_node::service::Event;
use radicle_node::test::environment::{Config, Environment, Node};
#[allow(unused_imports)]
use radicle_node::test::logger;
+
use radicle_node::PROTOCOL_VERSION;

/// Seed used in tests.
const RAD_SEED: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
@@ -1317,9 +1319,11 @@ fn rad_clone_partial_fail() {
        .addresses_mut()
        .insert(
            &carol,
+
            PROTOCOL_VERSION,
            node::Features::SEED,
            Alias::new("carol"),
            0,
+
            &UserAgent::default(),
            localtime::LocalTime::now().into(),
            [node::KnownAddress::new(
                // Eve will fail to connect to this address.
@@ -1363,6 +1367,7 @@ fn rad_clone_connect() {
    let bob = environment.node(Config::test(Alias::new("bob")));
    let mut eve = environment.node(Config::test(Alias::new("eve")));
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
+
    let ua = UserAgent::default();
    let now = localtime::LocalTime::now().into();

    fixtures::repository(working.join("acme"));
@@ -1383,9 +1388,11 @@ fn rad_clone_connect() {
        .addresses_mut()
        .insert(
            &alice.id,
+
            PROTOCOL_VERSION,
            node::Features::SEED,
            Alias::new("alice"),
            0,
+
            &ua,
            now,
            [node::KnownAddress::new(
                node::Address::from(alice.addr),
@@ -1397,9 +1404,11 @@ fn rad_clone_connect() {
        .addresses_mut()
        .insert(
            &bob.id,
+
            PROTOCOL_VERSION,
            node::Features::SEED,
            Alias::new("bob"),
            0,
+
            &ua,
            now,
            [node::KnownAddress::new(
                node::Address::from(bob.addr),
modified radicle-node/src/lib.rs
@@ -10,12 +10,23 @@ pub mod tests;
pub mod wire;
pub mod worker;

+
use radicle::version::Version;
+

pub use localtime::{LocalDuration, LocalTime};
pub use netservices::Direction as Link;
+
pub use radicle::node::PROTOCOL_VERSION;
pub use radicle::prelude::Timestamp;
pub use radicle::{collections, crypto, git, identity, node, profile, rad, storage};
pub use runtime::Runtime;

+
/// Node version.
+
pub const VERSION: Version = Version {
+
    name: env!("CARGO_PKG_NAME"),
+
    commit: env!("GIT_HEAD"),
+
    version: env!("RADICLE_VERSION"),
+
    timestamp: env!("GIT_COMMIT_TIME"),
+
};
+

pub mod prelude {
    pub use crate::bounded::BoundedVec;
    pub use crate::crypto::{PublicKey, Signature, Signer};
modified radicle-node/src/main.rs
@@ -7,18 +7,10 @@ use crossbeam_channel as chan;
use radicle::logger;
use radicle::prelude::Signer;
use radicle::profile;
-
use radicle::version::Version;
use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner};
-
use radicle_node::Runtime;
+
use radicle_node::{Runtime, VERSION};
use radicle_signals as signals;

-
pub const VERSION: Version = Version {
-
    name: env!("CARGO_PKG_NAME"),
-
    commit: env!("GIT_HEAD"),
-
    version: env!("RADICLE_VERSION"),
-
    timestamp: env!("GIT_COMMIT_TIME"),
-
};
-

pub const HELP_MSG: &str = r#"
Usage

modified radicle-node/src/runtime.rs
@@ -19,6 +19,7 @@ use radicle::node::address;
use radicle::node::address::Store as _;
use radicle::node::notifications;
use radicle::node::Handle as _;
+
use radicle::node::UserAgent;
use radicle::profile::Home;
use radicle::{cob, git, storage, Storage};

@@ -127,7 +128,6 @@ impl Runtime {
        for (key, _) in &config.extra {
            log::warn!(target: "node", "Unused or deprecated configuration attribute {:?}", key);
        }
-
        log::info!(target: "node", "Opening node database..");

        log::info!(target: "node", "Opening policy database..");
        let policies = home.policies_mut()?;
@@ -174,6 +174,7 @@ impl Runtime {
                .expect("Runtime::init: unable to solve proof-of-work puzzle")
        };

+
        log::info!(target: "node", "Opening node database..");
        let db = home
            .database_mut()?
            .journal_mode(node::db::JournalMode::default())?
@@ -181,6 +182,7 @@ impl Runtime {
                &id,
                announcement.features,
                announcement.alias.clone(),
+
                &announcement.agent,
                announcement.timestamp,
                announcement.addresses.iter(),
            )?;
@@ -189,14 +191,16 @@ impl Runtime {
        if config.connect.is_empty() && stores.addresses().is_empty()? {
            log::info!(target: "node", "Address book is empty. Adding bootstrap nodes..");

-
            for (alias, addr) in config.network.bootstrap() {
+
            for (alias, version, addr) in config.network.bootstrap() {
                let (id, addr) = addr.into();

                stores.addresses_mut().insert(
                    &id,
+
                    version,
                    radicle::node::Features::SEED,
                    alias,
                    0,
+
                    &UserAgent::default(),
                    clock.into(),
                    [node::KnownAddress::new(addr, address::Source::Bootstrap)],
                )?;
modified radicle-node/src/service.rs
@@ -36,7 +36,6 @@ use radicle::storage::refs::SIGREFS_BRANCH;
use radicle::storage::RepositoryError;
use radicle_fetch::policy::SeedingPolicy;

-
use crate::crypto;
use crate::crypto::{Signer, Verified};
use crate::identity::{Doc, RepoId};
use crate::node::routing;
@@ -56,6 +55,7 @@ use crate::storage::{refs::RefsAt, Namespaces, ReadStorage};
use crate::worker::fetch;
use crate::worker::FetchError;
use crate::Link;
+
use crate::{crypto, PROTOCOL_VERSION};

pub use crate::node::events::{Event, Events};
pub use crate::node::{config::Network, Config, NodeId};
@@ -1693,9 +1693,11 @@ where

                match self.db.addresses_mut().insert(
                    announcer,
-
                    *features,
+
                    ann.version,
+
                    ann.features,
                    ann.alias.clone(),
                    ann.work(),
+
                    &ann.agent,
                    timestamp,
                    addresses
                        .iter()
@@ -2380,6 +2382,7 @@ where
                // Nb. we don't want to connect to any peers that already have a session with us,
                // even if it's in a disconnected state. Those sessions are re-attempted automatically.
                let mut peers = entries
+
                    .filter(|entry| entry.version == PROTOCOL_VERSION)
                    .filter(|entry| !entry.address.banned)
                    .filter(|entry| !entry.penalty.is_connect_threshold_reached())
                    .filter(|entry| !self.sessions.contains_key(&entry.node))
modified radicle-node/src/service/gossip.rs
@@ -1,9 +1,20 @@
pub mod store;

+
use std::str::FromStr;
+

use super::*;
+
use crate::{PROTOCOL_VERSION, VERSION};
+
use once_cell::sync::Lazy;
+
use radicle::node::UserAgent;

pub use store::{AnnouncementId, Error, RelayStatus, Store};

+
/// This node's user agent string.
+
pub static USER_AGENT: Lazy<UserAgent> = Lazy::new(|| {
+
    FromStr::from_str(format!("/radicle:{}/", VERSION.version).as_str())
+
        .expect("user agent is valid")
+
});
+

pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement {
    let features = config.features();
    let alias = config.alias.clone();
@@ -12,13 +23,17 @@ pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement {
        .clone()
        .try_into()
        .expect("external addresses are within the limit");
+
    let agent = USER_AGENT.clone();
+
    let version = PROTOCOL_VERSION;

    NodeAnnouncement {
        features,
+
        version,
        timestamp,
        alias,
        addresses,
        nonce: 0,
+
        agent,
    }
}

modified radicle-node/src/service/message.rs
@@ -7,7 +7,7 @@ use radicle::storage::refs::RefsAt;
use crate::crypto;
use crate::identity::RepoId;
use crate::node;
-
use crate::node::{Address, Alias};
+
use crate::node::{Address, Alias, UserAgent};
use crate::prelude::BoundedVec;
use crate::service::filter::Filter;
use crate::service::{Link, NodeId, Timestamp};
@@ -54,6 +54,8 @@ impl Subscribe {
/// Node announcing itself to the network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeAnnouncement {
+
    /// Supported protocol version.
+
    pub version: u8,
    /// Advertized features.
    pub features: node::Features,
    /// Monotonic timestamp.
@@ -64,6 +66,8 @@ pub struct NodeAnnouncement {
    pub addresses: BoundedVec<Address, ADDRESS_LIMIT>,
    /// Nonce used for announcement proof-of-work.
    pub nonce: u64,
+
    /// User-agent string.
+
    pub agent: UserAgent,
}

impl NodeAnnouncement {
@@ -125,11 +129,13 @@ impl wire::Encode for NodeAnnouncement {
    fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<usize, io::Error> {
        let mut n = 0;

+
        n += self.version.encode(writer)?;
        n += self.features.encode(writer)?;
        n += self.timestamp.encode(writer)?;
        n += self.alias.encode(writer)?;
        n += self.addresses.encode(writer)?;
        n += self.nonce.encode(writer)?;
+
        n += self.agent.encode(writer)?;

        Ok(n)
    }
@@ -137,18 +143,26 @@ impl wire::Encode for NodeAnnouncement {

impl wire::Decode for NodeAnnouncement {
    fn decode<R: std::io::Read + ?Sized>(reader: &mut R) -> Result<Self, wire::Error> {
+
        let version = u8::decode(reader)?;
        let features = node::Features::decode(reader)?;
        let timestamp = Timestamp::decode(reader)?;
        let alias = wire::Decode::decode(reader)?;
        let addresses = BoundedVec::<Address, ADDRESS_LIMIT>::decode(reader)?;
        let nonce = u64::decode(reader)?;
+
        let agent = match UserAgent::decode(reader) {
+
            Ok(ua) => ua,
+
            Err(e) if e.is_eof() => UserAgent::default(),
+
            Err(e) => return Err(e),
+
        };

        Ok(Self {
+
            version,
            features,
            timestamp,
            alias,
            addresses,
            nonce,
+
            agent,
        })
    }
}
@@ -330,10 +344,10 @@ impl fmt::Debug for AnnouncementMessage {
pub struct Announcement {
    /// Node identifier.
    pub node: NodeId,
-
    /// Unsigned node announcement.
-
    pub message: AnnouncementMessage,
    /// Signature over the announcement.
    pub signature: crypto::Signature,
+
    /// Unsigned node announcement.
+
    pub message: AnnouncementMessage,
}

impl Announcement {
@@ -576,16 +590,18 @@ impl ZeroBytes {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
-
    use super::*;
-
    use crate::prelude::*;
-
    use crate::wire::Encode;
+
    use std::str::FromStr;

-
    use crate::crypto::test::signer::MockSigner;
-
    use crate::test::arbitrary;
    use fastrand;
    use qcheck_macros::quickcheck;
    use radicle::git::raw;

+
    use super::*;
+
    use crate::crypto::test::signer::MockSigner;
+
    use crate::prelude::*;
+
    use crate::test::arbitrary;
+
    use crate::wire::Encode;
+

    #[test]
    fn test_ref_remote_limit() {
        let mut refs = BoundedVec::<_, REF_REMOTE_LIMIT>::new();
@@ -672,16 +688,18 @@ mod tests {
    #[test]
    fn test_node_announcement_validate() {
        let ann = NodeAnnouncement {
+
            version: 1,
            features: node::Features::SEED,
            timestamp: Timestamp::try_from(42491841u64).unwrap(),
            alias: Alias::new("alice"),
            addresses: BoundedVec::new(),
            nonce: 0,
+
            agent: UserAgent::from_str("/heartwood:1.0.0/").unwrap(),
        };

-
        assert_eq!(ann.work(), 0);
-
        assert_eq!(ann.clone().solve(1).unwrap().work(), 4);
-
        assert_eq!(ann.clone().solve(8).unwrap().work(), 9);
+
        assert_eq!(ann.work(), 1);
+
        assert_eq!(ann.clone().solve(1).unwrap().work(), 1);
+
        assert_eq!(ann.clone().solve(8).unwrap().work(), 10);
        assert_eq!(ann.solve(14).unwrap().work(), 14);
    }
}
modified radicle-node/src/test/arbitrary.rs
@@ -2,6 +2,7 @@ use std::collections::HashSet;

use bloomy::BloomFilter;
use qcheck::Arbitrary;
+
use radicle::node::UserAgent;

use crate::crypto;
use crate::identity::DocAt;
@@ -80,11 +81,13 @@ impl Arbitrary for Message {
            .into(),
            MessageType::NodeAnnouncement => {
                let message = NodeAnnouncement {
+
                    version: u8::arbitrary(g),
                    features: u64::arbitrary(g).into(),
                    timestamp: Timestamp::arbitrary(g),
                    alias: Alias::arbitrary(g),
                    addresses: Arbitrary::arbitrary(g),
                    nonce: u64::arbitrary(g),
+
                    agent: UserAgent::arbitrary(g),
                }
                .into();
                let bytes: [u8; 64] = Arbitrary::arbitrary(g);
modified radicle-node/src/test/environment.rs
@@ -20,8 +20,7 @@ use radicle::identity::{RepoId, Visibility};
use radicle::node::config::ConnectAddress;
use radicle::node::policy::store as policy;
use radicle::node::seed::Store as _;
-
use radicle::node::Database;
-
use radicle::node::{Alias, POLICIES_DB_FILE};
+
use radicle::node::{Alias, Database, UserAgent, POLICIES_DB_FILE};
use radicle::node::{ConnectOptions, Handle as _};
use radicle::profile;
use radicle::profile::{env, Home, Profile};
@@ -135,6 +134,7 @@ impl Environment {
                &public_key,
                config.node.features(),
                Alias::new(alias),
+
                &UserAgent::default(),
                now.into(),
                config.node.external_addresses.iter(),
            )
modified radicle-node/src/test/gossip.rs
@@ -1,11 +1,15 @@
+
use std::str::FromStr;
+

use radicle::crypto::test::signer::MockSigner;
use radicle::node;
+
use radicle::node::UserAgent;
use radicle::test::fixtures::gen;

use crate::test::arbitrary;
use crate::{
    prelude::{LocalDuration, LocalTime, Message},
    service::message::{InventoryAnnouncement, NodeAnnouncement},
+
    PROTOCOL_VERSION,
};

pub fn messages(count: usize, now: LocalTime, delta: LocalDuration) -> Vec<Message> {
@@ -28,11 +32,13 @@ pub fn messages(count: usize, now: LocalTime, delta: LocalDuration) -> Vec<Messa

        msgs.push(Message::node(
            NodeAnnouncement {
+
                version: PROTOCOL_VERSION,
                features: node::Features::SEED,
                timestamp: time.into(),
                alias: node::Alias::new(gen::string(5)),
                addresses: None.into(),
                nonce: 0,
+
                agent: UserAgent::from_str("/radicle:test/").unwrap(),
            }
            .solve(0)
            .unwrap(),
modified radicle-node/src/test/peer.rs
@@ -10,6 +10,7 @@ use log::*;
use radicle::identity::Visibility;
use radicle::node::address::Store as _;
use radicle::node::Database;
+
use radicle::node::UserAgent;
use radicle::node::{address, Alias, ConnectOptions};
use radicle::rad;
use radicle::storage::refs::{RefsAt, SignedRefsAt};
@@ -33,8 +34,7 @@ use crate::storage::{RemoteId, WriteStorage};
use crate::test::storage::MockStorage;
use crate::test::{arbitrary, fixtures, simulator};
use crate::wire::MessageType;
-
use crate::Link;
-
use crate::{LocalDuration, LocalTime};
+
use crate::{Link, LocalDuration, LocalTime, PROTOCOL_VERSION};

/// Service instantiation used for testing.
pub type Service<S, G> = service::Service<Database, S, G>;
@@ -175,6 +175,7 @@ where
                &id,
                config.config.features(),
                config.config.alias.clone(),
+
                &UserAgent::default(),
                config.local_time.into(),
                config.config.external_addresses.iter(),
            )
@@ -251,9 +252,11 @@ where
                .addresses_mut()
                .insert(
                    &peer.node_id(),
+
                    PROTOCOL_VERSION,
                    radicle::node::Features::default(),
                    Alias::from_str(peer.name).unwrap(),
                    0,
+
                    &UserAgent::default(),
                    timestamp,
                    Some(known_address),
                )
@@ -303,11 +306,13 @@ where
    pub fn node_announcement(&self) -> Message {
        Message::node(
            NodeAnnouncement {
+
                version: PROTOCOL_VERSION,
                features: node::Features::SEED,
                timestamp: self.timestamp(),
                alias: Alias::from_str(self.name).unwrap(),
                addresses: Some(net::SocketAddr::from((self.ip, node::DEFAULT_PORT)).into()).into(),
                nonce: 0,
+
                agent: UserAgent::from_str("/radicle:test/").unwrap(),
            }
            .solve(0)
            .unwrap(),
modified radicle-node/src/wire.rs
@@ -6,6 +6,7 @@ mod varint;
pub use frame::StreamId;
pub use message::{AddressType, MessageType};
pub use protocol::{Control, Wire, WireReader, WireSession, WireWriter};
+
use radicle::node::UserAgent;

use std::collections::BTreeMap;
use std::convert::TryFrom;
@@ -54,6 +55,8 @@ pub enum Error {
    InvalidRefName(#[from] fmt::Error),
    #[error(transparent)]
    InvalidAlias(#[from] node::AliasError),
+
    #[error("invalid user agent string: {0:?}")]
+
    InvalidUserAgent(String),
    #[error("invalid control message with type `{0}`")]
    InvalidControlMessage(u8),
    #[error("invalid protocol version header `{0:x?}`")]
@@ -62,8 +65,8 @@ pub enum Error {
    InvalidOnionAddr(#[from] tor::OnionAddrDecodeError),
    #[error("invalid timestamp: {0}")]
    InvalidTimestamp(u64),
-
    #[error("unknown protocol version `{0}`")]
-
    UnknownProtocolVersion(u8),
+
    #[error("wrong protocol version `{0}`")]
+
    WrongProtocolVersion(u8),
    #[error("unknown address type `{0}`")]
    UnknownAddressType(u8),
    #[error("unknown message type `{0}`")]
@@ -251,6 +254,12 @@ impl Encode for cyphernet::addr::tor::OnionAddrV3 {
    }
}

+
impl Encode for UserAgent {
+
    fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<usize, io::Error> {
+
        self.as_ref().encode(writer)
+
    }
+
}
+

impl Encode for Alias {
    fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<usize, io::Error> {
        self.as_ref().encode(writer)
@@ -321,6 +330,13 @@ impl Decode for git::RefString {
    }
}

+
impl Decode for UserAgent {
+
    fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, Error> {
+
        String::decode(reader)
+
            .and_then(|s| UserAgent::from_str(&s).map_err(Error::InvalidUserAgent))
+
    }
+
}
+

impl Decode for Alias {
    fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, Error> {
        String::decode(reader).and_then(|s| Alias::from_str(&s).map_err(Error::from))
modified radicle-node/src/wire/frame.rs
@@ -2,10 +2,8 @@
#![warn(clippy::missing_docs_in_private_items)]
use std::{fmt, io};

-
use crate::{wire, wire::varint, wire::varint::VarInt, wire::Message, Link};
+
use crate::{wire, wire::varint, wire::varint::VarInt, wire::Message, Link, PROTOCOL_VERSION};

-
/// Protocol version.
-
pub const PROTOCOL_VERSION: u8 = 1;
/// Protocol version strings all start with the magic sequence `rad`, followed
/// by a version number.
pub const PROTOCOL_VERSION_STRING: Version = Version([b'r', b'a', b'd', PROTOCOL_VERSION]);
@@ -315,8 +313,8 @@ impl wire::Encode for Control {
impl wire::Decode for Frame {
    fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, wire::Error> {
        let version = Version::decode(reader)?;
-
        if version.number() > PROTOCOL_VERSION {
-
            return Err(wire::Error::UnknownProtocolVersion(version.number()));
+
        if version.number() != PROTOCOL_VERSION {
+
            return Err(wire::Error::WrongProtocolVersion(version.number()));
        }
        let stream = StreamId::decode(reader)?;

modified radicle-node/src/wire/message.rs
@@ -274,8 +274,8 @@ impl wire::Encode for Message {
                signature,
            }) => {
                n += node.encode(writer)?;
-
                n += message.encode(writer)?;
                n += signature.encode(writer)?;
+
                n += message.encode(writer)?;
            }
            Self::Info(info) => {
                n += info.encode(writer)?;
@@ -317,8 +317,8 @@ impl wire::Decode for Message {
            }
            Ok(MessageType::NodeAnnouncement) => {
                let node = NodeId::decode(reader)?;
-
                let message = NodeAnnouncement::decode(reader)?.into();
                let signature = Signature::decode(reader)?;
+
                let message = NodeAnnouncement::decode(reader)?.into();

                Ok(Announcement {
                    node,
@@ -329,8 +329,8 @@ impl wire::Decode for Message {
            }
            Ok(MessageType::InventoryAnnouncement) => {
                let node = NodeId::decode(reader)?;
-
                let message = InventoryAnnouncement::decode(reader)?.into();
                let signature = Signature::decode(reader)?;
+
                let message = InventoryAnnouncement::decode(reader)?.into();

                Ok(Announcement {
                    node,
@@ -341,8 +341,8 @@ impl wire::Decode for Message {
            }
            Ok(MessageType::RefsAnnouncement) => {
                let node = NodeId::decode(reader)?;
-
                let message = RefsAnnouncement::decode(reader)?.into();
                let signature = Signature::decode(reader)?;
+
                let message = RefsAnnouncement::decode(reader)?.into();

                Ok(Announcement {
                    node,
@@ -458,6 +458,7 @@ impl wire::Decode for ZeroBytes {
mod tests {
    use super::*;
    use qcheck_macros::quickcheck;
+
    use radicle::node::UserAgent;
    use radicle::storage::refs::RefsAt;
    use radicle_crypto::test::signer::MockSigner;

@@ -502,11 +503,13 @@ mod tests {
        let addrs: [Address; ADDRESS_LIMIT] = arbitrary::gen(1);
        let alias = ['@'; radicle::node::MAX_ALIAS_LENGTH];
        let ann = AnnouncementMessage::Node(NodeAnnouncement {
+
            version: 1,
            features: Default::default(),
            alias: radicle::node::Alias::new(String::from_iter(alias)),
            addresses: BoundedVec::collect_from(&mut addrs.into_iter()),
            timestamp: arbitrary::gen(1),
            nonce: u64::MAX,
+
            agent: UserAgent::default(),
        });
        let ann = ann.signed(&signer);
        let msg = Message::Announcement(ann);
@@ -555,10 +558,10 @@ mod tests {

    #[quickcheck]
    fn prop_message_encode_decode(message: Message) {
-
        assert_eq!(
-
            wire::deserialize::<Message>(&wire::serialize(&message)).unwrap(),
-
            message
-
        );
+
        let encoded = &wire::serialize(&message);
+
        let decoded = wire::deserialize::<Message>(encoded).unwrap();
+

+
        assert_eq!(message, decoded);
    }

    #[test]
modified radicle-node/src/wire/protocol.rs
@@ -31,7 +31,6 @@ use crate::service;
use crate::service::io::Io;
use crate::service::FETCH_TIMEOUT;
use crate::service::{session, DisconnectReason, Metrics, Service};
-
use crate::wire;
use crate::wire::frame;
use crate::wire::frame::{Frame, FrameData, StreamId};
use crate::wire::Encode;
@@ -839,15 +838,6 @@ where
                                // Buffer is empty, or message isn't complete.
                                break;
                            }
-
                            Err(wire::Error::UnknownProtocolVersion(v)) => {
-
                                // It's ok for a peer to send frames from a newer protocol version
-
                                // that we don't understand. We just ignore them.
-
                                log::debug!(
-
                                    target: "wire",
-
                                    "Ignoring frame with newer protocol version ({v})",
-
                                );
-
                                continue;
-
                            }
                            Err(e) => {
                                log::error!(target: "wire", "Invalid gossip message from {nid}: {e}");

modified radicle/src/node.rs
@@ -45,6 +45,8 @@ pub use features::Features;
pub use seed::SyncedAt;
pub use timestamp::Timestamp;

+
/// Peer-to-peer protocol version.
+
pub const PROTOCOL_VERSION: u8 = 1;
/// Default name for control socket file.
pub const DEFAULT_SOCKET_NAME: &str = "control.sock";
/// Default radicle protocol port.
@@ -217,6 +219,77 @@ impl PartialOrd for SyncStatus {
    }
}

+
/// Node user agent.
+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, serde::Serialize, serde::Deserialize)]
+
pub struct UserAgent(String);
+

+
impl UserAgent {
+
    /// Return a reference to the user agent string.
+
    pub fn as_str(&self) -> &str {
+
        self.0.as_str()
+
    }
+
}
+

+
impl Default for UserAgent {
+
    fn default() -> Self {
+
        UserAgent(String::from("/radicle/"))
+
    }
+
}
+

+
impl std::fmt::Display for UserAgent {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        self.0.fmt(f)
+
    }
+
}
+

+
impl FromStr for UserAgent {
+
    type Err = String;
+

+
    fn from_str(input: &str) -> Result<Self, Self::Err> {
+
        let reserved = ['/', ':'];
+

+
        if input.len() > 64 {
+
            return Err(input.to_owned());
+
        }
+
        let Some(s) = input.strip_prefix('/') else {
+
            return Err(input.to_owned());
+
        };
+
        let Some(s) = s.strip_suffix('/') else {
+
            return Err(input.to_owned());
+
        };
+
        if s.is_empty() {
+
            return Err(input.to_owned());
+
        }
+
        if s.split('/').all(|segment| {
+
            if let Some((client, version)) = segment.split_once(':') {
+
                if client.is_empty() || version.is_empty() {
+
                    false
+
                } else {
+
                    let client = client
+
                        .chars()
+
                        .all(|c| c.is_ascii_graphic() && !reserved.contains(&c));
+
                    let version = version
+
                        .chars()
+
                        .all(|c| c.is_ascii_graphic() || !reserved.contains(&c));
+
                    client && version
+
                }
+
            } else {
+
                true
+
            }
+
        }) {
+
            Ok(Self(input.to_owned()))
+
        } else {
+
            Err(input.to_owned())
+
        }
+
    }
+
}
+

+
impl AsRef<str> for UserAgent {
+
    fn as_ref(&self) -> &str {
+
        self.0.as_str()
+
    }
+
}
+

/// Node alias.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "String", into = "String")]
@@ -1283,6 +1356,27 @@ mod test {
    use crate::assert_matches;

    #[test]
+
    fn test_user_agent() {
+
        assert!(UserAgent::from_str("/radicle:1.0.0/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:1.0.0/heartwood:0.9/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:1.0.0/heartwood:0.9/rust:1.77/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:1.0.0-rc.1/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:1.0.0-rc.1/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:@a.b.c/").is_ok());
+
        assert!(UserAgent::from_str("/radicle/").is_ok());
+
        assert!(UserAgent::from_str("/rad/icle/").is_ok());
+
        assert!(UserAgent::from_str("/rad:ic/le/").is_ok());
+

+
        assert!(UserAgent::from_str("/:/").is_err());
+
        assert!(UserAgent::from_str("//").is_err());
+
        assert!(UserAgent::from_str("").is_err());
+
        assert!(UserAgent::from_str("radicle:1.0.0/").is_err());
+
        assert!(UserAgent::from_str("/radicle:1.0.0").is_err());
+
        assert!(UserAgent::from_str("/radi cle:1.0/").is_err());
+
        assert!(UserAgent::from_str("/radi\ncle:1.0/").is_err());
+
    }
+

+
    #[test]
    fn test_alias() {
        assert!(Alias::from_str("cloudhead").is_ok());
        assert!(Alias::from_str("cloud-head").is_ok());
modified radicle/src/node/address.rs
@@ -10,7 +10,7 @@ use localtime::LocalTime;
use nonempty::NonEmpty;

use crate::collections::RandomMap;
-
use crate::node::{Address, Alias, Penalty};
+
use crate::node::{Address, Alias, Penalty, UserAgent};
use crate::prelude::Timestamp;
use crate::{node, profile};

@@ -120,6 +120,8 @@ impl<K: hash::Hash + Eq, V> DerefMut for AddressBook<K, V> {
/// Node public data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
+
    /// Protocol version.
+
    pub version: u8,
    /// Advertized alias.
    pub alias: Alias,
    /// Advertized features.
@@ -130,6 +132,8 @@ pub struct Node {
    pub pow: u32,
    /// When this data was published.
    pub timestamp: Timestamp,
+
    /// User agent string.
+
    pub agent: UserAgent,
    /// Node connection penalty.
    pub penalty: Penalty,
    /// Whether the node is banned.
modified radicle/src/node/address/store.rs
@@ -1,4 +1,5 @@
use std::net::IpAddr;
+
use std::num::TryFromIntError;
use std::str::FromStr;

use localtime::LocalTime;
@@ -7,6 +8,7 @@ use thiserror::Error;

use crate::node;
use crate::node::address::{AddressType, KnownAddress, Node, Source};
+
use crate::node::UserAgent;
use crate::node::{Address, Alias, AliasError, AliasStore, Database, NodeId, Penalty, Severity};
use crate::prelude::Timestamp;
use crate::sql::transaction;
@@ -18,6 +20,8 @@ pub enum Error {
    Internal(#[from] sql::Error),
    #[error("alias error: {0}")]
    InvalidAlias(#[from] AliasError),
+
    #[error("integer conversion error: {0}")]
+
    TryFromInt(#[from] TryFromIntError),
    /// No rows returned in query result.
    #[error("no rows returned")]
    NoRows,
@@ -28,6 +32,8 @@ pub enum Error {
pub struct AddressEntry {
    /// Node ID.
    pub node: NodeId,
+
    /// Node protocol version.
+
    pub version: u8,
    /// Node penalty.
    pub penalty: Penalty,
    /// Node address.
@@ -48,9 +54,11 @@ pub trait Store {
    fn insert(
        &mut self,
        node: &NodeId,
+
        version: u8,
        features: node::Features,
        alias: Alias,
        pow: u32,
+
        agent: &UserAgent,
        timestamp: Timestamp,
        addrs: impl IntoIterator<Item = KnownAddress>,
    ) -> Result<bool, Error>;
@@ -89,24 +97,30 @@ pub trait Store {
impl Store for Database {
    fn get(&self, node: &NodeId) -> Result<Option<Node>, Error> {
        let mut stmt = self.db.prepare(
-
            "SELECT features, alias, pow, penalty, banned, timestamp FROM nodes WHERE id = ?",
+
            "SELECT version, features, alias, pow, penalty, banned, agent, timestamp
+
             FROM nodes
+
             WHERE id = ?",
        )?;
        stmt.bind((1, node))?;

        if let Some(Ok(row)) = stmt.into_iter().next() {
+
            let version = row.read::<i64, _>("version").try_into()?;
            let features = row.read::<node::Features, _>("features");
            let alias = Alias::from_str(row.read::<&str, _>("alias"))?;
            let timestamp = row.read::<Timestamp, _>("timestamp");
            let pow = row.read::<i64, _>("pow") as u32;
+
            let agent = row.read::<UserAgent, _>("agent");
            let penalty = row.read::<i64, _>("penalty").min(u8::MAX as i64);
            let penalty = Penalty(penalty as u8);
            let banned = row.read::<i64, _>("banned").is_positive();
            let addrs = self.addresses_of(node)?;

            Ok(Some(Node {
+
                version,
                features,
                alias,
                pow,
+
                agent,
                timestamp,
                penalty,
                addrs,
@@ -207,26 +221,30 @@ impl Store for Database {
    fn insert(
        &mut self,
        node: &NodeId,
+
        version: u8,
        features: node::Features,
        alias: Alias,
        pow: u32,
+
        agent: &UserAgent,
        timestamp: Timestamp,
        addrs: impl IntoIterator<Item = KnownAddress>,
    ) -> Result<bool, Error> {
        transaction(&self.db, move |db| {
            let mut stmt = db.prepare(
-
                "INSERT INTO nodes (id, features, alias, pow, timestamp)
-
                 VALUES (?1, ?2, ?3, ?4, ?5)
+
                "INSERT INTO nodes (id, version, features, alias, pow, agent, timestamp)
+
                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
                 ON CONFLICT DO UPDATE
-
                 SET features = ?2, alias = ?3, pow = ?4, timestamp = ?5
-
                 WHERE timestamp < ?5",
+
                 SET version = ?2, features = ?3, alias = ?4, pow = ?5, agent = ?6, timestamp = ?7
+
                 WHERE timestamp < ?7",
            )?;

            stmt.bind((1, node))?;
-
            stmt.bind((2, features))?;
-
            stmt.bind((3, sql::Value::String(alias.into())))?;
-
            stmt.bind((4, pow as i64))?;
-
            stmt.bind((5, &timestamp))?;
+
            stmt.bind((2, version as i64))?;
+
            stmt.bind((3, features))?;
+
            stmt.bind((4, sql::Value::String(alias.into())))?;
+
            stmt.bind((5, pow as i64))?;
+
            stmt.bind((6, agent.as_str()))?;
+
            stmt.bind((7, &timestamp))?;
            stmt.next()?;

            for addr in addrs {
@@ -261,7 +279,7 @@ impl Store for Database {
        let mut stmt = self
            .db
            .prepare(
-
                "SELECT a.node, a.type, a.value, a.source, a.last_success, a.last_attempt, a.banned, n.penalty
+
                "SELECT a.node, a.type, a.value, a.source, a.last_success, a.last_attempt, a.banned, n.version, n.penalty
                 FROM addresses AS a
                 JOIN nodes AS n ON a.node = n.id
                 ORDER BY n.penalty ASC, n.id ASC",
@@ -278,12 +296,14 @@ impl Store for Database {
            let last_attempt = row.read::<Option<i64>, _>("last_attempt");
            let last_success = last_success.map(|t| LocalTime::from_millis(t as u128));
            let last_attempt = last_attempt.map(|t| LocalTime::from_millis(t as u128));
+
            let version = row.read::<i64, _>("version").try_into()?;
            let banned = row.read::<i64, _>("banned").is_positive();
            let penalty = row.read::<i64, _>("penalty");
            let penalty = Penalty(penalty as u8); // Clamped at `u8::MAX`.

            entries.push(AddressEntry {
                node,
+
                version,
                penalty,
                address: KnownAddress {
                    addr,
@@ -510,15 +530,34 @@ mod test {
        let mut cache = Database::memory().unwrap();
        let features = node::Features::SEED;
        let timestamp = Timestamp::from(LocalTime::now());
+
        let ua = UserAgent::default();

        cache
-
            .insert(&alice, features, Alias::new("alice"), 16, timestamp, [])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                Alias::new("alice"),
+
                16,
+
                &ua,
+
                timestamp,
+
                [],
+
            )
            .unwrap();
        let node = cache.get(&alice).unwrap().unwrap();
        assert_eq!(node.alias.as_ref(), "alice");

        cache
-
            .insert(&alice, features, Alias::new("bob"), 16, timestamp + 1, [])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                Alias::new("bob"),
+
                16,
+
                &ua,
+
                timestamp + 1,
+
                [],
+
            )
            .unwrap();
        let node = cache.get(&alice).unwrap().unwrap();
        assert_eq!(node.alias.as_ref(), "bob");
@@ -528,8 +567,10 @@ mod test {
    fn test_insert_and_get() {
        let alice = arbitrary::gen::<NodeId>(1);
        let mut cache = Database::memory().unwrap();
+
        let version = 2;
        let features = node::Features::SEED;
        let timestamp = LocalTime::now().into();
+
        let ua = UserAgent::default();

        let ka = KnownAddress {
            addr: net::SocketAddr::from(([4, 4, 4, 4], 8776)).into(),
@@ -541,9 +582,11 @@ mod test {
        let inserted = cache
            .insert(
                &alice,
+
                version,
                features,
                Alias::new("alice"),
                16,
+
                &ua,
                timestamp,
                [ka.clone()],
            )
@@ -552,6 +595,7 @@ mod test {

        let node = cache.get(&alice).unwrap().unwrap();

+
        assert_eq!(node.version, version);
        assert_eq!(node.features, features);
        assert_eq!(node.pow, 16);
        assert_eq!(node.timestamp, timestamp);
@@ -566,6 +610,7 @@ mod test {
        let features = node::Features::SEED;
        let timestamp = LocalTime::now().into();
        let alias = Alias::new("alice");
+
        let ua = UserAgent::default();

        let ka = KnownAddress {
            addr: net::SocketAddr::from(([4, 4, 4, 4], 8776)).into(),
@@ -575,12 +620,21 @@ mod test {
            banned: false,
        };
        let inserted = cache
-
            .insert(&alice, features, alias.clone(), 0, timestamp, [ka.clone()])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                alias.clone(),
+
                0,
+
                &ua,
+
                timestamp,
+
                [ka.clone()],
+
            )
            .unwrap();
        assert!(inserted);

        let inserted = cache
-
            .insert(&alice, features, alias, 0, timestamp, [ka])
+
            .insert(&alice, 1, features, alias, 0, &ua, timestamp, [ka])
            .unwrap();
        assert!(!inserted);

@@ -593,6 +647,8 @@ mod test {
        let mut cache = Database::memory().unwrap();
        let timestamp = LocalTime::now().into();
        let features = node::Features::SEED;
+
        let ua1 = UserAgent::default();
+
        let ua2 = UserAgent::default();
        let alias1 = Alias::new("alice");
        let alias2 = Alias::new("~alice~");
        let ka = KnownAddress {
@@ -604,17 +660,35 @@ mod test {
        };

        let updated = cache
-
            .insert(&alice, features, alias1, 0, timestamp, [ka.clone()])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                alias1,
+
                0,
+
                &ua1,
+
                timestamp,
+
                [ka.clone()],
+
            )
            .unwrap();
        assert!(updated);

        let updated = cache
-
            .insert(&alice, features, alias2.clone(), 0, timestamp, [])
+
            .insert(&alice, 1, features, alias2.clone(), 0, &ua1, timestamp, [])
            .unwrap();
        assert!(!updated, "Can't update using the same timestamp");

        let updated = cache
-
            .insert(&alice, features, alias2.clone(), 0, timestamp - 1, [])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                alias2.clone(),
+
                0,
+
                &ua1,
+
                timestamp - 1,
+
                [],
+
            )
            .unwrap();
        assert!(!updated, "Can't update using a smaller timestamp");

@@ -624,12 +698,30 @@ mod test {
        assert_eq!(node.pow, 0);

        let updated = cache
-
            .insert(&alice, features, alias2.clone(), 0, timestamp + 1, [])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                alias2.clone(),
+
                0,
+
                &ua2,
+
                timestamp + 1,
+
                [],
+
            )
            .unwrap();
        assert!(updated, "Can update with a larger timestamp");

        let updated = cache
-
            .insert(&alice, node::Features::NONE, alias2, 1, timestamp + 2, [])
+
            .insert(
+
                &alice,
+
                1,
+
                node::Features::NONE,
+
                alias2,
+
                1,
+
                &ua2,
+
                timestamp + 2,
+
                [],
+
            )
            .unwrap();
        assert!(updated);

@@ -639,6 +731,7 @@ mod test {
        assert_eq!(node.timestamp, timestamp + 2);
        assert_eq!(node.pow, 1);
        assert_eq!(node.addrs, vec![ka]);
+
        assert_eq!(node.agent, ua2);
    }

    #[test]
@@ -647,6 +740,7 @@ mod test {
        let bob = arbitrary::gen::<NodeId>(1);
        let mut cache = Database::memory().unwrap();
        let timestamp = LocalTime::now().into();
+
        let ua = UserAgent::default();
        let features = node::Features::SEED;
        let alice_alias = Alias::new("alice");
        let bob_alias = Alias::new("bob");
@@ -666,15 +760,26 @@ mod test {
            cache
                .insert(
                    &alice,
+
                    1,
                    features,
                    alice_alias.clone(),
                    0,
+
                    &ua,
                    timestamp,
                    [ka.clone()],
                )
                .unwrap();
            cache
-
                .insert(&bob, features, bob_alias.clone(), 0, timestamp, [ka])
+
                .insert(
+
                    &bob,
+
                    1,
+
                    features,
+
                    bob_alias.clone(),
+
                    0,
+
                    &ua,
+
                    timestamp,
+
                    [ka],
+
                )
                .unwrap();
        }
        assert_eq!(cache.len().unwrap(), 6);
@@ -695,6 +800,7 @@ mod test {
        let mut cache = Database::memory().unwrap();
        let mut expected = Vec::new();
        let timestamp = LocalTime::now().into();
+
        let ua = UserAgent::default();
        let features = node::Features::SEED;
        let alias = Alias::new("alice");

@@ -711,11 +817,12 @@ mod test {
            };
            expected.push(AddressEntry {
                node: id,
+
                version: 3,
                penalty: Penalty::default(),
                address: ka.clone(),
            });
            cache
-
                .insert(&id, features, alias.clone(), 0, timestamp, [ka])
+
                .insert(&id, 3, features, alias.clone(), 0, &ua, timestamp, [ka])
                .unwrap();
        }

@@ -735,9 +842,19 @@ mod test {
        let mut cache = Database::memory().unwrap();
        let features = node::Features::SEED;
        let timestamp = Timestamp::from(LocalTime::now());
+
        let ua = UserAgent::default();

        cache
-
            .insert(&alice, features, Alias::new("alice"), 16, timestamp, [])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                Alias::new("alice"),
+
                16,
+
                &ua,
+
                timestamp,
+
                [],
+
            )
            .unwrap();
        let node = cache.get(&alice).unwrap().unwrap();
        assert_eq!(node.penalty, Penalty::default());
@@ -762,6 +879,7 @@ mod test {
    #[test]
    fn test_disconnected_ban() {
        let alice = arbitrary::gen::<NodeId>(1);
+
        let ua = UserAgent::default();
        let ip1: net::Ipv4Addr = [8, 8, 8, 8].into();
        let ip2: net::Ipv4Addr = [9, 9, 9, 9].into();
        let ka1 = arbitrary::gen::<KnownAddress>(1);
@@ -780,9 +898,11 @@ mod test {

        db.insert(
            &alice,
+
            1,
            features,
            Alias::new("alice"),
            16,
+
            &ua,
            timestamp,
            [ka1.clone(), ka2.clone()],
        )
modified radicle/src/node/config.rs
@@ -10,6 +10,9 @@ use crate::node;
use crate::node::policy::SeedingPolicy;
use crate::node::{Address, Alias, NodeId};

+
/// Peer-to-peer protocol version.
+
pub type ProtocolVersion = u8;
+

/// Default number of workers to spawn.
pub const DEFAULT_WORKERS: usize = 8;

@@ -63,14 +66,14 @@ pub enum Network {

impl Network {
    /// Bootstrap nodes for this network.
-
    pub fn bootstrap(&self) -> Vec<(Alias, ConnectAddress)> {
+
    pub fn bootstrap(&self) -> Vec<(Alias, ProtocolVersion, ConnectAddress)> {
        match self {
            Self::Main => [
                ("seed.radicle.garden", seeds::RADICLE_COMMUNITY_NODE.clone()),
                ("seed.radicle.xyz", seeds::RADICLE_TEAM_NODE.clone()),
            ]
            .into_iter()
-
            .map(|(a, s)| (Alias::new(a), s))
+
            .map(|(a, s)| (Alias::new(a), 1, s))
            .collect(),

            Self::Test => vec![],
modified radicle/src/node/db.rs
@@ -15,7 +15,9 @@ use std::{fmt, time};
use sqlite as sql;
use thiserror::Error;

-
use crate::node::{address, Address, Alias, Features, KnownAddress, NodeId, Timestamp};
+
use crate::node::{
+
    address, Address, Alias, Features, KnownAddress, NodeId, Timestamp, UserAgent, PROTOCOL_VERSION,
+
};
use crate::sql::transaction;

/// How long to wait for the database lock to be released before failing a read.
@@ -31,6 +33,7 @@ const MIGRATIONS: &[&str] = &[
    include_str!("db/migrations/3.sql"),
    include_str!("db/migrations/4.sql"),
    include_str!("db/migrations/5.sql"),
+
    include_str!("db/migrations/6.sql"),
];

#[derive(Error, Debug)]
@@ -131,15 +134,18 @@ impl Database {
        node: &NodeId,
        features: Features,
        alias: Alias,
+
        agent: &UserAgent,
        timestamp: Timestamp,
        addrs: impl IntoIterator<Item = &'a Address>,
    ) -> Result<Self, Error> {
        address::Store::insert(
            &mut self,
            node,
+
            PROTOCOL_VERSION,
            features,
            alias,
            0,
+
            agent,
            timestamp,
            addrs
                .into_iter()
added radicle/src/node/db/migrations/6.sql
@@ -0,0 +1,3 @@
+
-- Add the version and user-agent columns.
+
alter table "nodes" add column "version" integer not null default 1;
+
alter table "nodes" add column "agent" text not null default "/radicle/";
modified radicle/src/profile.rs
@@ -27,7 +27,7 @@ use crate::node::policy::config::store::Read;
use crate::node::{
    notifications, policy,
    policy::{Policy, Scope, SeedingPolicy},
-
    Alias, AliasStore, Handle as _, Node,
+
    Alias, AliasStore, Handle as _, Node, UserAgent,
};
use crate::prelude::{Did, NodeId, RepoId};
use crate::storage::git::transport;
@@ -319,6 +319,7 @@ impl Profile {
                &public_key,
                config.node.features(),
                config.node.alias.clone(),
+
                &UserAgent::default(),
                LocalTime::now().into(),
                config.node.external_addresses.iter(),
            )?;
modified radicle/src/sql.rs
@@ -6,7 +6,7 @@ use sqlite::Value;

use crate::identity::RepoId;
use crate::node;
-
use crate::node::Address;
+
use crate::node::{Address, UserAgent};

/// Run an SQL query inside a transaction.
/// Commits the transaction on success, and rolls back on error.
@@ -93,3 +93,20 @@ impl sql::BindableWithIndex for &Address {
        self.to_string().bind(stmt, i)
    }
}
+

+
impl TryFrom<&Value> for UserAgent {
+
    type Error = sql::Error;
+

+
    fn try_from(value: &Value) -> Result<Self, Self::Error> {
+
        match value {
+
            Value::String(ua) => UserAgent::from_str(ua).map_err(|e| sql::Error {
+
                code: None,
+
                message: Some(e.to_string()),
+
            }),
+
            _ => Err(sql::Error {
+
                code: None,
+
                message: Some("sql: invalid type for user-agent".to_owned()),
+
            }),
+
        }
+
    }
+
}
modified radicle/src/test/arbitrary.rs
@@ -19,7 +19,7 @@ use crate::identity::{
    Did,
};
use crate::node::address::{AddressType, Source};
-
use crate::node::{Address, Alias, KnownAddress, Timestamp};
+
use crate::node::{Address, Alias, KnownAddress, Timestamp, UserAgent};
use crate::storage;
use crate::storage::refs::{Refs, RefsAt, SignedRefs};
use crate::test::storage::{MockRepository, MockStorage};
@@ -324,3 +324,12 @@ impl Arbitrary for Timestamp {
        Self::try_from(u64::arbitrary(g).min(*Self::MAX)).unwrap()
    }
}
+

+
impl Arbitrary for UserAgent {
+
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
+
        UserAgent::from_str(
+
            format!("/radicle:1.{}.{}/", u8::arbitrary(g), u8::arbitrary(g)).as_str(),
+
        )
+
        .unwrap()
+
    }
+
}