radicle: introduce identity document version

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.
This commit is contained in:
Fintan Halpenny 2024-10-15 12:40:26 +01:00 committed by cloudhead
parent ccc0297b5a
commit 088339850b
No known key found for this signature in database
1 changed files with 241 additions and 2 deletions

View File

@ -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;
@ -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(&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();