radicle-heartwood-lfs/crates/radicle/src/identity/doc/update.rs

361 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

pub mod error;
use std::{collections::BTreeSet, str::FromStr};
use serde_json as json;
use crate::{
git,
identity::crefs::GetCanonicalRefs as _,
prelude::Did,
storage::{refs, ReadRepository, RepositoryError},
};
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<S>(
mut raw: RawDoc,
additions: Vec<Did>,
removals: Vec<Did>,
repo: &S,
) -> Result<Result<RawDoc, Vec<error::DelegateVerification>>, RepositoryError>
where
S: ReadRepository,
{
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)),
}
}
// 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
/// Change the payload of the document, using the set of triples:
///
/// - [`PayloadId`]: the identifier for the document [`Payload`]
/// - [`String`]: the key within the [`Payload`] that is being updated
/// - [`json::Value`]: the value to update the [`Payload`]
///
/// # Errors
///
/// This fails if one of the [`PayloadId`]s does not point to a JSON object as
/// its value.
///
/// [`Payload`]: super::Payload
pub fn payload(
mut raw: RawDoc,
payload: Vec<(PayloadId, String, json::Value)>,
) -> Result<RawDoc, error::PayloadError> {
for (id, key, val) in payload {
if let Some(ref mut payload) = raw.payload.get_mut(&id) {
if let Some(obj) = payload.as_object_mut() {
if val.is_null() {
obj.remove(&key);
} else {
obj.insert(key, val);
}
} else {
return Err(error::PayloadError::ExpectedObject { id });
}
} else {
raw.payload
.insert(id, serde_json::json!({ key: val }).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.
match raw
.raw_canonical_refs()
.map(|rcrefs| rcrefs.and_then(|c| project.map(|p| (c, p))))
{
Ok(Some((crefs, project))) => {
let default = git::Qualified::from(git::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<S>(
proposal: &RawDoc,
repo: &S,
) -> Result<Option<Vec<error::DelegateVerification>>, RepositoryError>
where
S: ReadRepository,
{
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::SignedRefsAt::load((*did).into(), repo)? {
None => {
missing.push(error::DelegateVerification::MissingDelegate { did: *did });
}
Some(refs::SignedRefsAt { 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::{
crefs::GetCanonicalRefs,
doc::{update::error, PayloadId},
},
prelude::RawDoc,
test::arbitrary,
};
#[test]
fn test_can_update_crefs() {
let raw = arbitrary::gen::<RawDoc>(1);
let raw = super::payload(
raw,
vec![(
PayloadId::canonical_refs(),
"rules".to_string(),
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::gen::<RawDoc>(1);
let branch = git::Qualified::from(git::lit::refs_heads(
raw.project().unwrap().default_branch(),
));
let raw = super::payload(
raw,
vec![(
PayloadId::canonical_refs(),
"rules".to_string(),
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::gen::<RawDoc>(1);
let branch = git::Qualified::from(git::lit::refs_heads(
raw.project().unwrap().default_branch(),
));
let raw = super::payload(
raw,
vec![(
PayloadId::canonical_refs(),
"rules".to_string(),
json!({
"refs/tags/*": {
"threshold": 1,
"allow": "delegates"
}
}),
)],
)
.unwrap();
let verified = super::verify(raw).unwrap();
let crefs = verified.canonical_refs().unwrap().unwrap();
assert!(
crefs.rules().matches(&branch).next().is_some(),
"Default branch rule is missing!"
);
}
}