radicle/canonical: Remove RawRules::matches

Testing revealed that the implementation violated RIP-0006, which
requires the most specific rule matching the target of `HEAD`
to use an `allow` of `delegates` and the document threshold.
`validate_head_rule` took the first match from `RawRules`, which orders
patterns lexicographically, so a glob could be validated in place of the
rule that actually governs the branch.
The issue here is that the correct order for matching patterns is only
guaranteed on `Rules`, but not on `RawRules`.

To prevent such errors in the future, remove `RawRules::matches`. This
requires some refactoring, and makes synthesis of the default branch
rule slightly less efficient in the worst case, but we are willing to
take that hit for a cleaner implementation.

Co-authored-by: Lorenz Leutgeb <lorenz.leutgeb@radicle.dev>
This commit is contained in:
Daniel Norman
2026-09-01 19:41:02 +02:00
committed by Lorenz Leutgeb
co-authored by Lorenz Leutgeb
parent fd4e4903d7
commit 4867917f37
6 changed files with 148 additions and 79 deletions
@@ -1,17 +1,15 @@
Bob overhears that the new name for the default branch is "main", not "master" Bob has a weird name for a particular release in mind, and also wants to test
as it used to be. To make tooling that expect "main" work, without the hassle out symbolic references.
of having to push to such branch manually, he opts to create a symbolic
reference.
``` ~bob ``` ~bob
$ cd heartwood $ cd heartwood
$ rad id update --title "Add canonical symbolic ref" --payload xyz.radicle.crefs symbolic '{ "refs/heads/main": "refs/heads/master" }' $ rad id update --title "Add canonical symbolic ref" --payload xyz.radicle.crefs symbolic '{ "refs/heads/foobar": "refs/heads/releases/foobaz" }'
✓ Identity revision [..] created ✓ Identity revision [..] created
╭────────────────────────────────────────────────────────────────────────╮ ╭────────────────────────────────────────────────────────────────────────╮
│ Title Add canonical symbolic ref │ │ Title Add canonical symbolic ref │
│ Revision 62e2cb60c6df9ad9908b6697b5d126760a855484 │ │ Revision b7597e3eeaba64467db4417602518169b76b43d5 │
│ Parent 37a1aad231100cd206c49aed79e405ea2da9204b │ │ Parent 37a1aad231100cd206c49aed79e405ea2da9204b │
│ Blob b20de7b184673eb0d9227be17640c923d8ef3f3e │ │ Blob 49a21614cf358b131fd6b590d0d01396f98906ce │
│ Author did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │ │ Author did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │
│ State active │ │ State active │
│ Quorum no │ │ Quorum no │
@@ -41,7 +39,7 @@ $ rad id update --title "Add canonical symbolic ref" --payload xyz.radicle.crefs
} }
+ }, + },
+ "symbolic": { + "symbolic": {
+ "refs/heads/main": "refs/heads/master" + "refs/heads/foobar": "refs/heads/releases/foobaz"
} }
}, },
"xyz.radicle.project": { "xyz.radicle.project": {
@@ -58,14 +56,22 @@ $ rad id update --title "Add canonical symbolic ref" --payload xyz.radicle.crefs
} }
``` ```
Alice is happy with the new revision and accepts it. Bob proceeds to push some commit to the branch that is targeted by the
symbolic reference.
``` ~bob
$ git push -q rad master:releases/foobaz
```
Alice decides to play along with Bob's strange ideas and accepts the new
revision.
``` ~alice ``` ~alice
$ rad sync -f $ rad sync -f
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s). Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s) ✓ Target met: 1 seed(s)
🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk 🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
$ rad id accept 62e2cb60c6df9ad9908b6697b5d126760a855484 -q $ rad id accept b7597e3eeaba64467db4417602518169b76b43d5 -q
``` ```
As usual, alice works on "master". As usual, alice works on "master".
@@ -91,9 +97,10 @@ She inspects the remote and sees that indeed a new branch named "main" now exist
``` ~alice ``` ~alice
$ git ls-remote rad $ git ls-remote rad
4dc510ddea5fd66499d1d2e996b8a97c8d57be54 HEAD 4dc510ddea5fd66499d1d2e996b8a97c8d57be54 HEAD
4dc510ddea5fd66499d1d2e996b8a97c8d57be54 refs/heads/main f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/foobar
4dc510ddea5fd66499d1d2e996b8a97c8d57be54 refs/heads/master 4dc510ddea5fd66499d1d2e996b8a97c8d57be54 refs/heads/master
afec366785ed3651cdc66975c0fec41866c9ce62 refs/heads/releases/2 afec366785ed3651cdc66975c0fec41866c9ce62 refs/heads/releases/2
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/releases/foobaz
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/tags/qa/v2.1 f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/tags/qa/v2.1
ac51a0746a5e8311829bc481202909a1e3acc0c2 refs/tags/v1.0-hotfix ac51a0746a5e8311829bc481202909a1e3acc0c2 refs/tags/v1.0-hotfix
89f935f27a16f8ed97915ade4accab8fe48057aa refs/tags/v2.0 89f935f27a16f8ed97915ade4accab8fe48057aa refs/tags/v2.0
@@ -104,10 +111,11 @@ Of course, she can also fetch it to her working copy as usual.
``` ~alice (stderr) ``` ~alice (stderr)
$ git fetch rad $ git fetch rad
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
* [new branch] main -> rad/main * [new branch] foobar -> rad/foobar
* [new branch] releases/2 -> rad/releases/2 * [new branch] releases/2 -> rad/releases/2
* [new tag] qa/v2.1 -> rad/tags/qa/v2.1 * [new branch] releases/foobaz -> rad/releases/foobaz
* [new tag] qa/v2.1 -> qa/v2.1 * [new tag] qa/v2.1 -> rad/tags/qa/v2.1
* [new tag] qa/v2.1 -> qa/v2.1
``` ```
Bob fetches Alice's changes. Bob fetches Alice's changes.
@@ -124,8 +132,8 @@ And, sure enough, there is the new branch just as he wanted it.
``` ~bob (stderr) ``` ~bob (stderr)
$ git fetch rad $ git fetch rad
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
* [new branch] main -> rad/main * [new branch] foobar -> rad/foobar
f2de534..4dc510d master -> rad/master f2de534..4dc510d master -> rad/master
``` ```
Note that neither Alice nor Bob pushed directly to "main". Note that neither Alice nor Bob pushed directly to "foobar".
+10 -16
View File
@@ -32,9 +32,9 @@ use super::protect::Unprotected;
const ASTERISK: char = '*'; const ASTERISK: char = '*';
/// Private trait to ensure that not any `Rule` can be deserialized. /// Private trait to ensure that not any [`Rule`] can be deserialized.
/// Implementations are provided for `Allowed` and `usize` so that `RawRule`s /// Implementations are provided for [`Allowed`] and [`usize`] so that [`RawRule`]s
/// can be deserialized, while `ValidRule`s cannot – preventing deserialization /// can be deserialized, while [`ValidRule`]s cannot – preventing deserialization
/// bugs for that type. /// bugs for that type.
trait Sealed {} trait Sealed {}
impl Sealed for Allowed {} impl Sealed for Allowed {}
@@ -246,7 +246,13 @@ impl RawRule {
} }
} }
/// A set of `RawRule`s that can be serialized and deserialized. /// A set of [`RawRule`]s that can be serialized and deserialized.
///
/// Note that matching on the [`RawPattern`]s is deliberately not implemented,
/// as the [`RawRule`]s are not validated and thus should not be used to
/// calculate canonical references.
/// If you want to match on a set of rules, obtain [`Rules`] via
/// [`Rules::from_raw`], and use [`Rules::matches`] on the result.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RawRules { pub struct RawRules {
/// The reference pattern that this rule applies to. /// The reference pattern that this rule applies to.
@@ -285,18 +291,6 @@ impl RawRules {
.iter() .iter()
.any(|(pattern, _)| pattern.as_str() == refname) .any(|(pattern, _)| pattern.as_str() == refname)
} }
/// Check if the `refname` matches any existing rules, including glob
/// matches.
pub fn matches<'a, 'b>(
&self,
refname: &Qualified<'b>,
) -> impl Iterator<Item = (&RawPattern, &RawRule)> + use<'a, '_, 'b> {
let refname = refname.clone();
self.rules
.iter()
.filter(move |(pattern, _)| matches(pattern, &refname))
}
} }
impl Extend<(RawPattern, RawRule)> for RawRules { impl Extend<(RawPattern, RawRule)> for RawRules {
@@ -563,3 +563,45 @@ fn matches_exactly_curly_braces() {
assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/foo"))); assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/foo")));
assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/bar"))); assert!(!matches(&exact, &git::fmt::qualified!("refs/heads/bar")));
} }
#[test]
fn most_specific_match_ignores_lexicographic_order() {
use helper::*;
let glob = raw_pattern(qualified_pattern!("refs/heads/*"));
let nested = raw_pattern(qualified_pattern!("refs/heads/feature/*"));
let exact = raw_pattern(qualified_pattern!("refs/heads/main"));
let rules = RawRules::from_iter([
(glob.clone(), Rule::new(Allowed::Delegates, 1)),
(nested.clone(), Rule::new(Allowed::Delegates, 1)),
(exact.clone(), Rule::new(Allowed::Delegates, 1)),
]);
let resolve = &mut || {
doc::Delegates::from(
Did::decode("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K").unwrap(),
)
};
let rules = Rules::from_raw(rules, resolve).unwrap();
let main = git::fmt::qualified!("refs/heads/main");
assert_eq!(
rules.matches(&main).next().map(|(pattern, _)| pattern),
Some(&exact)
);
let feature = git::fmt::qualified!("refs/heads/feature/x");
assert_eq!(
rules.matches(&feature).next().map(|(pattern, _)| pattern),
Some(&nested),
"more components beat the glob"
);
assert!(
rules
.matches(&git::fmt::qualified!("refs/tags/v1.0"))
.next()
.is_none()
);
}
+4
View File
@@ -132,6 +132,10 @@ impl CanonicalRefs {
pub fn symbolic(&self) -> &SymbolicRefs { pub fn symbolic(&self) -> &SymbolicRefs {
&self.symbolic &self.symbolic
} }
pub fn into_raw(self) -> (RawRules, SymbolicRefs) {
(self.rules.into(), self.symbolic)
}
} }
impl Extend<(rules::RawPattern, rules::RawRule)> for RawCanonicalRefs { impl Extend<(rules::RawPattern, rules::RawRule)> for RawCanonicalRefs {
+43 -32
View File
@@ -19,7 +19,7 @@ use crate::cob::identity;
use crate::crypto; use crate::crypto;
use crate::crypto::Signature; use crate::crypto::Signature;
use crate::git; use crate::git;
use crate::git::canonical::rules; use crate::git::canonical::rules::{self, ResolvedDelegates};
use crate::git::canonical::symbolic; use crate::git::canonical::symbolic;
use crate::git::fmt::Qualified; use crate::git::fmt::Qualified;
use crate::git::fmt::RefString; use crate::git::fmt::RefString;
@@ -808,17 +808,20 @@ impl Doc {
/// ///
/// [`RawCanonicalRefs`]: super::crefs::RawCanonicalRefs /// [`RawCanonicalRefs`]: super::crefs::RawCanonicalRefs
pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> { pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
let mut raw_crefs = self let resolve = &mut || self.delegates.clone();
let crefs = self
.raw_canonical_refs() .raw_canonical_refs()
.transpose() .transpose()
.map_err(CanonicalRefsError::Payload)? .map_err(CanonicalRefsError::Payload)?
.map(|crefs| crefs.try_into_canonical_refs(resolve))
.transpose()
.map_err(CanonicalRefsError::CanonicalRefs)?
.unwrap_or_default(); .unwrap_or_default();
let resolve = &mut || self.delegates.clone();
// Determine where `HEAD` comes from. The `resolve_head()` result // Determine where `HEAD` comes from. The `resolve_head()` result
// borrows `raw_crefs`, so clone to allow mutation in the synthesis // borrows `raw_crefs`, so clone to allow mutation in the synthesis
// path. // path.
let head: Option<Qualified<'static>> = raw_crefs.symbolic().resolve_head().cloned(); let head: Option<Qualified<'static>> = crefs.symbolic().resolve_head().cloned();
match (head, self.project()) { match (head, self.project()) {
(Some(ref default_branch), Some(Ok(project))) => { (Some(ref default_branch), Some(Ok(project))) => {
@@ -831,13 +834,14 @@ impl Doc {
}, },
)); ));
} }
self.validate_head_rule(&raw_crefs, default_branch)?; self.validate_head_rule(&crefs, default_branch)?;
} }
(Some(ref default_branch), None) => { (Some(ref default_branch), None) => {
self.validate_head_rule(&raw_crefs, default_branch)?; self.validate_head_rule(&crefs, default_branch)?;
} }
(None, Some(Ok(project))) => { (None, Some(Ok(project))) => {
self.synthesize_head(&mut raw_crefs, &project)?; let raw_crefs = self.synthesize_head(crefs, &project)?;
return Ok(raw_crefs.try_into_canonical_refs(resolve)?);
} }
(None, None) => { (None, None) => {
return Err(CanonicalRefsError::SynthesisPayloadMissing); return Err(CanonicalRefsError::SynthesisPayloadMissing);
@@ -847,7 +851,7 @@ impl Doc {
} }
} }
Ok(raw_crefs.try_into_canonical_refs(resolve)?) Ok(crefs)
} }
/// Validate that the rule matching `HEAD`'s target branch uses /// Validate that the rule matching `HEAD`'s target branch uses
@@ -857,23 +861,32 @@ impl Doc {
/// later by [`RawCanonicalRefs::try_into_canonical_refs`] validation. /// later by [`RawCanonicalRefs::try_into_canonical_refs`] validation.
fn validate_head_rule( fn validate_head_rule(
&self, &self,
raw_crefs: &RawCanonicalRefs, raw_crefs: &CanonicalRefs,
default_branch: &Qualified, default_branch: &Qualified<'static>,
) -> Result<(), CanonicalRefsError> { ) -> Result<(), CanonicalRefsError> {
let Some((pattern, rule)) = raw_crefs.raw_rules().matches(default_branch).next() else { let Some((pattern, rule)) = raw_crefs.rules().matches(default_branch).next() else {
return Ok(()); return Ok(());
}; };
let allowed = rule.allowed(); let _allowed = rule.allowed();
if *allowed != rules::Allowed::Delegates { match rule.allowed() {
return Err(CanonicalRefsError::DefaultBranchRuleError( ResolvedDelegates::Delegates(_) => {
DefaultBranchRuleError::Allowed { // This is the expected case, so we continue to validate the threshold.
pattern: pattern.to_string(), }
actual: allowed.to_string(), ResolvedDelegates::Set(actual) => {
}, return Err(CanonicalRefsError::DefaultBranchRuleError(
)); DefaultBranchRuleError::Allowed {
pattern: pattern.to_string(),
actual: actual
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(", "),
},
));
}
} }
let actual = *rule.threshold(); let actual = (*rule.threshold()).into();
let expected = self.threshold(); let expected = self.threshold();
if actual != expected { if actual != expected {
return Err(CanonicalRefsError::DefaultBranchRuleError( return Err(CanonicalRefsError::DefaultBranchRuleError(
@@ -891,30 +904,28 @@ impl Doc {
/// from the project payload. /// from the project payload.
fn synthesize_head( fn synthesize_head(
&self, &self,
raw_crefs: &mut RawCanonicalRefs, crefs: CanonicalRefs,
project: &Project, project: &Project,
) -> Result<(), CanonicalRefsError> { ) -> Result<RawCanonicalRefs, CanonicalRefsError> {
let default_branch = project.default_branch_qualified(); let default_branch = project.default_branch_qualified();
if raw_crefs let add_rule = crefs.rules().matches(&default_branch).next().is_none();
.raw_rules()
.matches(&default_branch) let (mut raw_rules, mut symbolic) = crefs.into_raw();
.next()
.is_none() if add_rule {
{ raw_rules.insert(
raw_crefs.raw_rules_mut().insert(
git::fmt::refspec::QualifiedPattern::from(default_branch.to_owned()), git::fmt::refspec::QualifiedPattern::from(default_branch.to_owned()),
rules::Rule::new(rules::Allowed::Delegates, self.threshold()), rules::Rule::new(rules::Allowed::Delegates, self.threshold()),
); );
} }
#[allow(deprecated)] #[allow(deprecated)]
raw_crefs symbolic
.symbolic_mut()
.combine(symbolic::SymbolicRefs::head(project.default_branch())) .combine(symbolic::SymbolicRefs::head(project.default_branch()))
.map_err(|source| CanonicalRefsError::SynthesisCycle { source })?; .map_err(|source| CanonicalRefsError::SynthesisCycle { source })?;
Ok(()) Ok(RawCanonicalRefs::new(raw_rules, symbolic))
} }
/// Return the associated [`Visibility`] of this document. /// Return the associated [`Visibility`] of this document.
+24 -14
View File
@@ -208,21 +208,38 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
} }
}; };
let resolve = &mut || proposal.delegates.clone();
let crefs = match proposal.raw_canonical_refs() {
None => None,
Some(Ok(crefs)) => match crefs.try_into_canonical_refs(resolve) {
Ok(crefs) => Some(crefs),
Err(err) => {
return Err(error::DocVerification::PayloadError {
id: PayloadId::canonical_refs().clone(),
err: err.to_string(),
});
}
},
Some(Err(PayloadError::Json(e))) => {
return Err(error::DocVerification::PayloadError {
id: PayloadId::canonical_refs().clone(),
err: e.to_string(),
});
}
};
// If we have both payloads `xyz.radicle.{project,crefs}` ensure that, // If we have both payloads `xyz.radicle.{project,crefs}` ensure that,
// in the `crefs` payload there is no … // in the `crefs` payload there is no …
// 1. … rule that matches the default branch from the `project` payload. // 1. … rule that matches the default branch from the `project` payload.
// (This rule must be synthesized!) // (This rule must be synthesized!)
// 2. … symbolic reference with the name `HEAD`. // 2. … symbolic reference with the name `HEAD`.
// (This reference must be synthesized!) // (This reference must be synthesized!)
match raw match crefs.zip(project) {
.raw_canonical_refs() Some((crefs, project)) => {
.transpose()
.map(|rcrefs| rcrefs.zip(project))
{
Ok(Some((crefs, project))) => {
let default = project.default_branch_qualified().to_owned(); let default = project.default_branch_qualified().to_owned();
let matches = crefs let matches = crefs
.raw_rules() .rules()
.matches(&default) .matches(&default)
.map(|(pattern, _)| pattern.to_string()) .map(|(pattern, _)| pattern.to_string())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -240,13 +257,6 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
_ => { /* we validate below */ } _ => { /* 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().clone(),
err: e.to_string(),
});
}
Ok(proposal) Ok(proposal)
} }