Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle: introduce identity document version
Fintan Halpenny committed 1 year ago
commit 088339850b8a355b954a26856a8bcbf66350f44c
parent ccc0297b5acb5a375cebfa7868dd9b891462fe03
1 file changed +241 -2
modified radicle/src/identity/doc.rs
@@ -2,7 +2,7 @@ mod id;

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
-
use std::num::NonZeroUsize;
+
use std::num::{NonZeroU32, NonZeroUsize};
use std::ops::{Deref, Not};
use std::path::Path;
use std::str::FromStr;
@@ -11,7 +11,7 @@ use nonempty::NonEmpty;
use once_cell::sync::Lazy;
use radicle_cob::type_name::{TypeName, TypeNameParse};
use radicle_git_ext::Oid;
-
use serde::{Deserialize, Serialize};
+
use serde::{de, Deserialize, Serialize};
use thiserror::Error;

use crate::canonical::formatter::CanonicalFormatter;
@@ -32,6 +32,9 @@ pub static PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json"));
pub const MAX_STRING_LENGTH: usize = 255;
/// Maximum number of a delegates in the identity document.
pub const MAX_DELEGATES: usize = 255;
+
/// The current, most recent version of the identity document.
+
// SAFETY: identity version should never be 0, so we can use `unsafe` here
+
pub const IDENTITY_VERSION: Version = Version(unsafe { NonZeroU32::new_unchecked(1) });

#[derive(Error, Debug)]
pub enum DocError {
@@ -69,6 +72,120 @@ impl DocError {
    }
}

+
/// The version number of the identity document.
+
///
+
/// It is used to ensure compatibility when parsing identity documents.
+
///
+
/// If an invalid version is found – either the `0` version, or an unrecognized
+
/// future version – the parsing of a version will fail.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+
pub struct Version(NonZeroU32);
+

+
impl Version {
+
    /// Construct a [`Version`].
+
    ///
+
    /// # Errors
+
    ///
+
    ///   - `n` is 0
+
    ///   - `n` is greater than the latest version, specified by
+
    ///     [`IDENTITY_VERSION`].
+
    pub fn new(n: u32) -> Result<Version, VersionError> {
+
        match NonZeroU32::new(n) {
+
            None => Err(VersionError::ZeroVersion),
+
            Some(n) if n > IDENTITY_VERSION.into() => Err(VersionError::UnkownVersion(n)),
+
            Some(n) => Ok(Version(n)),
+
        }
+
    }
+

+
    /// Return the underlying [`NonZeroU32`] number of the `Version`.
+
    pub fn number(&self) -> NonZeroU32 {
+
        self.0
+
    }
+

+
    /// Check if the provided version is part of the set of accepted versions.
+
    pub fn is_valid_version(v: &u32) -> bool {
+
        0 < *v && *v <= IDENTITY_VERSION.into()
+
    }
+

+
    /// Helper for skipping the serialization of the version if `version <= 1`.
+
    ///
+
    /// Note that we shouldn't allow `version: 0`, but there is no harm in
+
    /// skipping it anyway.
+
    fn skip_serializing(&self) -> bool {
+
        u32::from(*self) <= 1
+
    }
+
}
+

+
impl From<Version> for NonZeroU32 {
+
    fn from(Version(n): Version) -> Self {
+
        n
+
    }
+
}
+

+
impl From<Version> for u32 {
+
    fn from(Version(n): Version) -> Self {
+
        n.into()
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[non_exhaustive]
+
pub enum VersionError {
+
    #[error("the version 0 is not supported")]
+
    ZeroVersion,
+
    #[error("unknown identity document version {0}, only version {IDENTITY_VERSION} is supported")]
+
    UnkownVersion(NonZeroU32),
+
}
+

+
impl VersionError {
+
    /// Provide a verbose error.
+
    ///
+
    /// This will give a user more information on how to upgrade to a newer
+
    /// version of an identity document, if there is one.
+
    pub fn verbose(&self) -> String {
+
        const UNKOWN_VERSION_ERROR: &str = r#"
+
Perhaps a new version of the identity document is released which is not supported by the current client.
+
See https://radicle.xyz for the latest versions of Radicle.
+
The CLI command `rad id migrate` will help to migrate to an up-to-date versions."#;
+

+
        match self {
+
            err @ Self::ZeroVersion => err.to_string(),
+
            err @ Self::UnkownVersion(_) => format!("{err}{UNKOWN_VERSION_ERROR}"),
+
        }
+
    }
+
}
+

+
impl TryFrom<u32> for Version {
+
    type Error = VersionError;
+

+
    fn try_from(n: u32) -> Result<Self, Self::Error> {
+
        Version::new(n)
+
    }
+
}
+

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

+
impl<'de> Deserialize<'de> for Version {
+
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+
    where
+
        D: serde::Deserializer<'de>,
+
    {
+
        u32::deserialize(deserializer)
+
            .and_then(|v| Version::new(v).map_err(|e| de::Error::custom(e.to_string())))
+
    }
+
}
+

+
/// Used for [`Deserialize`] of a [`Version`] in [`RawDoc`], so that
+
/// deserializing a missing version results in `Version(1)`.
+
fn missing_version() -> Version {
+
    // N.B. the default version is `1` which is non-zero so unsafe is fine here
+
    unsafe { Version(NonZeroU32::new_unchecked(1)) }
+
}
+

/// Identifies an identity document payload type.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
@@ -231,6 +348,9 @@ impl Visibility {
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RawDoc {
+
    /// Version of the identity document.
+
    #[serde(default = "missing_version")]
+
    version: Version,
    /// The payload section.
    pub payload: BTreeMap<PayloadId, Payload>,
    /// The delegates section.
@@ -264,6 +384,7 @@ impl RawDoc {
            serde_json::to_value(project).expect("Doc::initial: payload must be serializable");

        Self {
+
            version: IDENTITY_VERSION,
            payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
            delegates,
            threshold,
@@ -271,6 +392,11 @@ impl RawDoc {
        }
    }

+
    /// Get the version of the document.
+
    pub fn version(&self) -> &Version {
+
        &self.version
+
    }
+

    /// Get the project payload, if it exists and is valid, out of this document.
    pub fn project(&self) -> Result<Project, PayloadError> {
        let value = self
@@ -319,6 +445,7 @@ impl RawDoc {
    ///  - [`RawDoc::threshold`]: ensure that it is in the range `[1, delegates.len()]`.
    pub fn verified(self) -> Result<Doc, DocError> {
        let RawDoc {
+
            version,
            payload,
            delegates,
            threshold,
@@ -327,6 +454,7 @@ impl RawDoc {
        let delegates = Delegates::new(delegates)?;
        let threshold = Threshold::new(threshold, &delegates)?;
        Ok(Doc {
+
            version,
            payload,
            delegates,
            threshold,
@@ -496,6 +624,8 @@ impl Threshold {
#[serde(rename_all = "camelCase")]
#[serde(try_from = "RawDoc")]
pub struct Doc {
+
    #[serde(skip_serializing_if = "Version::skip_serializing")]
+
    version: Version,
    payload: BTreeMap<PayloadId, Payload>,
    delegates: Delegates,
    threshold: Threshold,
@@ -514,6 +644,7 @@ impl Doc {
            serde_json::to_value(project).expect("Doc::initial: payload must be serializable");

        Self {
+
            version: IDENTITY_VERSION,
            payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
            delegates: Delegates(NonEmpty::new(delegate)),
            threshold: Threshold(NonZeroUsize::MIN),
@@ -530,12 +661,14 @@ impl Doc {
    /// re-verifying.
    pub fn edit(self) -> RawDoc {
        let Doc {
+
            version,
            payload,
            delegates,
            threshold,
            visibility,
        } = self;
        RawDoc {
+
            version,
            payload,
            delegates: delegates.into(),
            threshold: threshold.into(),
@@ -554,6 +687,11 @@ impl Doc {
        raw.verified()
    }

+
    /// Get the version of the document.
+
    pub fn version(&self) -> &Version {
+
        &self.version
+
    }
+

    /// Return the associated payloads for this [`Doc`].
    pub fn payload(&self) -> &BTreeMap<PayloadId, Payload> {
        &self.payload
@@ -726,6 +864,7 @@ impl Doc {
mod test {
    use radicle_crypto::test::signer::MockSigner;
    use radicle_crypto::Signer as _;
+
    use serde_json::json;

    use crate::assert_matches;
    use crate::rad;
@@ -770,6 +909,106 @@ mod test {
    }

    #[test]
+
    fn test_is_valid_version() {
+
        // 0 is not a valid version
+
        assert!(!Version::is_valid_version(&0));
+

+
        // Ensures that the latest version is always valid
+
        let current = IDENTITY_VERSION.number();
+
        assert!(Version::is_valid_version(&current.into()));
+

+
        // Ensures that the next version is not valid because we have not
+
        // defined it yet
+
        let next = current.checked_add(1).unwrap();
+
        assert!(!Version::is_valid_version(&next.into()));
+
    }
+

+
    #[test]
+
    fn test_future_version_error() {
+
        let v = Version(NonZeroU32::MAX).to_string();
+
        assert_eq!(
+
            serde_json::from_str::<Version>(&v)
+
                .expect_err("should fail to deserialize")
+
                .to_string(),
+
            VersionError::UnkownVersion(NonZeroU32::MAX).to_string(),
+
        )
+
    }
+

+
    #[test]
+
    fn test_parse_version() {
+
        // Original document before introducing the version field
+
        let v1 = json!(
+
            {
+
                "payload": {
+
                    "xyz.radicle.project": {
+
                        "defaultBranch": "master",
+
                        "description": "Radicle Heartwood Protocol & Stack",
+
                        "name": "heartwood"
+
                    }
+
                },
+
                "delegates": [
+
                    "did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
+
                    "did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
+
                    "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM"
+
                ],
+
                "threshold": 1
+
            }
+
        );
+

+
        // Deserializing the `RawDoc` should not fail and should include the
+
        // `IDENTITY_VERSION`.
+
        let doc = serde_json::from_str::<RawDoc>(&v1.to_string()).unwrap();
+
        let payload = [(
+
            PayloadId::project(),
+
            Payload {
+
                value: json!({
+
                    "name": "heartwood",
+
                    "description": "Radicle Heartwood Protocol & Stack",
+
                    "defaultBranch": "master",
+
                }),
+
            },
+
        )]
+
        .into_iter()
+
        .collect::<BTreeMap<_, _>>();
+
        let delegates = vec![
+
            "did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"
+
                .parse::<Did>()
+
                .unwrap(),
+
            "did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW"
+
                .parse::<Did>()
+
                .unwrap(),
+
            "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM"
+
                .parse::<Did>()
+
                .unwrap(),
+
        ];
+
        // And this is the expected outcome of the deserialization
+
        assert_eq!(
+
            doc,
+
            RawDoc {
+
                version: IDENTITY_VERSION,
+
                payload: payload.clone(),
+
                delegates: delegates.clone(),
+
                threshold: 1,
+
                visibility: Visibility::Public,
+
            }
+
        );
+

+
        // Deserializing into `Doc` should also succeed.
+
        let verified = serde_json::from_str::<Doc>(&v1.to_string()).unwrap();
+
        let delegates = Delegates(NonEmpty::from_vec(delegates).unwrap());
+
        assert_eq!(
+
            verified,
+
            Doc {
+
                version: IDENTITY_VERSION,
+
                threshold: Threshold::new(1, &delegates).unwrap(),
+
                payload: payload.clone(),
+
                delegates,
+
                visibility: Visibility::Public,
+
            }
+
        );
+
    }
+

+
    #[test]
    fn test_canonical_example() {
        let tempdir = tempfile::tempdir().unwrap();
        let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();