From 088339850b8a355b954a26856a8bcbf66350f44c Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Tue, 15 Oct 2024 12:40:26 +0100 Subject: [PATCH] radicle: introduce identity document version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change introduces versioning to the identity document format. The `Version` type is introduced to capture this. Importantly, it is very restricted in its construction and API. `Version::new` provides a smart constructor to ensure that it is only ever constructed within the expected bounds of `0 < v <= IDENTITY_VERSION`, where `IDENTITY_VERSION` is the constant for the current version. Note that serializing of the `Doc::version` is skipped to preserve the `Oid` outputs of all the tests – which means that the first `version` is always implicit and is not really expected to exist in the serialized forms of the identity document. --- radicle/src/identity/doc.rs | 243 +++++++++++++++++++++++++++++++++++- 1 file changed, 241 insertions(+), 2 deletions(-) diff --git a/radicle/src/identity/doc.rs b/radicle/src/identity/doc.rs index e47702f2..1beb3600 100644 --- a/radicle/src/identity/doc.rs +++ b/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 { + 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 for NonZeroU32 { + fn from(Version(n): Version) -> Self { + n + } +} + +impl From 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 for Version { + type Error = VersionError; + + fn try_from(n: u32) -> Result { + 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(deserializer: D) -> Result + 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, /// 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 { 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 { 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, 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 { &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; @@ -769,6 +908,106 @@ mod test { assert_matches!(doc.verified(), Err(DocError::Delegates(DelegatesError(_)))); } + #[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(¤t.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::(&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::(&v1.to_string()).unwrap(); + let payload = [( + PayloadId::project(), + Payload { + value: json!({ + "name": "heartwood", + "description": "Radicle Heartwood Protocol & Stack", + "defaultBranch": "master", + }), + }, + )] + .into_iter() + .collect::>(); + let delegates = vec![ + "did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT" + .parse::() + .unwrap(), + "did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW" + .parse::() + .unwrap(), + "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM" + .parse::() + .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::(&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();