radicle: disallow default branch

Ensure that the default branch is only ever synthesized from the `Doc`'s
`threshold` and `delegates` value. If the rule is ever written into a `RawDoc`,
then the update of the that `RawDoc` must go through the `update::veryify`
function, which checks that the rule is not present.
This commit is contained in:
Fintan Halpenny 2025-06-19 14:47:52 +02:00 committed by Lorenz Leutgeb
parent a69397386a
commit ff365e2d8b
7 changed files with 191 additions and 23 deletions

View File

@ -71,5 +71,5 @@ pub enum Canonical {
#[error(transparent)] #[error(transparent)]
Identity(#[from] radicle::storage::RepositoryError), Identity(#[from] radicle::storage::RepositoryError),
#[error(transparent)] #[error(transparent)]
Payload(#[from] radicle::identity::PayloadError), CanonicalRefs(#[from] radicle::identity::doc::CanonicalRefsError),
} }

View File

@ -6,7 +6,7 @@ use std::str::FromStr;
use std::{assert_eq, io}; use std::{assert_eq, io};
use radicle::identity::crefs::GetCanonicalRefs as _; use radicle::identity::crefs::GetCanonicalRefs as _;
use radicle::identity::doc::DefaultBranchRuleError; use radicle::identity::doc::CanonicalRefsError;
use radicle::node::device::Device; use radicle::node::device::Device;
use thiserror::Error; use thiserror::Error;
@ -118,7 +118,7 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
Quorum(#[from] radicle::git::canonical::QuorumError), Quorum(#[from] radicle::git::canonical::QuorumError),
#[error(transparent)] #[error(transparent)]
DefaultBranchRule(#[from] radicle::identity::doc::DefaultBranchRuleError), CanonicalRefs(#[from] radicle::identity::doc::CanonicalRefsError),
} }
/// Push command. /// Push command.
@ -271,7 +271,7 @@ pub fn run(
let identity = stored.identity()?; let identity = stored.identity()?;
let crefs = identity.canonical_refs_or_default(|| { let crefs = identity.canonical_refs_or_default(|| {
let rule = identity.doc().default_branch_rule()?; let rule = identity.doc().default_branch_rule()?;
Ok::<_, DefaultBranchRuleError>(CanonicalRefs::from_iter([rule])) Ok::<_, CanonicalRefsError>(CanonicalRefs::from_iter([rule]))
})?; })?;
let rules = crefs.rules(); let rules = crefs.rules();
let me = Did::from(nid); let me = Did::from(nid);

View File

@ -69,7 +69,7 @@ impl RawCanonicalRefs {
R: Fn() -> Delegates, R: Fn() -> Delegates,
{ {
let rules = Rules::from_raw(self.rules, resolve)?; let rules = Rules::from_raw(self.rules, resolve)?;
Ok(CanonicalRefs { rules }) Ok(CanonicalRefs::new(rules))
} }
} }

View File

@ -241,8 +241,6 @@ impl PayloadId {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum PayloadError { pub enum PayloadError {
#[error(transparent)]
CanonicalRefs(#[from] rules::ValidationError),
#[error("json: {0}")] #[error("json: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("payload '{0}' not found in identity document")] #[error("payload '{0}' not found in identity document")]
@ -914,14 +912,32 @@ impl Doc {
} }
} }
#[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::GetCanonicalRefs for Doc { impl crefs::GetCanonicalRefs for Doc {
type Error = PayloadError; type Error = CanonicalRefsError;
fn canonical_refs(&self) -> Result<Option<CanonicalRefs>, Self::Error> { fn canonical_refs(&self) -> Result<Option<CanonicalRefs>, Self::Error> {
self.raw_canonical_refs().and_then(|raw| { self.raw_canonical_refs().and_then(|raw| {
raw.map(|raw| { raw.map(|raw| {
raw.try_into_canonical_refs(&mut || self.delegates.clone()) raw.try_into_canonical_refs(&mut || self.delegates.clone())
.map_err(PayloadError::from) .map_err(CanonicalRefsError::from)
.and_then(|mut crefs| {
self.default_branch_rule()
.map_err(CanonicalRefsError::from)
.map(|rule| {
crefs.extend([rule]);
crefs
})
})
}) })
.transpose() .transpose()
}) })
@ -930,7 +946,27 @@ impl crefs::GetCanonicalRefs for Doc {
fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error> { fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error> {
let value = self.payload.get(&PayloadId::canonical_refs()); let value = self.payload.get(&PayloadId::canonical_refs());
let crefs = value let crefs = value
.map(|value| serde_json::from_value((**value).clone()).map_err(PayloadError::from)) .map(|value| {
serde_json::from_value((**value).clone()).map_err(CanonicalRefsError::from)
})
.transpose()?;
Ok(crefs)
}
}
impl crefs::GetCanonicalRefs for RawDoc {
type Error = CanonicalRefsError;
fn canonical_refs(&self) -> Result<Option<CanonicalRefs>, Self::Error> {
Ok(None)
}
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()?; .transpose()?;
Ok(crefs) Ok(crefs)
} }

View File

@ -5,12 +5,13 @@ use std::{collections::BTreeSet, str::FromStr};
use serde_json as json; use serde_json as json;
use crate::{ use crate::{
git,
identity::crefs::GetCanonicalRefs as _, identity::crefs::GetCanonicalRefs as _,
prelude::Did, prelude::Did,
storage::{refs, ReadRepository, RepositoryError}, storage::{refs, ReadRepository, RepositoryError},
}; };
use super::{Doc, PayloadId, RawDoc, Visibility}; use super::{Doc, PayloadError, PayloadId, RawDoc, Visibility};
/// [`EditVisibility`] allows the visibility of a [`RawDoc`] to be edited using /// [`EditVisibility`] allows the visibility of a [`RawDoc`] to be edited using
/// the [`visibility`] function. /// the [`visibility`] function.
@ -186,18 +187,44 @@ pub fn payload(
/// [`Project`]: crate::identity::Project /// [`Project`]: crate::identity::Project
/// [`CanonicalRefs`]: crate::identity::CanonicalRefs /// [`CanonicalRefs`]: crate::identity::CanonicalRefs
pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> { pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
let proposal = raw.verified()?; let proposal = raw.clone().verified()?;
// Verify that the payloads can still be parsed into the correct types. // Verify that the project payload is valid
if let Err(super::PayloadError::Json(e)) = proposal.project() { // TODO(finto): perhaps this should be handled by JSON Schemas instead
return Err(error::DocVerification::PayloadJson { let project = match proposal.project() {
id: PayloadId::project(), Ok(project) => Some(project),
err: e, 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 */ }
} }
if let Err(super::PayloadError::Json(e)) = proposal.canonical_refs() { // Verify that the canonical references payload is valid
return Err(error::DocVerification::PayloadJson { if let Err(e) = proposal.canonical_refs() {
return Err(error::DocVerification::PayloadError {
id: PayloadId::canonical_refs(), id: PayloadId::canonical_refs(),
err: e, err: e.to_string(),
}); });
} }
Ok(proposal) Ok(proposal)
@ -233,3 +260,101 @@ where
Ok((dids.len() - missing.len() < threshold).then_some(missing)) 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!"
);
}
}

View File

@ -1,6 +1,6 @@
use serde_json as json;
use thiserror::Error; use thiserror::Error;
use crate::git;
use crate::git::RefString; use crate::git::RefString;
use crate::identity::{doc::PayloadId, Did, DocError}; use crate::identity::{doc::PayloadId, Did, DocError};
@ -25,9 +25,14 @@ pub enum PayloadError {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum DocVerification { pub enum DocVerification {
#[error("failed to verify `{id}`, {err}")] #[error("failed to verify `{id}`, {err}")]
PayloadJson { id: PayloadId, err: json::Error }, PayloadError { id: PayloadId, err: String },
#[error(transparent)] #[error(transparent)]
Doc(#[from] DocError), Doc(#[from] DocError),
#[error("incompatible payloads: The rule(s) xyz.radicle.crefs.rules.{matches:?} matches the value of xyz.radicle.project.defaultBranch ('{default}'). Possible resolutions: Change the name of the default branch or remove the rule(s).")]
DisallowDefault {
matches: Vec<String>,
default: git::Qualified<'static>,
},
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@ -124,6 +124,8 @@ pub enum RepositoryError {
MissingBranchRule, MissingBranchRule,
#[error("could not get the default branch rule: {0}")] #[error("could not get the default branch rule: {0}")]
DefaultBranchRule(#[from] doc::DefaultBranchRuleError), DefaultBranchRule(#[from] doc::DefaultBranchRuleError),
#[error("failed to get canonical reference rules: {0}")]
CanonicalRefs(#[from] doc::CanonicalRefsError),
} }
impl RepositoryError { impl RepositoryError {