1220 lines
37 KiB
Rust
1220 lines
37 KiB
Rust
pub mod update;
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::fmt;
|
||
use std::num::{NonZeroU32, NonZeroUsize};
|
||
use std::ops::{Deref, Not};
|
||
use std::path::Path;
|
||
use std::str::FromStr;
|
||
use std::sync::LazyLock;
|
||
|
||
use crate::git::Oid;
|
||
use nonempty::NonEmpty;
|
||
use radicle_cob::type_name::{TypeName, TypeNameParse};
|
||
use serde::{Deserialize, Serialize, de};
|
||
use thiserror::Error;
|
||
|
||
use crate::canonical::formatter::CanonicalFormatter;
|
||
use crate::cob::identity;
|
||
use crate::crypto;
|
||
use crate::crypto::Signature;
|
||
use crate::git;
|
||
use crate::git::canonical::rules;
|
||
use crate::git::raw::ErrorExt as _;
|
||
use crate::identity::{Did, project::Project};
|
||
use crate::node::device::Device;
|
||
use crate::storage;
|
||
use crate::storage::{ReadRepository, RepositoryError};
|
||
|
||
pub use crypto::PublicKey;
|
||
pub use radicle_core::repo::*;
|
||
|
||
use super::CanonicalRefs;
|
||
use super::crefs::{self, RawCanonicalRefs};
|
||
|
||
/// Path to the identity document in the identity branch.
|
||
pub static PATH: LazyLock<&Path> = LazyLock::new(|| Path::new("radicle.json"));
|
||
/// Maximum length of a string in the identity document.
|
||
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(NonZeroU32::new(1).unwrap());
|
||
|
||
#[derive(Error, Debug)]
|
||
pub enum DocError {
|
||
#[error("json: {0}")]
|
||
Json(#[from] serde_json::Error),
|
||
#[error(transparent)]
|
||
Delegates(#[from] DelegatesError),
|
||
#[error(transparent)]
|
||
Threshold(#[from] ThresholdError),
|
||
#[error("git: {0}")]
|
||
Git(#[from] git::raw::Error),
|
||
#[error("missing identity document")]
|
||
Missing,
|
||
}
|
||
|
||
#[derive(Debug, Error)]
|
||
#[error("invalid delegates: {0}")]
|
||
pub struct DelegatesError(&'static str);
|
||
|
||
#[derive(Debug, Error)]
|
||
#[error("invalid threshold `{0}`: {1}")]
|
||
pub struct ThresholdError(usize, &'static str);
|
||
|
||
impl DocError {
|
||
/// Whether this error is caused by the document not being found.
|
||
pub fn is_not_found(&self) -> bool {
|
||
match self {
|
||
Self::Git(e) => e.is_not_found(),
|
||
_ => false,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum DefaultBranchRuleError {
|
||
#[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
|
||
Payload(#[from] PayloadError),
|
||
}
|
||
|
||
/// 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::UnknownVersion(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")]
|
||
UnknownVersion(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 UNKNOWN_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::UnknownVersion(_) => format!("{err}{UNKNOWN_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)]
|
||
pub struct PayloadId(TypeName);
|
||
|
||
impl fmt::Display for PayloadId {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
self.0.fmt(f)
|
||
}
|
||
}
|
||
|
||
impl FromStr for PayloadId {
|
||
type Err = TypeNameParse;
|
||
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
TypeName::from_str(s).map(Self)
|
||
}
|
||
}
|
||
|
||
impl PayloadId {
|
||
/// Project payload type.
|
||
pub fn project() -> Self {
|
||
Self(
|
||
// SAFETY: We know this is valid.
|
||
TypeName::from_str("xyz.radicle.project")
|
||
.expect("PayloadId::project: type name is valid"),
|
||
)
|
||
}
|
||
|
||
pub fn canonical_refs() -> Self {
|
||
Self(
|
||
// SAFETY: We know this is valid.
|
||
TypeName::from_str("xyz.radicle.crefs")
|
||
.expect("PayloadId::canonical_refs: type name is valid"),
|
||
)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum PayloadError {
|
||
#[error("json: {0}")]
|
||
Json(#[from] serde_json::Error),
|
||
#[error("payload '{0}' not found in identity document")]
|
||
NotFound(PayloadId),
|
||
}
|
||
|
||
/// A `Payload` is a free-form JSON value that can be associated with an
|
||
/// identity's [`Doc`].
|
||
/// The payload is identified in the [`Doc`] by its corresponding [`PayloadId`].
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(transparent)]
|
||
pub struct Payload {
|
||
value: serde_json::Value,
|
||
}
|
||
|
||
impl Payload {
|
||
/// Get a mutable reference to the JSON map, or `None` if the payload is not a map.
|
||
pub fn as_object_mut(
|
||
&mut self,
|
||
) -> Option<&mut serde_json::value::Map<String, serde_json::Value>> {
|
||
self.value.as_object_mut()
|
||
}
|
||
}
|
||
|
||
impl From<serde_json::Value> for Payload {
|
||
fn from(value: serde_json::Value) -> Self {
|
||
Self { value }
|
||
}
|
||
}
|
||
|
||
impl Deref for Payload {
|
||
type Target = serde_json::Value;
|
||
|
||
fn deref(&self) -> &Self::Target {
|
||
&self.value
|
||
}
|
||
}
|
||
|
||
/// A verified identity document at a specific commit.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct DocAt {
|
||
/// The commit at which this document exists.
|
||
pub commit: Oid,
|
||
/// The document blob at this commit.
|
||
pub blob: Oid,
|
||
/// The parsed document.
|
||
pub doc: Doc,
|
||
}
|
||
|
||
impl Deref for DocAt {
|
||
type Target = Doc;
|
||
|
||
fn deref(&self) -> &Self::Target {
|
||
&self.doc
|
||
}
|
||
}
|
||
|
||
impl From<DocAt> for Doc {
|
||
fn from(value: DocAt) -> Self {
|
||
value.doc
|
||
}
|
||
}
|
||
|
||
impl AsRef<Doc> for DocAt {
|
||
fn as_ref(&self) -> &Doc {
|
||
&self.doc
|
||
}
|
||
}
|
||
|
||
/// Repository visibility.
|
||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase", tag = "type")]
|
||
pub enum Visibility {
|
||
/// Anyone and everyone.
|
||
#[default]
|
||
Public,
|
||
/// Delegates plus the allowed DIDs.
|
||
Private {
|
||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||
allow: BTreeSet<Did>,
|
||
},
|
||
}
|
||
|
||
#[derive(Error, Debug)]
|
||
#[error("'{0}' is not a valid visibility type")]
|
||
pub struct VisibilityParseError(String);
|
||
|
||
impl FromStr for Visibility {
|
||
type Err = VisibilityParseError;
|
||
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
match s {
|
||
"public" => Ok(Visibility::Public),
|
||
"private" => Ok(Visibility::private([])),
|
||
_ => Err(VisibilityParseError(s.to_owned())),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Visibility {
|
||
/// Check whether the visibility is public.
|
||
pub fn is_public(&self) -> bool {
|
||
matches!(self, Self::Public)
|
||
}
|
||
|
||
/// Check whether the visibility is private.
|
||
pub fn is_private(&self) -> bool {
|
||
matches!(self, Self::Private { .. })
|
||
}
|
||
|
||
/// Private visibility with list of allowed DIDs beyond the repository delegates.
|
||
pub fn private(allow: impl IntoIterator<Item = Did>) -> Self {
|
||
Self::Private {
|
||
allow: BTreeSet::from_iter(allow),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `RawDoc` is similar to the [`Doc`] type, however, it can be edited and may
|
||
/// not be valid.
|
||
///
|
||
/// It is expected that any changes to a [`Doc`] are made via [`RawDoc`], and
|
||
/// then verified by using [`RawDoc::verified`].
|
||
///
|
||
/// Note that `RawDoc` only implements [`Deserialize`]. This prevents us from
|
||
/// serializing an unverified document, while also making sure that any document
|
||
/// that is deserialized is verified.
|
||
#[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.
|
||
pub delegates: Vec<Did>,
|
||
/// The signature threshold.
|
||
pub threshold: usize,
|
||
/// Repository visibility.
|
||
#[serde(default)]
|
||
pub visibility: Visibility,
|
||
}
|
||
|
||
impl TryFrom<RawDoc> for Doc {
|
||
type Error = DocError;
|
||
|
||
fn try_from(doc: RawDoc) -> Result<Self, Self::Error> {
|
||
doc.verified()
|
||
}
|
||
}
|
||
|
||
impl RawDoc {
|
||
/// Construct a new [`RawDoc`] with an initial [`RawDoc::payload`]
|
||
/// containing the provided [`Project`], and the given `delegates`,
|
||
/// `threshold`, and `visibility`.
|
||
pub fn new(
|
||
project: Project,
|
||
delegates: Vec<Did>,
|
||
threshold: usize,
|
||
visibility: Visibility,
|
||
) -> Self {
|
||
let project =
|
||
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,
|
||
visibility,
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
.payload
|
||
.get(&PayloadId::project())
|
||
.ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?;
|
||
let proj: Project = serde_json::from_value((**value).clone())?;
|
||
|
||
Ok(proj)
|
||
}
|
||
|
||
/// Check if the given `did` is in the set of [`RawDoc::delegates`].
|
||
pub fn is_delegate(&self, did: &Did) -> bool {
|
||
self.delegates.contains(did)
|
||
}
|
||
|
||
/// Add a new delegate to the document.
|
||
///
|
||
/// Note that if this `Did` is a duplicate, then the resulting set will only
|
||
/// show it once.
|
||
pub fn delegate(&mut self, did: Did) {
|
||
self.delegates.push(did)
|
||
}
|
||
|
||
/// Remove the `did` from the set of delegates. Returns `true` if it was
|
||
/// removed.
|
||
pub fn rescind(&mut self, did: &Did) -> Result<bool, DocError> {
|
||
let (matches, delegates) = self.delegates.iter().partition(|d| *d == did);
|
||
self.delegates = delegates;
|
||
Ok(matches.is_empty().not())
|
||
}
|
||
|
||
/// Construct the `RawDoc` from the set of `bytes` that are expected to be
|
||
/// in JSON format.
|
||
pub fn from_json(bytes: &[u8]) -> Result<Self, DocError> {
|
||
serde_json::from_slice(bytes).map_err(DocError::from)
|
||
}
|
||
|
||
/// Verify the `RawDoc`'s values, converting it into a valid [`Doc`].
|
||
///
|
||
/// The verifications are as follows:
|
||
///
|
||
/// - [`RawDoc::delegates`]: any duplicates are removed, and for the
|
||
/// remaining set ensure that it is non-empty and does not exceed a
|
||
/// length of [`MAX_DELEGATES`].
|
||
/// - [`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,
|
||
visibility,
|
||
} = self;
|
||
let delegates = Delegates::new(delegates)?;
|
||
let threshold = Threshold::new(threshold, &delegates)?;
|
||
Ok(Doc {
|
||
version,
|
||
payload,
|
||
delegates,
|
||
threshold,
|
||
visibility,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// A valid set of delegates for the identity [`Doc`].
|
||
///
|
||
/// It can only be constructed via [`Delegates::new`].
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(try_from = "Vec<Did>")]
|
||
pub struct Delegates(NonEmpty<Did>);
|
||
|
||
impl AsRef<NonEmpty<Did>> for Delegates {
|
||
fn as_ref(&self) -> &NonEmpty<Did> {
|
||
&self.0
|
||
}
|
||
}
|
||
|
||
impl From<Did> for Delegates {
|
||
fn from(did: Did) -> Self {
|
||
Self(NonEmpty::new(did))
|
||
}
|
||
}
|
||
|
||
impl TryFrom<Vec<Did>> for Delegates {
|
||
type Error = DelegatesError;
|
||
|
||
fn try_from(dids: Vec<Did>) -> Result<Self, Self::Error> {
|
||
Delegates::new(dids)
|
||
}
|
||
}
|
||
|
||
impl IntoIterator for Delegates {
|
||
type Item = <NonEmpty<Did> as IntoIterator>::Item;
|
||
type IntoIter = <NonEmpty<Did> as IntoIterator>::IntoIter;
|
||
|
||
fn into_iter(self) -> Self::IntoIter {
|
||
self.0.into_iter()
|
||
}
|
||
}
|
||
|
||
impl Delegates {
|
||
/// Construct the set of `Delegates` by removing any duplicate [`Did`]s,
|
||
/// ensure that the set is non-empty, and check the length does not exceed
|
||
/// the [`MAX_DELEGATES`].
|
||
pub fn new(delegates: impl IntoIterator<Item = Did>) -> Result<Self, DelegatesError> {
|
||
let delegates = delegates
|
||
.into_iter()
|
||
.try_fold(Vec::<Did>::new(), |mut dids, did| {
|
||
if !dids.contains(&did) {
|
||
if dids.len() >= MAX_DELEGATES {
|
||
return Err(DelegatesError("number of delegates cannot exceed 255"));
|
||
}
|
||
dids.push(did);
|
||
}
|
||
Ok(dids)
|
||
})?;
|
||
NonEmpty::from_vec(delegates)
|
||
.map(Self)
|
||
.ok_or(DelegatesError("delegate list cannot be empty"))
|
||
}
|
||
|
||
/// Get the first delegate in the set.
|
||
pub fn first(&self) -> &Did {
|
||
self.0.first()
|
||
}
|
||
|
||
/// Obtain an iterator over the [`Did`]s.
|
||
pub fn iter(&self) -> impl Iterator<Item = &Did> {
|
||
self.0.iter()
|
||
}
|
||
|
||
/// Check if the set contains the given `did`.
|
||
pub fn contains(&self, did: &Did) -> bool {
|
||
self.0.contains(did)
|
||
}
|
||
|
||
/// Check if the `did` is the only delegate of the repository.
|
||
pub fn is_only(&self, did: &Did) -> bool {
|
||
self.0.tail.is_empty() && &self.0.head == did
|
||
}
|
||
|
||
/// Get the number of delegates in the set.
|
||
pub fn len(&self) -> usize {
|
||
self.0.len()
|
||
}
|
||
|
||
/// Check if the set is empty. Note that this always returns `false`.
|
||
pub fn is_empty(&self) -> bool {
|
||
false
|
||
}
|
||
}
|
||
|
||
impl<'a> From<&'a Delegates> for &'a NonEmpty<Did> {
|
||
fn from(ds: &'a Delegates) -> Self {
|
||
&ds.0
|
||
}
|
||
}
|
||
|
||
impl From<Delegates> for NonEmpty<Did> {
|
||
fn from(ds: Delegates) -> Self {
|
||
ds.0
|
||
}
|
||
}
|
||
|
||
impl From<Delegates> for Vec<Did> {
|
||
fn from(Delegates(ds): Delegates) -> Self {
|
||
ds.into()
|
||
}
|
||
}
|
||
|
||
/// A valid threshold for the identity [`Doc`].
|
||
///
|
||
/// It can only be constructed via [`Threshold::new`] or [`Threshold::MIN`].
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||
#[serde(transparent)]
|
||
pub struct Threshold(NonZeroUsize);
|
||
|
||
impl From<Threshold> for usize {
|
||
fn from(Threshold(t): Threshold) -> Self {
|
||
t.get()
|
||
}
|
||
}
|
||
|
||
impl AsRef<NonZeroUsize> for Threshold {
|
||
fn as_ref(&self) -> &NonZeroUsize {
|
||
&self.0
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for Threshold {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
write!(f, "{}", self.0)
|
||
}
|
||
}
|
||
|
||
impl Threshold {
|
||
/// A threshold of `1`.
|
||
pub const MIN: Threshold = Threshold(NonZeroUsize::MIN);
|
||
|
||
/// Construct the `Threshold` by checking that `t` is not greater than
|
||
/// [`MAX_DELEGATES`], that it does not exceed the number of delegates, and
|
||
/// is non-zero.
|
||
pub fn new(t: usize, delegates: &Delegates) -> Result<Self, ThresholdError> {
|
||
if t > MAX_DELEGATES {
|
||
Err(ThresholdError(t, "threshold cannot exceed 255"))
|
||
} else if t > delegates.len() {
|
||
Err(ThresholdError(
|
||
t,
|
||
"threshold cannot exceed number of delegates",
|
||
))
|
||
} else {
|
||
NonZeroUsize::new(t)
|
||
.map(Self)
|
||
.ok_or(ThresholdError(t, "threshold cannot be zero"))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `Doc` is a valid identity document.
|
||
///
|
||
/// To ensure that only valid documents are used, this type is restricted to be
|
||
/// read-only. For mutating the document use [`Doc::edit`].
|
||
///
|
||
/// A valid `Doc` can be constructed in four ways:
|
||
///
|
||
/// 1. [`Doc::initial`]: a safe way to construct the initial document for an identity.
|
||
/// 2. [`RawDoc::verified`]: validates a [`RawDoc`]'s fields and converts it
|
||
/// into a `Doc`
|
||
/// 3. [`Deserialize`]: will deserialize a `Doc` by first deserializing a
|
||
/// [`RawDoc`] and use [`RawDoc::verified`] to construct the `Doc`.
|
||
/// 4. [`Doc::from_blob`]: construct a `Doc` from a Git blob by deserializing
|
||
/// its contents.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[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,
|
||
#[serde(default, skip_serializing_if = "Visibility::is_public")]
|
||
visibility: Visibility,
|
||
}
|
||
|
||
impl Doc {
|
||
/// Construct the initial [`Doc`] for an identity.
|
||
///
|
||
/// It will begin with the provided `project` in the [`Doc::payload`], the
|
||
/// `delegate` as the sole delegate, a threshold of 1, and the given
|
||
/// `visibility`.
|
||
pub fn initial(project: Project, delegate: Did, visibility: Visibility) -> Self {
|
||
let project =
|
||
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),
|
||
visibility,
|
||
}
|
||
}
|
||
|
||
/// Construct a [`Doc`] contained in the provided Git blob.
|
||
pub fn from_blob(blob: &git::raw::Blob) -> Result<Self, DocError> {
|
||
RawDoc::from_json(blob.content())?.verified()
|
||
}
|
||
|
||
/// Convert the [`Doc`] into a [`RawDoc`] for changing the field values and
|
||
/// re-verifying.
|
||
pub fn edit(self) -> RawDoc {
|
||
let Doc {
|
||
version,
|
||
payload,
|
||
delegates,
|
||
threshold,
|
||
visibility,
|
||
} = self;
|
||
RawDoc {
|
||
version,
|
||
payload,
|
||
delegates: delegates.into(),
|
||
threshold: threshold.into(),
|
||
visibility,
|
||
}
|
||
}
|
||
|
||
/// Using the current state of the `Doc`, perform any edits on the `RawDoc`
|
||
/// form and verify the changes.
|
||
pub fn with_edits<F>(self, f: F) -> Result<Self, DocError>
|
||
where
|
||
F: FnOnce(&mut RawDoc),
|
||
{
|
||
let mut raw = self.edit();
|
||
f(&mut raw);
|
||
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
|
||
}
|
||
|
||
/// Get the project payload, if it exists and is valid, out of this document.
|
||
pub fn project(&self) -> Result<Project, PayloadError> {
|
||
let value = self
|
||
.payload
|
||
.get(&PayloadId::project())
|
||
.ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?;
|
||
let proj: Project = serde_json::from_value((**value).clone())?;
|
||
|
||
Ok(proj)
|
||
}
|
||
|
||
/// Gets the qualified reference name of the default branch,
|
||
/// according to the project payload in this document.
|
||
pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, PayloadError> {
|
||
Ok(git::refs::branch(self.project()?.default_branch()))
|
||
}
|
||
|
||
pub fn default_branch_rule(&self) -> Result<rules::Rules, DefaultBranchRuleError> {
|
||
let pattern = git::fmt::refspec::QualifiedPattern::from(git::refs::branch(
|
||
self.project()?.default_branch(),
|
||
));
|
||
let rule = rules::Rule::new(
|
||
rules::ResolvedDelegates::Delegates(self.delegates.clone()),
|
||
self.threshold,
|
||
);
|
||
Ok(rules::Rules::from_raw(
|
||
rules::RawRules::from_iter([(pattern, rule.into())]),
|
||
&mut || self.delegates.clone(),
|
||
)
|
||
.expect("default rules are valid"))
|
||
}
|
||
|
||
/// Construct the canonical references for this document.
|
||
/// The implementation of [`crefs::RawCanonicalRefs`] is used to
|
||
/// obtain the payload identified by [`PayloadId::canonical_refs`], if it
|
||
/// exists.
|
||
/// The resulting [`CanonicalRefs`] are constructed by extension with
|
||
/// [`Self::default_branch_rule`].
|
||
pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
|
||
use crefs::GetRawCanonicalRefs;
|
||
let raw_crefs = self.raw_canonical_refs()?.unwrap_or_default();
|
||
|
||
let mut raw_rules = raw_crefs.raw_rules().clone();
|
||
raw_rules.extend(rules::RawRules::from(self.default_branch_rule()?));
|
||
|
||
let raw_crefs = RawCanonicalRefs::new(raw_rules);
|
||
Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?)
|
||
}
|
||
|
||
/// Return the associated [`Visibility`] of this document.
|
||
pub fn visibility(&self) -> &Visibility {
|
||
&self.visibility
|
||
}
|
||
|
||
/// Check whether the visibility of the document is public.
|
||
pub fn is_public(&self) -> bool {
|
||
self.visibility.is_public()
|
||
}
|
||
|
||
/// Check whether the visibility of the document is private.
|
||
pub fn is_private(&self) -> bool {
|
||
self.visibility.is_private()
|
||
}
|
||
|
||
/// Return the associated threshold of this document.
|
||
pub fn threshold(&self) -> usize {
|
||
self.threshold.into()
|
||
}
|
||
|
||
/// Return the associated threshold of this document in its non-zero format.
|
||
pub fn threshold_nonzero(&self) -> &NonZeroUsize {
|
||
&self.threshold.0
|
||
}
|
||
|
||
/// Return the associated delegates of this document.
|
||
pub fn delegates(&self) -> &Delegates {
|
||
&self.delegates
|
||
}
|
||
|
||
/// Check if the `did` is part of the [`Doc::delegates`] set.
|
||
pub fn is_delegate(&self, did: &Did) -> bool {
|
||
self.delegates.contains(did)
|
||
}
|
||
|
||
/// Check whether this document and the associated repository is visible to
|
||
/// the given peer.
|
||
pub fn is_visible_to(&self, did: &Did) -> bool {
|
||
match &self.visibility {
|
||
Visibility::Public => true,
|
||
Visibility::Private { allow } => allow.contains(did) || self.is_delegate(did),
|
||
}
|
||
}
|
||
|
||
/// Validate `signature` using this document's delegates, against a given
|
||
/// document blob.
|
||
pub fn verify_signature(
|
||
&self,
|
||
key: &PublicKey,
|
||
signature: &Signature,
|
||
blob: Oid,
|
||
) -> Result<(), PublicKey> {
|
||
if !self.is_delegate(&key.into()) {
|
||
return Err(*key);
|
||
}
|
||
if key.verify(AsRef::<[u8]>::as_ref(&blob), signature).is_err() {
|
||
return Err(*key);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Check the provided `votes` passes the [`Doc::majority`].
|
||
pub fn is_majority(&self, votes: usize) -> bool {
|
||
votes >= self.majority()
|
||
}
|
||
|
||
/// Return the majority number based on the size of the delegates set.
|
||
pub fn majority(&self) -> usize {
|
||
self.delegates.len() / 2 + 1
|
||
}
|
||
|
||
/// Helper for getting an `embeds` Git blob.
|
||
pub(crate) fn blob_at<R: ReadRepository>(
|
||
commit: Oid,
|
||
repo: &R,
|
||
) -> Result<git::raw::Blob<'_>, DocError> {
|
||
let path = Path::new("embeds").join(*PATH);
|
||
repo.blob_at(commit, path.as_path()).map_err(DocError::from)
|
||
}
|
||
|
||
/// Encode the [`Doc`] as canonical JSON, returning the set of bytes and its
|
||
/// corresponding Git [`Oid`].
|
||
pub fn encode(&self) -> Result<(git::Oid, Vec<u8>), DocError> {
|
||
let mut buf = Vec::new();
|
||
let mut serializer =
|
||
serde_json::Serializer::with_formatter(&mut buf, CanonicalFormatter::new());
|
||
|
||
self.serialize(&mut serializer)?;
|
||
let oid = git::raw::Oid::hash_object(git::raw::ObjectType::Blob, &buf)?;
|
||
|
||
Ok((oid.into(), buf))
|
||
}
|
||
|
||
/// [`Doc::encode`] and sign the [`Doc`], returning the set of bytes, its
|
||
/// corresponding Git [`Oid`] and the [`Signature`] over the [`Oid`].
|
||
pub fn sign<G>(&self, signer: &G) -> Result<(git::Oid, Vec<u8>, Signature), DocError>
|
||
where
|
||
G: crypto::signature::Signer<crypto::Signature>,
|
||
{
|
||
let (oid, bytes) = self.encode()?;
|
||
let sig = signer.sign(oid.as_ref());
|
||
|
||
Ok((oid, bytes, sig))
|
||
}
|
||
|
||
/// Similar to [`Doc::sign`], but only returning the [`Signature`].
|
||
pub fn signature_of<G>(&self, signer: &G) -> Result<Signature, DocError>
|
||
where
|
||
G: crypto::signature::Signer<crypto::Signature>,
|
||
{
|
||
let (_, _, sig) = self.sign(signer)?;
|
||
|
||
Ok(sig)
|
||
}
|
||
|
||
/// Load the [`DocAt`] found at the given `commit`. The [`DocAt`] will
|
||
/// contain the corresponding [`Doc`].
|
||
pub fn load_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<DocAt, DocError> {
|
||
let blob = Self::blob_at(commit, repo)?;
|
||
let doc = Self::from_blob(&blob)?;
|
||
|
||
Ok(DocAt {
|
||
commit,
|
||
doc,
|
||
blob: blob.id().into(),
|
||
})
|
||
}
|
||
|
||
/// Initialize an [`identity::Identity`] with this [`Doc`] as the associated
|
||
/// document.
|
||
pub fn init<G>(
|
||
&self,
|
||
repo: &storage::git::Repository,
|
||
signer: &Device<G>,
|
||
) -> Result<git::Oid, RepositoryError>
|
||
where
|
||
G: crypto::signature::Signer<crypto::Signature>,
|
||
{
|
||
let cob = identity::Identity::initialize(self, repo, signer)?;
|
||
let id_ref = git::refs::storage::id(signer.public_key());
|
||
let cob_ref = git::refs::storage::cob(
|
||
signer.public_key(),
|
||
&crate::cob::identity::TYPENAME,
|
||
&cob.id,
|
||
);
|
||
// Set `.../refs/rad/id` -> `.../refs/cobs/xyz.radicle.id/<id>`
|
||
repo.backend.reference_symbolic(
|
||
id_ref.as_str(),
|
||
cob_ref.as_str(),
|
||
false,
|
||
"Create `rad/id` reference to point to new identity COB",
|
||
)?;
|
||
|
||
Ok(*cob.id)
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum CanonicalRefsError {
|
||
#[error(transparent)]
|
||
Json(#[from] serde_json::Error),
|
||
#[error(transparent)]
|
||
CanonicalRefs(#[from] rules::ValidationError),
|
||
#[error(transparent)]
|
||
DefaultBranch(#[from] DefaultBranchRuleError),
|
||
}
|
||
|
||
impl crefs::GetRawCanonicalRefs for Doc {
|
||
type Error = CanonicalRefsError;
|
||
|
||
fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error> {
|
||
let value = self.payload.get(&PayloadId::canonical_refs());
|
||
let crefs = value
|
||
.map(|value| {
|
||
serde_json::from_value((**value).clone()).map_err(CanonicalRefsError::from)
|
||
})
|
||
.transpose()?;
|
||
Ok(crefs)
|
||
}
|
||
}
|
||
|
||
impl crefs::GetRawCanonicalRefs for RawDoc {
|
||
type Error = CanonicalRefsError;
|
||
|
||
fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error> {
|
||
let value = self.payload.get(&PayloadId::canonical_refs());
|
||
let crefs = value
|
||
.map(|value| {
|
||
serde_json::from_value((**value).clone()).map_err(CanonicalRefsError::from)
|
||
})
|
||
.transpose()?;
|
||
Ok(crefs)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(clippy::unwrap_used)]
|
||
mod test {
|
||
use serde_json::json;
|
||
|
||
use crate::assert_matches;
|
||
use crate::rad;
|
||
use crate::storage::git::Storage;
|
||
use crate::storage::git::transport;
|
||
use crate::storage::{ReadStorage as _, RemoteId, WriteStorage as _};
|
||
use crate::test::arbitrary;
|
||
use crate::test::arbitrary::r#gen;
|
||
use crate::test::fixtures;
|
||
|
||
use super::*;
|
||
use qcheck_macros::quickcheck;
|
||
|
||
#[test]
|
||
fn test_duplicate_dids() {
|
||
let delegate = Device::mock_from_seed([0xff; 32]);
|
||
let did = Did::from(delegate.public_key());
|
||
let mut doc = RawDoc::new(r#gen::<Project>(1), vec![did], 1, Visibility::Public);
|
||
doc.delegate(did);
|
||
let doc = doc.verified().unwrap();
|
||
assert!(doc.delegates().len() == 1, "Duplicate DID was not removed");
|
||
assert!(doc.delegates().first() == &did)
|
||
}
|
||
|
||
#[test]
|
||
fn test_max_delegates() {
|
||
// Generate more than the max delegates
|
||
let delegates = (0..MAX_DELEGATES + 1).map(r#gen).collect::<Vec<Did>>();
|
||
|
||
// A document with max delegates will be fine
|
||
let doc = RawDoc::new(
|
||
r#gen::<Project>(1),
|
||
delegates[0..MAX_DELEGATES].into(),
|
||
1,
|
||
Visibility::Public,
|
||
);
|
||
assert_matches!(doc.verified(), Ok(_));
|
||
|
||
// A document that exceeds max delegates should fail
|
||
let doc = RawDoc::new(r#gen::<Project>(1), delegates, 1, Visibility::Public);
|
||
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::UnknownVersion(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();
|
||
|
||
transport::local::register(storage.clone());
|
||
|
||
let delegate = Device::mock_from_seed([0xff; 32]);
|
||
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
|
||
let (id, _, _) = rad::init(
|
||
&repo,
|
||
"heartwood".try_into().unwrap(),
|
||
"Radicle Heartwood Protocol & Stack",
|
||
git::fmt::refname!("master"),
|
||
Visibility::default(),
|
||
&delegate,
|
||
&storage,
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
delegate.public_key().to_human(),
|
||
String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi")
|
||
);
|
||
assert_eq!(
|
||
(*id).to_string(),
|
||
"d96f425412c9f8ad5d9a9a05c9831d0728e2338d"
|
||
);
|
||
assert_eq!(id.urn(), String::from("rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_not_found() {
|
||
let tempdir = tempfile::tempdir().unwrap();
|
||
let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();
|
||
let remote = arbitrary::r#gen::<RemoteId>(1);
|
||
let proj = arbitrary::r#gen::<RepoId>(1);
|
||
let repo = storage.create(proj).unwrap();
|
||
let oid = git::raw::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();
|
||
|
||
let err = repo.identity_head_of(&remote).unwrap_err();
|
||
{
|
||
use crate::git::raw::ErrorExt as _;
|
||
assert!(err.is_not_found());
|
||
}
|
||
|
||
let err = Doc::load_at(oid.into(), &repo).unwrap_err();
|
||
assert!(err.is_not_found());
|
||
}
|
||
|
||
#[test]
|
||
fn test_canonical_doc() {
|
||
let tempdir = tempfile::tempdir().unwrap();
|
||
let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();
|
||
transport::local::register(storage.clone());
|
||
|
||
let (working, _) = fixtures::repository(tempdir.path().join("working"));
|
||
|
||
let delegate = Device::mock_from_seed([0xff; 32]);
|
||
let (rid, doc, _) = rad::init(
|
||
&working,
|
||
"heartwood".try_into().unwrap(),
|
||
"Radicle Heartwood Protocol & Stack",
|
||
git::fmt::refname!("master"),
|
||
Visibility::default(),
|
||
&delegate,
|
||
&storage,
|
||
)
|
||
.unwrap();
|
||
let repo = storage.repository(rid).unwrap();
|
||
|
||
assert_eq!(doc, repo.identity_doc().unwrap().doc);
|
||
}
|
||
|
||
#[quickcheck]
|
||
fn prop_encode_decode(doc: Doc) {
|
||
let (_, bytes) = doc.encode().unwrap();
|
||
assert_eq!(RawDoc::from_json(&bytes).unwrap().verified().unwrap(), doc);
|
||
}
|
||
|
||
#[test]
|
||
fn test_visibility_json() {
|
||
use std::str::FromStr;
|
||
|
||
assert_eq!(
|
||
serde_json::to_value(Visibility::Public).unwrap(),
|
||
serde_json::json!({ "type": "public" })
|
||
);
|
||
assert_eq!(
|
||
serde_json::to_value(Visibility::private([])).unwrap(),
|
||
serde_json::json!({ "type": "private" })
|
||
);
|
||
assert_eq!(
|
||
serde_json::to_value(Visibility::private([Did::from_str(
|
||
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"
|
||
)
|
||
.unwrap()]))
|
||
.unwrap(),
|
||
serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] })
|
||
);
|
||
}
|
||
}
|