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:
parent
ccc0297b5a
commit
088339850b
|
|
@ -2,7 +2,7 @@ mod id;
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::num::NonZeroUsize;
|
use std::num::{NonZeroU32, NonZeroUsize};
|
||||||
use std::ops::{Deref, Not};
|
use std::ops::{Deref, Not};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
@ -11,7 +11,7 @@ use nonempty::NonEmpty;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use radicle_cob::type_name::{TypeName, TypeNameParse};
|
use radicle_cob::type_name::{TypeName, TypeNameParse};
|
||||||
use radicle_git_ext::Oid;
|
use radicle_git_ext::Oid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{de, Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::canonical::formatter::CanonicalFormatter;
|
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;
|
pub const MAX_STRING_LENGTH: usize = 255;
|
||||||
/// Maximum number of a delegates in the identity document.
|
/// Maximum number of a delegates in the identity document.
|
||||||
pub const MAX_DELEGATES: usize = 255;
|
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)]
|
#[derive(Error, Debug)]
|
||||||
pub enum DocError {
|
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.
|
/// Identifies an identity document payload type.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
|
|
@ -231,6 +348,9 @@ impl Visibility {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RawDoc {
|
pub struct RawDoc {
|
||||||
|
/// Version of the identity document.
|
||||||
|
#[serde(default = "missing_version")]
|
||||||
|
version: Version,
|
||||||
/// The payload section.
|
/// The payload section.
|
||||||
pub payload: BTreeMap<PayloadId, Payload>,
|
pub payload: BTreeMap<PayloadId, Payload>,
|
||||||
/// The delegates section.
|
/// The delegates section.
|
||||||
|
|
@ -264,6 +384,7 @@ impl RawDoc {
|
||||||
serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
|
serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
version: IDENTITY_VERSION,
|
||||||
payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
|
payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
|
||||||
delegates,
|
delegates,
|
||||||
threshold,
|
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.
|
/// Get the project payload, if it exists and is valid, out of this document.
|
||||||
pub fn project(&self) -> Result<Project, PayloadError> {
|
pub fn project(&self) -> Result<Project, PayloadError> {
|
||||||
let value = self
|
let value = self
|
||||||
|
|
@ -319,6 +445,7 @@ impl RawDoc {
|
||||||
/// - [`RawDoc::threshold`]: ensure that it is in the range `[1, delegates.len()]`.
|
/// - [`RawDoc::threshold`]: ensure that it is in the range `[1, delegates.len()]`.
|
||||||
pub fn verified(self) -> Result<Doc, DocError> {
|
pub fn verified(self) -> Result<Doc, DocError> {
|
||||||
let RawDoc {
|
let RawDoc {
|
||||||
|
version,
|
||||||
payload,
|
payload,
|
||||||
delegates,
|
delegates,
|
||||||
threshold,
|
threshold,
|
||||||
|
|
@ -327,6 +454,7 @@ impl RawDoc {
|
||||||
let delegates = Delegates::new(delegates)?;
|
let delegates = Delegates::new(delegates)?;
|
||||||
let threshold = Threshold::new(threshold, &delegates)?;
|
let threshold = Threshold::new(threshold, &delegates)?;
|
||||||
Ok(Doc {
|
Ok(Doc {
|
||||||
|
version,
|
||||||
payload,
|
payload,
|
||||||
delegates,
|
delegates,
|
||||||
threshold,
|
threshold,
|
||||||
|
|
@ -496,6 +624,8 @@ impl Threshold {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[serde(try_from = "RawDoc")]
|
#[serde(try_from = "RawDoc")]
|
||||||
pub struct Doc {
|
pub struct Doc {
|
||||||
|
#[serde(skip_serializing_if = "Version::skip_serializing")]
|
||||||
|
version: Version,
|
||||||
payload: BTreeMap<PayloadId, Payload>,
|
payload: BTreeMap<PayloadId, Payload>,
|
||||||
delegates: Delegates,
|
delegates: Delegates,
|
||||||
threshold: Threshold,
|
threshold: Threshold,
|
||||||
|
|
@ -514,6 +644,7 @@ impl Doc {
|
||||||
serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
|
serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
version: IDENTITY_VERSION,
|
||||||
payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
|
payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
|
||||||
delegates: Delegates(NonEmpty::new(delegate)),
|
delegates: Delegates(NonEmpty::new(delegate)),
|
||||||
threshold: Threshold(NonZeroUsize::MIN),
|
threshold: Threshold(NonZeroUsize::MIN),
|
||||||
|
|
@ -530,12 +661,14 @@ impl Doc {
|
||||||
/// re-verifying.
|
/// re-verifying.
|
||||||
pub fn edit(self) -> RawDoc {
|
pub fn edit(self) -> RawDoc {
|
||||||
let Doc {
|
let Doc {
|
||||||
|
version,
|
||||||
payload,
|
payload,
|
||||||
delegates,
|
delegates,
|
||||||
threshold,
|
threshold,
|
||||||
visibility,
|
visibility,
|
||||||
} = self;
|
} = self;
|
||||||
RawDoc {
|
RawDoc {
|
||||||
|
version,
|
||||||
payload,
|
payload,
|
||||||
delegates: delegates.into(),
|
delegates: delegates.into(),
|
||||||
threshold: threshold.into(),
|
threshold: threshold.into(),
|
||||||
|
|
@ -554,6 +687,11 @@ impl Doc {
|
||||||
raw.verified()
|
raw.verified()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the version of the document.
|
||||||
|
pub fn version(&self) -> &Version {
|
||||||
|
&self.version
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the associated payloads for this [`Doc`].
|
/// Return the associated payloads for this [`Doc`].
|
||||||
pub fn payload(&self) -> &BTreeMap<PayloadId, Payload> {
|
pub fn payload(&self) -> &BTreeMap<PayloadId, Payload> {
|
||||||
&self.payload
|
&self.payload
|
||||||
|
|
@ -726,6 +864,7 @@ impl Doc {
|
||||||
mod test {
|
mod test {
|
||||||
use radicle_crypto::test::signer::MockSigner;
|
use radicle_crypto::test::signer::MockSigner;
|
||||||
use radicle_crypto::Signer as _;
|
use radicle_crypto::Signer as _;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::assert_matches;
|
use crate::assert_matches;
|
||||||
use crate::rad;
|
use crate::rad;
|
||||||
|
|
@ -769,6 +908,106 @@ mod test {
|
||||||
assert_matches!(doc.verified(), Err(DocError::Delegates(DelegatesError(_))));
|
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::<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]
|
#[test]
|
||||||
fn test_canonical_example() {
|
fn test_canonical_example() {
|
||||||
let tempdir = tempfile::tempdir().unwrap();
|
let tempdir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue