Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle src identity doc update.rs
pub mod error;

use std::{collections::BTreeSet, str::FromStr};

use serde_json as json;

use crate::{
    git,
    prelude::Did,
    storage::{self, ReadRepository, RepositoryError, refs},
};

use super::{Doc, PayloadError, PayloadId, RawDoc, Visibility};

/// [`EditVisibility`] allows the visibility of a [`RawDoc`] to be edited using
/// the [`visibility`] function.
///
/// Note that this differs from [`Visibility`] since the
/// [`EditVisibility::Private`] variant does not hold the allowed set of
/// [`Did`]s.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EditVisibility {
    #[default]
    Public,
    Private,
}

impl FromStr for EditVisibility {
    type Err = error::ParseEditVisibility;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "public" => Ok(EditVisibility::Public),
            "private" => Ok(EditVisibility::Private),
            _ => Err(error::ParseEditVisibility(s.to_owned())),
        }
    }
}

/// Change the visibility of the [`RawDoc`], using the provided
/// [`EditVisibility`].
pub fn visibility(mut raw: RawDoc, edit: EditVisibility) -> RawDoc {
    match (&mut raw.visibility, edit) {
        (Visibility::Public, EditVisibility::Public) => raw,
        (Visibility::Private { .. }, EditVisibility::Private) => raw,
        (Visibility::Public, EditVisibility::Private) => {
            raw.visibility = Visibility::private([]);
            raw
        }
        (Visibility::Private { .. }, EditVisibility::Public) => {
            raw.visibility = Visibility::Public;
            raw
        }
    }
}

/// Change the `allow` set of a document if the visibility is set to
/// [`Visibility::Private`].
///
/// All `Did`s in the `allow` set are added to the set, while all `Did`s in the
/// `disallow` set are removed from the set.
///
/// # Errors
///
/// This will fail when `allow` and `disallow` are not disjoint, i.e. they
/// contain at least share one `Did`.
///
/// This will fail when the [`Visibility`] of the document is
/// [`Visibility::Public`].
pub fn privacy_allow_list(
    mut raw: RawDoc,
    allow: BTreeSet<Did>,
    disallow: BTreeSet<Did>,
) -> Result<RawDoc, error::PrivacyAllowList> {
    if allow.is_empty() && disallow.is_empty() {
        return Ok(raw);
    }

    if !allow.is_disjoint(&disallow) {
        let overlap = allow
            .intersection(&disallow)
            .map(Did::to_string)
            .collect::<Vec<_>>();
        return Err(error::PrivacyAllowList::Overlapping(overlap));
    }

    match &mut raw.visibility {
        Visibility::Public => Err(error::PrivacyAllowList::PublicVisibility),
        Visibility::Private { allow: existing } => {
            for did in allow {
                existing.insert(did);
            }
            for did in disallow {
                existing.remove(&did);
            }
            Ok(raw)
        }
    }
}

/// Change the delegates of the document and perform some verification based on
/// the new set of delegates.
///
/// The set of `additions` are added to the delegates, while the set to
/// `removals` are removed from the delegates. Note that `removals` will take
/// precedence over the additions, i.e. if an addition and removal overlap, then
/// the [`Did`] will not be in the final set.
///
/// The result is either the updated [`RawDoc`] or a set of
/// [`error::DelegateVerification`] errors – which may be reported by the caller
/// to provide better error messaging.
///
/// # Errors
///
/// This will fail if an operation using the repository fails.
pub fn delegates(
    mut raw: RawDoc,
    additions: Vec<Did>,
    removals: Vec<Did>,
    repo: &storage::git::Repository,
) -> Result<Result<RawDoc, Vec<error::DelegateVerification>>, RepositoryError> {
    if additions.is_empty() && removals.is_empty() {
        return Ok(Ok(raw));
    }

    raw.delegates = raw
        .delegates
        .into_iter()
        .chain(additions)
        .filter(|d| !removals.contains(d))
        .collect::<Vec<_>>();
    match verify_delegates(&raw, repo)? {
        Some(errors) => Ok(Err(errors)),
        None => Ok(Ok(raw)),
    }
}

/// [`Payload`]: super::Payload
/// A change (update or insertion) to particular `key` within a [`Payload`]
/// in a document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayloadUpsert {
    /// [`Payload`]: super::Payload
    /// The identifier for the document [`Payload`].
    pub id: PayloadId,
    /// [`Payload`]: super::Payload
    /// The key within the [`Payload`] that is being updated.
    pub key: String,
    /// [`Payload`]: super::Payload
    /// The value to update within the [`Payload`].
    pub value: json::Value,
}

// TODO(finto): I think this API would likely be much nicer if we use [JSON Patch][patch] and [JSON Merge Patch][merge]
//
// [patch]: https://datatracker.ietf.org/doc/html/rfc6902
// [merge]: https://datatracker.ietf.org/doc/html/rfc7396
/// [`Payload`]: super::Payload
/// Change (update or insert) a key in a [`Payload`] of the document,
/// using the provided `updates`.
///
/// # Errors
///
/// This fails if one of the [`PayloadId`]s does not point to a JSON object as
/// its value.
pub fn payload(
    mut raw: RawDoc,
    upserts: impl IntoIterator<Item = PayloadUpsert>,
) -> Result<RawDoc, error::PayloadError> {
    for PayloadUpsert { id, key, value } in upserts {
        if let Some(ref mut payload) = raw.payload.get_mut(&id) {
            if let Some(obj) = payload.as_object_mut() {
                if value.is_null() {
                    obj.remove(&key);
                } else {
                    obj.insert(key, value);
                }
            } else {
                return Err(error::PayloadError::ExpectedObject { id });
            }
        } else {
            raw.payload
                .insert(id, serde_json::json!({ key: value }).into());
        }
    }
    Ok(raw)
}

/// Verify the document.
///
/// This ensures performs the verification of the [`RawDoc`] into the [`Doc`],
/// while also checking the [`Project`] and [`CanonicalRefs`] will also
/// deserialize correctly.
///
/// [`Project`]: crate::identity::Project
/// [`CanonicalRefs`]: crate::identity::CanonicalRefs
pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
    let proposal = raw.clone().verified()?;
    // Verify that the project payload is valid
    // TODO(finto): perhaps this should be handled by JSON Schemas instead
    let project = match proposal.project() {
        Ok(project) => Some(project),
        Err(PayloadError::NotFound(_)) => None,
        Err(PayloadError::Json(e)) => {
            return Err(error::DocVerification::PayloadError {
                id: PayloadId::project(),
                err: e.to_string(),
            });
        }
    };
    // Ensure that if we have canonical reference rules and a project, that no
    // rule exists for the default branch. This rule must be synthesized when
    // constructing the canonical reference rules.
    use super::GetRawCanonicalRefs as _;
    match raw
        .raw_canonical_refs()
        .map(|rcrefs| rcrefs.and_then(|c| project.map(|p| (c, p))))
    {
        Ok(Some((crefs, project))) => {
            let default =
                git::fmt::Qualified::from(git::fmt::lit::refs_heads(project.default_branch()));
            let matches = crefs
                .raw_rules()
                .matches(&default)
                .map(|(pattern, _)| pattern.to_string())
                .collect::<Vec<_>>();
            if !matches.is_empty() {
                return Err(error::DocVerification::DisallowDefault { matches, default });
            }
        }
        _ => { /* we validate below */ }
    }
    // Verify that the canonical references payload is valid
    if let Err(e) = proposal.canonical_refs() {
        return Err(error::DocVerification::PayloadError {
            id: PayloadId::canonical_refs(),
            err: e.to_string(),
        });
    }
    Ok(proposal)
}

fn verify_delegates(
    proposal: &RawDoc,
    repo: &storage::git::Repository,
) -> Result<Option<Vec<error::DelegateVerification>>, RepositoryError> {
    let dids = &proposal.delegates;
    let threshold = proposal.threshold;
    let (canonical, _) = repo.canonical_head()?;
    let mut missing = Vec::with_capacity(dids.len());

    for did in dids {
        match refs::SignedRefs::load((*did).into(), repo)
            .map_err(|err| storage::Error::Refs(storage::refs::Error::Read(err)))?
        {
            None => {
                missing.push(error::DelegateVerification::MissingDelegate { did: *did });
            }
            Some(sigrefs) => {
                if sigrefs.get(&canonical).is_none() {
                    missing.push(error::DelegateVerification::MissingDefaultBranch {
                        branch: canonical.to_ref_string(),
                        did: *did,
                    });
                }
            }
        }
    }

    Ok((dids.len() - missing.len() < threshold).then_some(missing))
}

#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod test {
    use serde_json::json;

    use crate::{
        git,
        identity::doc::{PayloadId, update::error},
        prelude::RawDoc,
        test::arbitrary,
    };

    use super::PayloadUpsert;

    #[test]
    fn test_can_update_crefs() {
        let raw = arbitrary::r#gen::<RawDoc>(1);
        let raw = super::payload(
            raw,
            [PayloadUpsert {
                id: PayloadId::canonical_refs(),
                key: "rules".to_string(),
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
                    }
                }),
            }],
        )
        .unwrap();
        let verified = super::verify(raw);
        assert!(verified.is_ok(), "Unexpected error {verified:?}");
    }

    #[test]
    fn test_cannot_include_default_branch_rule() {
        let raw = arbitrary::r#gen::<RawDoc>(1);
        let branch = git::fmt::Qualified::from(git::fmt::lit::refs_heads(
            raw.project().unwrap().default_branch(),
        ));
        let raw = super::payload(
            raw,
            [PayloadUpsert {
                id: PayloadId::canonical_refs(),
                key: "rules".to_string(),
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
                    },
                    branch.as_str(): {
                        "threshold": 1,
                        "allow": "delegates",
                    }
                }),
            }],
        )
        .unwrap();
        assert!(
            matches!(
                super::verify(raw),
                Err(error::DocVerification::DisallowDefault { .. })
            ),
            "Verification should be rejected for including default branch rule"
        )
    }

    #[test]
    fn test_default_branch_rule_exists_after_verification() {
        let raw = arbitrary::r#gen::<RawDoc>(1);
        let branch = git::fmt::Qualified::from(git::fmt::lit::refs_heads(
            raw.project().unwrap().default_branch(),
        ));
        let raw = super::payload(
            raw,
            [PayloadUpsert {
                id: PayloadId::canonical_refs(),
                key: "rules".to_string(),
                value: json!({
                    "refs/tags/*": {
                        "threshold": 1,
                        "allow": "delegates"
                    }
                }),
            }],
        )
        .unwrap();
        let verified = super::verify(raw).unwrap();
        let crefs = verified.canonical_refs().unwrap();
        assert!(
            crefs.rules().matches(&branch).next().is_some(),
            "Default branch rule is missing!"
        );
    }
}