Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: Add Version to User Agent
Lorenz Leutgeb committed 27 days ago
commit 22b2871f64ecf34a22d32add0dd59a0c7c96ad10
parent 48551cd
15 files changed +182 -44
modified CHANGELOG.md
@@ -15,6 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
  using `--verbose`, and the full OID when using `--verbose`.
  These ranges make using `git range-diff` a lot easier, since you can copy
  the range from each revision you want to compare.
+
- Nodes will now advertise the version of Radicle they are running in the node
+
  announcement as part of the "user agent", which is shared among the network.
+
  For example, the value that will be shared by Radicle 1.9.0 will be
+
  `/radicle:1.9.0/`. This value is also customizable by node operators via the
+
  configuration value `node.userAgent`. Refer to `rad config schema` for more
+
  information on the possible values. Operators that choose to set a custom
+
  value are asked to keep the substring `/radicle:{YOUR_VERSION}/` which allows
+
  for better telemetry regarding version distribution on the network.
+
  To opt-out of sending any meaningful user agent, set `node.userAgent = null`.

## 1.8.0

modified crates/radicle-cli/examples/rad-config.md
@@ -203,6 +203,17 @@ $ rad config schema
          "description": "Node alias.",
          "$ref": "#/$defs/Alias"
        },
+
        "userAgent": {
+
          "description": "User agent string to advertise in the node announcement, which is sent out to other nodes.",
+
          "anyOf": [
+
            {
+
              "$ref": "#/$defs/UserAgent"
+
            },
+
            {
+
              "type": "null"
+
            }
+
          ]
+
        },
        "listen": {
          "description": "Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on.",
          "type": "array",
@@ -329,6 +340,17 @@ $ rad config schema
      "description": "Node alias, i.e. a short and memorable name for it.",
      "type": "string"
    },
+
    "UserAgent": {
+
      "description": "A user agent string that starts and ends with the symbol '/', and contains segments of the form 'client:version' separated by '/'. The client and version parts must be non-empty, and must consist of printable ASCII characters excluding '/' and ':'. The entire string must be at most 64 characters long.",
+
      "type": "string",
+
      "minLength": 3,
+
      "maxLength": 64,
+
      "examples": [
+
        "/radicle:1.9.0/",
+
        "/example:42.0.0/other-client:2.3.4/"
+
      ],
+
      "pattern": "^/([^:///s]+((:[^:///s]+))?/)+$"
+
    },
    "PeerConfig": {
      "description": "Peer configuration.",
      "oneOf": [
modified crates/radicle-cli/tests/util/environment.rs
@@ -5,11 +5,11 @@ use radicle::cob::cache::COBS_DB_FILE;
use radicle::crypto::ssh::{Keystore, keystore::MemorySigner};
use radicle::crypto::{KeyPair, Seed};
use radicle::git;
+
use radicle::node;
use radicle::node::policy::store as policy;
-
use radicle::node::{self, UserAgent};
use radicle::node::{Alias, Config, POLICIES_DB_FILE};
+
use radicle::profile;
use radicle::profile::Home;
-
use radicle::profile::{self};
use radicle::storage::git::transport;
use radicle::{Profile, Storage};
use radicle_localtime::LocalTime;
@@ -161,7 +161,7 @@ impl Environment {
                &keypair.pk.into(),
                config.node.features(),
                &alias,
-
                &UserAgent::default(),
+
                &config.node.user_agent(),
                LocalTime::now().into(),
                config.node.external_addresses.iter(),
            )
modified crates/radicle-node/src/lib.rs
@@ -19,15 +19,11 @@ pub mod tests;

extern crate radicle_localtime as localtime;

-
use std::str::FromStr;
-
use std::sync::LazyLock;
-

use radicle::version::Version;

pub use localtime::{LocalDuration, LocalTime};
pub use radicle::node::Link;
pub use radicle::node::PROTOCOL_VERSION;
-
pub use radicle::node::UserAgent;
pub use radicle::prelude::Timestamp;
pub use radicle::{collections, crypto, git, identity, node, profile, rad, storage};
pub use runtime::Runtime;
@@ -40,12 +36,6 @@ pub const VERSION: Version = Version {
    timestamp: env!("SOURCE_DATE_EPOCH"),
};

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

pub mod prelude {
    pub use crate::crypto::{PublicKey, Signature};
    pub use crate::identity::{Did, RepoId};
modified crates/radicle-node/src/runtime.rs
@@ -3,6 +3,7 @@ pub mod thread;

use std::fmt::Debug;
use std::path::PathBuf;
+
use std::str::FromStr as _;
use std::{fs, io, net};

#[cfg(unix)]
@@ -197,7 +198,8 @@ impl Runtime {
                        radicle::node::Features::SEED,
                        &alias,
                        0,
-
                        &UserAgent::default(),
+
                        &UserAgent::from_str("/radicle/runtime/bootstrap/")
+
                            .expect("valid user agent"),
                        clock.into(),
                        [node::KnownAddress::new(addr, address::Source::Bootstrap)],
                    )?;
modified crates/radicle-node/src/test/gossip.rs
@@ -1,5 +1,3 @@
-
use std::str::FromStr;
-

use radicle::node;
use radicle::node::UserAgent;
use radicle::node::device::Device;
@@ -34,7 +32,7 @@ pub fn messages(count: usize, now: LocalTime, delta: LocalDuration) -> Vec<Messa
                alias: node::Alias::new(r#gen::string(5)),
                addresses: None.into(),
                nonce: 0,
-
                agent: UserAgent::from_str("/radicle:test/").unwrap(),
+
                agent: UserAgent::test(),
            }
            .solve(0)
            .unwrap(),
modified crates/radicle-node/src/test/peer.rs
@@ -320,7 +320,7 @@ where
                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(),
+
                agent: UserAgent::test(),
            }
            .solve(0)
            .unwrap(),
modified crates/radicle-protocol/src/service/gossip.rs
@@ -1,21 +1,11 @@
pub mod store;

-
use std::str::FromStr;
-
use std::sync::LazyLock;
-

use super::*;
use crate::bounded::BoundedVec;
use radicle::node::PROTOCOL_VERSION;
-
use radicle::node::UserAgent;

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

-
/// This node's user agent string.
-
pub static PROTOCOL_VERSION_STRING: LazyLock<UserAgent> = LazyLock::new(|| {
-
    FromStr::from_str(format!("/radicle:{PROTOCOL_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();
@@ -24,7 +14,9 @@ pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement {
        .clone()
        .try_into()
        .expect("external addresses are within the limit");
-
    let agent = PROTOCOL_VERSION_STRING.clone();
+

+
    let agent = config.user_agent();
+

    let version = PROTOCOL_VERSION;

    NodeAnnouncement {
modified crates/radicle-protocol/src/service/message.rs
@@ -1,3 +1,4 @@
+
use std::str::FromStr;
use std::{fmt, mem};

use bytes::{Buf, BufMut};
@@ -142,7 +143,9 @@ impl wire::Decode for NodeAnnouncement {
        let nonce = u64::decode(buf)?;
        let agent = match UserAgent::decode(buf) {
            Ok(ua) => ua,
-
            Err(wire::Error::UnexpectedEnd { .. }) => UserAgent::default(),
+
            Err(wire::Error::UnexpectedEnd { .. }) => {
+
                UserAgent::from_str("/radicle/message/truncated/").expect("valid user agent")
+
            }
            Err(e) => return Err(e),
        };

@@ -683,7 +686,6 @@ impl qcheck::Arbitrary for ZeroBytes {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
-
    use std::str::FromStr;

    use fastrand;
    use localtime::LocalTime;
@@ -784,12 +786,12 @@ mod tests {
            alias: Alias::new("alice"),
            addresses: BoundedVec::new(),
            nonce: 0,
-
            agent: UserAgent::from_str("/heartwood:1.0.0/").unwrap(),
+
            agent: UserAgent::test(),
        };

-
        assert_eq!(ann.work(), 1);
+
        assert_eq!(ann.work(), 2);
        assert_eq!(ann.clone().solve(1).unwrap().work(), 1);
-
        assert_eq!(ann.clone().solve(8).unwrap().work(), 10);
+
        assert_eq!(ann.clone().solve(8).unwrap().work(), 8);
        assert_eq!(ann.solve(14).unwrap().work(), 14);
    }
}
added crates/radicle/build.rs
@@ -0,0 +1 @@
+
../../build.rs

\ No newline at end of file
modified crates/radicle/src/node.rs
@@ -220,18 +220,56 @@ impl PartialOrd for SyncStatus {

/// Node user agent.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)]
-
pub struct UserAgent(String);
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
#[cfg_attr(
+
    feature = "schemars",
+
    schemars(description = "\
+
    A user agent string that starts and ends with the symbol '/', and contains segments of the form 'client:version' separated by '/'. \
+
    The client and version parts must be non-empty, and must consist of printable ASCII characters excluding '/' and ':'. \
+
    The entire string must be at most 64 characters long.",
+
    extend(
+
        "examples" = [
+
            "/radicle:1.9.0/",
+
            "/example:42.0.0/other-client:2.3.4/",
+
        ],
+
        "pattern" = r"^/([^:/\s]+((:[^:/\s]+))?/)+$",
+
    ),
+
))]
+
pub struct UserAgent(
+
    #[cfg_attr(feature = "schemars", schemars(
+
        length(min = 3, max = UserAgent::LEN_MAX),
+
    ))]
+
    String,
+
);

impl UserAgent {
+
    const LEN_MAX: usize = 64;
+

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

+
    /// Return a user agent that can be used for testing purposes.
+
    #[cfg(any(test, feature = "test"))]
+
    pub fn test() -> Self {
+
        UserAgent("/radicle:test/".to_owned())
+
    }
}

impl Default for UserAgent {
    fn default() -> Self {
-
        UserAgent(String::from("/radicle/"))
+
        const NAME: &str = env!("CARGO_PKG_NAME");
+
        const VERSION: &str = env!("RADICLE_VERSION");
+

+
        // The length check can be performed at compile time.
+
        #[allow(clippy::int_plus_one)]
+
        const _: () = assert!(1 + NAME.len() + 1 + VERSION.len() + 1 <= UserAgent::LEN_MAX);
+

+
        // All other checks are performed by the `FromStr` implementation
+
        // at run time.
+
        UserAgent::from_str(&format!("/{NAME}:{VERSION}/"))
+
            .expect("default user agent should be valid")
    }
}

@@ -247,7 +285,7 @@ impl FromStr for UserAgent {
    fn from_str(input: &str) -> Result<Self, Self::Err> {
        let reserved = ['/', ':'];

-
        if input.len() > 64 {
+
        if input.len() > UserAgent::LEN_MAX {
            return Err(input.to_owned());
        }
        let Some(s) = input.strip_prefix('/') else {
@@ -1502,6 +1540,7 @@ mod test {
        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("/heartwood:1.8.0-6-gf223afd9d-dirty/").is_ok());

        assert!(UserAgent::from_str("/:/").is_err());
        assert!(UserAgent::from_str("//").is_err());
modified crates/radicle/src/node/config.rs
@@ -8,8 +8,8 @@ use localtime::LocalDuration;
use serde::{Deserialize, Serialize};
use serde_json as json;

-
use crate::node;
use crate::node::policy::SeedingPolicy;
+
use crate::node::{self, UserAgent};
use crate::node::{Address, Alias, NodeId};
use crate::storage::refs::FeatureLevel;

@@ -530,6 +530,12 @@ impl Fetch {
pub struct Config {
    /// Node alias.
    pub alias: Alias,
+
    /// User agent string to advertise in the node announcement, which is sent out to other nodes.
+
    #[serde(
+
        default = "crate::serde_ext::some_default::<UserAgent>",
+
        skip_serializing_if = "crate::serde_ext::is_some_default"
+
    )]
+
    pub user_agent: Option<UserAgent>,
    /// Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on.
    #[serde(default)]
    #[cfg_attr(feature = "schemars", schemars(example = &"127.0.0.1:8776"))]
@@ -603,6 +609,7 @@ impl Config {
    pub fn new(alias: Alias) -> Self {
        Self {
            alias,
+
            user_agent: Some(UserAgent::default()),
            peers: PeerConfig::default(),
            listen: vec![],
            connect: HashSet::default(),
@@ -652,6 +659,15 @@ impl Config {
    pub fn features(&self) -> node::Features {
        node::Features::SEED
    }
+

+
    /// Return the configured user agent, if set. Otherwise fall back to the
+
    /// unintetesting value `"/radicle/"`.
+
    pub fn user_agent(&self) -> UserAgent {
+
        match self.user_agent.as_ref() {
+
            Some(agent) => agent.clone(),
+
            None => UserAgent::from_str("/radicle/").expect("valid user agent"),
+
        }
+
    }
}

#[derive(Clone, Copy, Debug, Display, Deserialize, Serialize, From)]
@@ -781,7 +797,7 @@ wrapper!(
#[allow(clippy::unwrap_used)]
mod test {
    use super::{DefaultSeedingPolicy, Scope};
-
    use crate::node::{Alias, policy};
+
    use crate::node::{Alias, UserAgent, policy};
    use serde_json::json;

    #[test]
@@ -974,4 +990,56 @@ mod test {
        .unwrap();
        assert_eq!(super::AddressConfig::Drop, actual.onion);
    }
+

+
    #[test]
+
    fn user_agent_opt_out() {
+
        let actual: super::Config = serde_json::from_value(json!({
+
            "alias": "radicle",
+
            "userAgent": null,
+
        }))
+
        .unwrap();
+
        assert_eq!(None, actual.user_agent);
+
    }
+

+
    #[test]
+
    fn user_agent_default() {
+
        let actual: super::Config = serde_json::from_value(json!({
+
            "alias": "radicle",
+
        }))
+
        .unwrap();
+
        assert_eq!(Some(UserAgent::default()), actual.user_agent);
+
    }
+

+
    #[test]
+
    fn user_agent_custom() {
+
        use std::str::FromStr as _;
+

+
        let actual: super::Config = serde_json::from_value(json!({
+
            "alias": "radicle",
+
            "userAgent": "/example:0.1.0/",
+
        }))
+
        .unwrap();
+
        assert_eq!(
+
            Some(UserAgent::from_str("/example:0.1.0/").unwrap()),
+
            actual.user_agent
+
        );
+
    }
+

+
    #[test]
+
    fn user_agent_default_explicit() {
+
        use std::str::FromStr as _;
+

+
        let default_as_string = UserAgent::default().to_string();
+
        assert!(default_as_string.contains(":"));
+

+
        let actual: super::Config = serde_json::from_value(json!({
+
            "alias": "radicle",
+
            "userAgent": default_as_string,
+
        }))
+
        .unwrap();
+
        assert_eq!(
+
            Some(UserAgent::from_str(&default_as_string).unwrap()),
+
            actual.user_agent
+
        );
+
    }
}
modified crates/radicle/src/profile.rs
@@ -28,9 +28,7 @@ use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{Keystore, Passphrase, keystore};
use crate::node::device::{BoxedDevice, Device};
use crate::node::policy::config::store::Read;
-
use crate::node::{
-
    Alias, AliasStore, Handle as _, Node, UserAgent, notifications, policy, policy::Scope,
-
};
+
use crate::node::{Alias, AliasStore, Handle as _, Node, notifications, policy, policy::Scope};
use crate::prelude::{Did, NodeId, RepoId};
use crate::storage::ReadRepository;
use crate::storage::git::Storage;
@@ -257,7 +255,7 @@ impl Profile {
            &public_key,
            config.node.features(),
            &config.node.alias,
-
            &UserAgent::default(),
+
            &config.node.user_agent(),
            LocalTime::now().into(),
            config.node.external_addresses.iter(),
        )?;
modified crates/radicle/src/serde_ext.rs
@@ -56,3 +56,15 @@ where
    use serde::Deserialize as _;
    Ok(Option::deserialize(deserializer)?.unwrap_or_default())
}
+

+
/// A helper that makes it easy to use `Option<T>` with the `serde(default)`
+
/// attribute, in case a default of `Some(T::default())` is desired instead
+
/// of `None`.
+
pub(crate) fn some_default<T: Default>() -> Option<T> {
+
    Some(T::default())
+
}
+

+
/// Like [`is_default`], but for use in combination with [`some_default`].
+
pub(crate) fn is_some_default<T: Default + PartialEq>(t: &Option<T>) -> bool {
+
    t.as_ref() == Some(&T::default())
+
}
modified crates/radicle/src/test/arbitrary.rs
@@ -276,7 +276,12 @@ impl Arbitrary for Timestamp {
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(),
+
            format!(
+
                "/radicle:1.{}.{}/fake/arbitrary/",
+
                u8::arbitrary(g),
+
                u8::arbitrary(g)
+
            )
+
            .as_str(),
        )
        .unwrap()
    }