pub mod error;
use std::{collections::BTreeSet, str::FromStr};
use serde_json as json;
use crate::{
git,
identity::crefs::GetCanonicalRefs as _,
prelude::Did,
storage::{self, refs, ReadRepository, RepositoryError},
};
use super::{Doc, PayloadError, PayloadId, RawDoc, Visibility};
#[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())),
}
}
}
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
}
}
}
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)
}
}
}
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)),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayloadUpsert {
pub id: PayloadId,
pub key: String,
pub value: json::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)
}
pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
let proposal = raw.clone().verified()?;
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(),
})
}
};
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 });
}
}
_ => { }
}
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::{
crefs::GetCanonicalRefs,
doc::{update::error, PayloadId},
},
prelude::RawDoc,
test::arbitrary,
};
use super::PayloadUpsert;
#[test]
fn test_can_update_crefs() {
let raw = arbitrary::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::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::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().unwrap();
assert!(
crefs.rules().matches(&branch).next().is_some(),
"Default branch rule is missing!"
);
}
}