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 { 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, disallow: BTreeSet, ) -> Result { 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::>(); 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, removals: Vec, repo: &storage::git::Repository, ) -> Result>, 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::>(); 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, ) -> Result { 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 { 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::>(); 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>, 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::(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::(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::(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!" ); } }