From af6cf03acda43bc28ac7de854ec9a4768a06c397 Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Tue, 9 Jul 2024 09:02:22 +0100 Subject: [PATCH] radicle: canonical reference rules Introduce the `rules` module beneath `canonical`. The implementation follows the Canonical References RIP[^0]. A `Rule` defines the DIDs and threshold required to get the canonical tip for a reference (or set of references). Since a `Rule` has some specific validation rules, there can be two versions of a `Rule`. The `RawRule` is one that can be easily constructed, serialized, and deserialized. It must then be validated into a `ValidRule`, which validates any constraints, and can be used in the computing of a canonical tip. For convenience, a set of `ValidRule`s is wrapped into the `Rules` type, which also has a further constraint that each rule refers to a specific `refspec`. [0]: https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3trNYnLWS11cJWC6BbxDs5niGo82/patches/1d1ce874f7c39ecdcd8c318bbae46ffd02fe1ea8 --- Cargo.lock | 7 + crates/radicle/Cargo.toml | 1 + crates/radicle/src/git/canonical.rs | 3 + crates/radicle/src/git/canonical/rules.rs | 1206 +++++++++++++++++++++ crates/radicle/src/identity/doc.rs | 6 + 5 files changed, 1223 insertions(+) create mode 100644 crates/radicle/src/git/canonical/rules.rs diff --git a/Cargo.lock b/Cargo.lock index 6c987563..39d0b714 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -845,6 +845,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fast-glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3afcf4effa2c44390b9912544582d5af29e10dc4c816c5dbebf748e1c7416faa" + [[package]] name = "faster-hex" version = "0.9.0" @@ -2464,6 +2470,7 @@ dependencies = [ "crossbeam-channel", "cyphernet", "emojis", + "fast-glob", "fastrand", "git2", "jsonschema", diff --git a/crates/radicle/Cargo.toml b/crates/radicle/Cargo.toml index c7b19ff4..dd7fe4ef 100644 --- a/crates/radicle/Cargo.toml +++ b/crates/radicle/Cargo.toml @@ -22,6 +22,7 @@ chrono = { workspace = true, features = ["clock"], optional = true } colored = { workspace = true, optional = true } crossbeam-channel = { workspace = true } cyphernet = { workspace = true, features = ["tor", "dns", "p2p-ed25519"] } +fast-glob = { version = "0.3.2" } fastrand = { workspace = true } git2 = { workspace = true, features = ["vendored-libgit2"] } libc = { workspace = true } diff --git a/crates/radicle/src/git/canonical.rs b/crates/radicle/src/git/canonical.rs index 08417f3e..2f5e9282 100644 --- a/crates/radicle/src/git/canonical.rs +++ b/crates/radicle/src/git/canonical.rs @@ -1,3 +1,6 @@ +pub mod rules; +pub use rules::{MatchedRule, RawRule, Rules, ValidRule}; + use std::collections::BTreeMap; use std::fmt; diff --git a/crates/radicle/src/git/canonical/rules.rs b/crates/radicle/src/git/canonical/rules.rs new file mode 100644 index 00000000..2b3c6161 --- /dev/null +++ b/crates/radicle/src/git/canonical/rules.rs @@ -0,0 +1,1206 @@ +//! Implementation of RIP-0004 Canonical References +//! +//! [`RawRules`] is intended to be deserialized and then validated into a set of +//! [`Rules`]. These can then be used to see if a [`Qualified`] reference +//! matches any of the rules, using [`Rules::matches`]. Using [`Canonical`] with +//! the first matched rule, and this can be used to calculate the +//! [`Canonical::quorum`]. + +use core::fmt; +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use nonempty::NonEmpty; +use serde::{Deserialize, Serialize}; +use serde_json as json; +use thiserror::Error; + +use crate::git; +use crate::git::canonical::Canonical; +use crate::git::fmt::{refname, RefString}; +use crate::git::refspec::QualifiedPattern; +use crate::git::Qualified; +use crate::identity::{doc, Did}; +use crate::storage::git::Repository; + +const ASTERISK: char = '*'; + +static REFS_RAD: LazyLock = LazyLock::new(|| refname!("refs/rad")); + +/// Private trait to ensure that not any `Rule` can be deserialized. +/// Implementations are provided for `Allowed` and `usize` so that `RawRule`s +/// can be deserialized, while `ValidRule`s cannot – preventing deserialization +/// bugs for that type. +trait Sealed {} +impl Sealed for Allowed {} +impl Sealed for usize {} + +/// A `Pattern` is a `QualifiedPattern` reference, however, it disallows any +/// references under the `refs/rad` hierarchy. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(into = "QualifiedPattern", try_from = "QualifiedPattern")] +pub struct Pattern(QualifiedPattern<'static>); + +impl fmt::Display for Pattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0.as_str()) + } +} + +impl From for QualifiedPattern<'static> { + fn from(Pattern(pattern): Pattern) -> Self { + pattern + } +} + +impl<'a> TryFrom> for Pattern { + type Error = PatternError; + + fn try_from(pattern: QualifiedPattern<'a>) -> Result { + if pattern.starts_with(REFS_RAD.as_str()) { + Err(PatternError::ProtectedRef { + prefix: (*REFS_RAD).clone(), + pattern: pattern.to_owned(), + }) + } else { + Ok(Self(pattern.to_owned())) + } + } +} + +impl<'a> TryFrom> for Pattern { + type Error = PatternError; + + fn try_from(name: Qualified<'a>) -> Result { + Self::try_from(QualifiedPattern::from(name)) + } +} + +impl Pattern { + /// Check if the `refname` matches the rule's `refspec`. + pub fn matches(&self, refname: &Qualified) -> bool { + // N.b. Git's refspecs do not quite match with glob-star semantics. A + // single `*` in a refspec is expected to match all references under + // that namespace, even if they are further down the hierarchy. + // Thus, the following rules are applied: + // + // - a trailing `*` changes to `**/*` + // - a `*` in between path components changes to `**` + let spec = match self.0.as_str().split_once(ASTERISK) { + None => self.0.to_string(), + // Expand `refs/tags/*` to `refs/tags/**/*` + Some((prefix, "")) => { + let mut spec = prefix.to_string(); + spec.push_str("**/*"); + spec + } + // Expand `refs/tags/*/v1.0` to `refs/tags/**/v1.0` + Some((prefix, suffix)) => { + let mut spec = prefix.to_string(); + spec.push_str("**"); + spec.push_str(suffix); + spec + } + }; + fast_glob::glob_match(&spec, refname.as_str()) + } +} + +impl AsRef> for Pattern { + fn as_ref(&self) -> &QualifiedPattern<'static> { + &self.0 + } +} + +impl PartialOrd for Pattern { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Patterns are ordered by their specificity. +/// +/// This is heavily influenced by the evaluation priority of Rules. For a +/// candidate reference name, we want the rule associated with the most specific +/// pattern to apply, i.e. to take priority over all other rules with less +/// specific patterns. +/// +/// For two patterns `φ` and `ψ`, we say that "`φ` is more specific than `ψ`", denoted +/// `φ < ψ` if: +/// +/// 1. The number of components in `φ` is larger than the number of components +/// in `ψ`. (Note that the number of components is equal to the number of +/// occurrences of the symbol '/' in the pattern, plus 1). +/// The justification is, that refnames might be interpreted as a hierarchy +/// where a match on more components would mean a match at a lower level in +/// the hierarchy, thus being more specific. +/// Imagine a refname hierarchy that maps to a corporate hierarchy. +/// The pattern "department-1" matches all refnames that are administered +/// by a particular department, and thus is not very specific. +/// To contrast, the pattern "department-1/team-a/project-i/nice-feature" +/// is very specific as it matches all refnames that relate to the +/// development of a particular feature for a particular project by a +/// particular team. +/// Note that this would also apply when the connection between the `φ` and `ψ` +/// is not as obvious, e.g. also `a/b/c/d/* < */x`. +/// +/// (Note that for the following items, one may assume that `φ` and `ψ` have the +/// same number of components.) +/// +/// 2. If path component i of `φ`, denoted `φ[i]`, is more specific than path +/// component i of `ψ`, denoted `ψ[i]`. This is the case if: +/// a. `φ[i]` does not contain an asterisk and `ψ[i]` contains an asterisk, +/// i.e. the symbol `*`, e.g. `a < * and abc < a*`. +/// Note that this is important to capture specificity accross +/// components, i.e. to conclude that `a/b/* < a/*/c`. +/// b. Both `φ[i]` and `ψ[i]` contain an asterisk. +/// A. The asterisk in `φ[i]` is further right than the asterisk in `φ[i]`, +/// e.g. `aa* < a*`. +/// B. The asterisk in `φ[i]` and `ψ[i]` is equally far to the right, +/// and `φ[i]` is longer than `ψ[i]`, e.g. `a*b < a*`. +/// +/// 3. Otherwise, fall back to a lexicographic ordering. +/// +/// Some examples (justification in parentheses): +/// +/// ```text, no_run +/// refs/tags/release/candidates/* <(1.) refs/tags/release/* <(1.) refs/tags/* +/// refs/tags/v1.0 <(2.a.) refs/tags/* +/// refs/heads/* <(3.) refs/tags/* +/// refs/heads/main <(3.) refs/tags/v1.0 +/// ``` +impl Ord for Pattern { + fn cmp(&self, other: &Self) -> Ordering { + #[derive(Debug, Clone, Copy)] + #[repr(i8)] + enum ComponentOrdering { + MatchLength(Ordering), + Lexicographic(Ordering), + } + + impl ComponentOrdering { + fn merge(&mut self, other: Self) { + *self = match (*self, other) { + (Self::Lexicographic(Ordering::Equal), Self::Lexicographic(other)) => { + Self::Lexicographic(other) + } + (Self::Lexicographic(_), Self::MatchLength(other)) => Self::MatchLength(other), + (Self::MatchLength(Ordering::Equal), Self::MatchLength(other)) => { + Self::MatchLength(other) + } + (clone, _) => clone, + } + } + } + + impl From for Ordering { + fn from(value: ComponentOrdering) -> Self { + match value { + ComponentOrdering::MatchLength(ordering) => ordering, + ComponentOrdering::Lexicographic(ordering) => ordering, + } + } + } + + impl Default for ComponentOrdering { + /// The weakest value of Self, which will be absorbed by any + /// other in [`ComponentOrdering::merge`]. + fn default() -> Self { + Self::Lexicographic(Ordering::Equal) + } + } + + use git::refspec::Component; + + fn cmp_component(lhs: Component<'_>, rhs: Component<'_>) -> ComponentOrdering { + let (l, r) = (lhs.as_str(), rhs.as_str()); + match (l.find(ASTERISK), r.find(ASTERISK)) { + // (2.a.) + (Some(_), None) => ComponentOrdering::MatchLength(Ordering::Greater), + // (2.a.) + (None, Some(_)) => ComponentOrdering::MatchLength(Ordering::Less), + (Some(li), Some(ri)) => { + if li != ri { + // (2.b.A) + ComponentOrdering::MatchLength(li.cmp(&ri).reverse()) + } else if l.len() != r.len() { + // (2.b.B) + ComponentOrdering::MatchLength(l.len().cmp(&r.len()).reverse()) + } else { + // (3.) + ComponentOrdering::Lexicographic(l.cmp(r)) + } + } + // (3.) + (None, None) => ComponentOrdering::Lexicographic(l.cmp(r)), + } + } + + let mut result = ComponentOrdering::default(); + let mut lhs = self.0.components(); + let mut rhs = other.0.components(); + loop { + match (lhs.next(), rhs.next()) { + (None, Some(_)) => return Ordering::Greater, // (1.) + (Some(_), None) => return Ordering::Less, // (1.) + (Some(lhs), Some(rhs)) => { + result.merge(cmp_component(lhs, rhs)); + } + (None, None) => return result.into(), + } + } + } +} + +/// A [`Rule`] that can be serialized and deserialized safely. +/// +/// Should be converted to a [`ValidRule`] via [`Rule::validate`]. +pub type RawRule = Rule; + +impl RawRule { + /// Validate the `Rule` into a form that can be used for calculating + /// canonical references. + /// + /// The `resolve` callback is to allow the caller to specify the DIDs of the + /// identity document, in the case that the allowed value is + /// [`Allowed::Delegates`]. + pub fn validate(self, resolve: &mut R) -> Result + where + R: Fn() -> doc::Delegates, + { + let Self { + allow: delegates, + threshold, + .. + } = self; + let allow = match &delegates { + Allowed::Delegates => ResolvedDelegates::Delegates(resolve()), + Allowed::Set(delegates) => { + let valid = + doc::Delegates::new(delegates.clone()).map_err(ValidationError::from)?; + ResolvedDelegates::Set(valid) + } + }; + let threshold = doc::Threshold::new(threshold, &allow)?; + Ok(Rule { + allow, + threshold, + extensions: self.extensions, + }) + } +} + +/// A set of `RawRule`s that can be serialized and deserialized. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RawRules { + /// The reference pattern that this rule applies to. + /// + /// Note that this can be a fully-qualified pattern, e.g. `refs/heads/qa`, + /// as well as a wild-card pattern, e.g. `refs/tags/*`. + #[serde(flatten)] + pub rules: BTreeMap, +} + +impl RawRules { + /// Returns an iterator over the [`Pattern`] and [`RawRule`] in the set of + /// rules. + pub fn iter(&self) -> impl Iterator { + self.rules.iter() + } + + /// Add a new [`RawRule`] to the set of rules. + /// + /// Returns the replaced rule, if it existed. + pub fn insert(&mut self, pattern: Pattern, rule: RawRule) -> Option { + self.rules.insert(pattern, rule) + } + + /// Remove the rule that matches the `pattern` parameter. + /// + /// Returns the rule if it existed. + pub fn remove(&mut self, pattern: &Pattern) -> Option { + self.rules.remove(pattern) + } + + /// Check to see if there is an exact match for `refname` in the rules. + pub fn exact_match(&self, refname: &Qualified) -> bool { + let refname = refname.as_str(); + self.rules + .iter() + .any(|(pattern, _)| pattern.0.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 + use<'a, '_, 'b> { + let refname = refname.clone(); + self.rules + .iter() + .filter(move |(pattern, _)| pattern.matches(&refname)) + } +} + +impl Extend<(Pattern, RawRule)> for RawRules { + fn extend>(&mut self, iter: T) { + self.rules.extend(iter) + } +} + +impl From> for RawRules { + fn from(rules: BTreeMap) -> Self { + RawRules { rules } + } +} + +impl FromIterator<(Pattern, RawRule)> for RawRules { + fn from_iter>(iter: T) -> Self { + iter.into_iter().collect::>().into() + } +} + +impl IntoIterator for RawRules { + type Item = (Pattern, RawRule); + type IntoIter = std::collections::btree_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.rules.into_iter() + } +} + +/// A [`Rule`] that has been validated. See [`Rules`] and [`Rules::matches`] for +/// its main usage. +/// +/// N.b. a `ValidRule` can be serialized, however, it cannot be deserialized. +/// This is due to the fact that the `allow` field may have a value of +/// `delegates`. In those cases the value needs to be looked up via the identity +/// document and validated. +pub type ValidRule = Rule; + +impl ValidRule { + /// Initialize a `ValidRule` for the default branch, given by `name`. The + /// rule will contain the single `did` as the allowed DID, and use a + /// threshold of `1`. + /// + /// Note that the serialization of the rule will use the `delegates` token + /// for the rule. E.g. + /// ```json + /// { + /// "pattern": "refs/heads/main", + /// "allow": ["did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"], + /// "threshold": 1 + /// } + /// ``` + /// + /// # Errors + /// + /// If the `name` reference begins with `refs/rad`. + pub fn default_branch(did: Did, name: &git::RefStr) -> Result<(Pattern, Self), PatternError> { + let pattern = Pattern::try_from(git::refs::branch(name).to_owned())?; + let rule = Self { + allow: ResolvedDelegates::Delegates(doc::Delegates::from(did)), + // N.B. this needs to be the minimum since we only have one + // delegate. + threshold: doc::Threshold::MIN, + extensions: json::Map::new(), + }; + Ok((pattern, rule)) + } +} + +impl From for RawRule { + fn from(rule: ValidRule) -> Self { + let Rule { + allow, + threshold, + extensions, + } = rule; + Self { + allow: allow.into(), + threshold: threshold.into(), + extensions, + } + } +} + +/// A representation of a set of allowed DIDs. +/// +/// `Allowed` is used in a `RawRule`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Allowed { + /// Pointer to the identity document's set of delegates. + #[serde(rename = "delegates")] + #[default] + Delegates, + /// Explicit list of allowed DIDs. + /// + /// The elements of the list of allowed DIDs will be made unique, i.e. + /// duplicate DIDs will be discarded. + /// + /// # Validation + /// + /// The list of allowed DIDs, `allowed`, must satisfy: + /// ```text + /// 1 <= allowed.len() <= 255 + /// ``` + #[serde(untagged)] + Set(NonEmpty), +} + +impl From> for Allowed { + fn from(dids: NonEmpty) -> Self { + Self::Set(dids) + } +} + +impl From for Allowed { + fn from(did: Did) -> Self { + Self::Set(NonEmpty::new(did)) + } +} + +/// A marker `enum` that is used in a [`ValidRule`]. +/// +/// It ensures that a rule that has been deserialized, resolving the `delegates` +/// token to a set of DIDs, is still serialized back to the `delegates` token – +/// as opposed to serializing it to the set of DIDs. +/// +/// The variants mirror the [`Allowed::Delegates`] and [`Allowed::Set`] +/// variants. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(into = "Allowed")] +pub enum ResolvedDelegates { + Delegates(doc::Delegates), + Set(doc::Delegates), +} + +impl From for Allowed { + fn from(ds: ResolvedDelegates) -> Self { + match ds { + ResolvedDelegates::Delegates(_) => Self::Delegates, + ResolvedDelegates::Set(ds) => Self::Set(ds.into()), + } + } +} + +impl std::ops::Deref for ResolvedDelegates { + type Target = doc::Delegates; + + fn deref(&self) -> &Self::Target { + match self { + ResolvedDelegates::Delegates(ds) => ds, + ResolvedDelegates::Set(ds) => ds, + } + } +} + +/// A reference that has been matched against a [`ValidRule`]. +/// +/// Can be constructed by using [`Rules::matches`]. +#[derive(Debug)] +pub struct MatchedRule<'a> { + refname: Qualified<'a>, + rule: ValidRule, +} + +impl MatchedRule<'_> { + /// Return the reference name that was used for checking if it was a match. + pub fn refname(&self) -> &Qualified { + &self.refname + } + + /// Return the rule that was matched. + pub fn rule(&self) -> &ValidRule { + &self.rule + } + + /// Return the allowed DIDs for the matched rule. + pub fn allowed(&self) -> &doc::Delegates { + self.rule().allowed() + } + + /// Return the [`doc::Threshold`] for the matched rule. + pub fn threshold(&self) -> &doc::Threshold { + self.rule().threshold() + } + + /// Return the [`Canonical`] representation for the matched rule. + pub fn canonical(&self, repo: &Repository) -> Result { + Canonical::reference( + repo, + &self.refname, + self.rule.allow.as_ref(), + self.rule.threshold.into(), + ) + } +} + +/// A set of valid [`Rule`]s, where the set of DIDs and threshold are fully +/// resolved and valid. Since the rules are constructed via a `BTreeMap`, they +/// cannot be duplicated. +/// +/// To construct the set of rules, use [`Rules::from_raw`], which validates a +/// set of [`RawRule`]s, and their [`Pattern`] references, into a set of +/// [`ValidRule`]s. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Rules { + #[serde(flatten)] + rules: BTreeMap, +} + +impl FromIterator<(Pattern, ValidRule)> for Rules { + fn from_iter>(iter: T) -> Self { + Self { + rules: iter.into_iter().collect(), + } + } +} + +impl<'a> IntoIterator for &'a Rules { + type Item = (&'a Pattern, &'a ValidRule); + type IntoIter = std::collections::btree_map::Iter<'a, Pattern, ValidRule>; + + fn into_iter(self) -> Self::IntoIter { + self.rules.iter() + } +} + +impl IntoIterator for Rules { + type Item = (Pattern, ValidRule); + type IntoIter = std::collections::btree_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.rules.into_iter() + } +} + +impl From for RawRules { + fn from(Rules { rules }: Rules) -> Self { + Self { + rules: rules + .into_iter() + .map(|(pattern, rule)| (pattern, rule.into())) + .collect(), + } + } +} + +impl Rules { + /// Returns an iterator over the [`Pattern`] and [`ValidRule`] in the set of + /// rules. + pub fn iter(&self) -> impl Iterator { + self.rules.iter() + } + + /// Returns `true` is the set of rules is empty. + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } + + /// Construct a set of `Rules` given a set of `RawRule`s. + pub fn from_raw( + rules: impl IntoIterator, + resolve: &mut R, + ) -> Result + where + R: Fn() -> doc::Delegates, + { + let valid = rules + .into_iter() + .map(|(pattern, rule)| rule.validate(resolve).map(|rule| (pattern, rule))) + .collect::>()?; + Ok(Self { rules: valid }) + } + + /// Return the first matching rule for the given `refname`, if there is any. + /// + /// N.b. it will find the first rule that is most specific for the given + /// `refname`. + pub fn matches<'a>(&self, refname: Qualified<'a>) -> Option> { + self.rules + .iter() + .find(|(pattern, _)| pattern.matches(&refname)) + .map(|(_, rule)| MatchedRule { + refname, + rule: rule.clone(), + }) + } +} + +/// A `Rule` defines how a reference or set of references can be made canonical, +/// i.e. have a top-level `refs/*` entry – see [`Pattern`]. +/// +/// The [`Rule::allowed`] type is generic to allow for [`Allowed`] to be used +/// for serialization and deserialization, however, the use of +/// [`Rule::validate`] should be used to get a valid rule. +/// +/// The [`Rule::threshold`], similarly, allows for [`doc::Threshold`] to be used, and +/// [`Rule::validate`] should be used to get a valid rule. +// N.b. it's safe to derive `Serialize` since we only allow constructing a +// `Rule` via `Rule::validate`, and we seal `Deserialize` by ensuring that only +// `RawRule` can be deserialized. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(bound(deserialize = "D: Sealed + Deserialize<'de>, T: Sealed + Deserialize<'de>"))] +pub struct Rule { + /// The set of allowed DIDs that are considered for voting for this rule. + allow: D, + /// The threshold the votes must pass for the reference(s) to be considered + /// canonical. + threshold: T, + + /// Optional extensions in rules. This is intended to preserve backwards and + /// forward-compatibility + #[serde(skip_serializing_if = "json::Map::is_empty")] + #[serde(flatten)] + extensions: json::Map, +} + +impl Rule { + /// Construct a new `Rule` with the given `allow` and `threshold`. + pub fn new(allow: D, threshold: T) -> Self { + Self { + allow, + threshold, + extensions: json::Map::new(), + } + } + + /// Get the set of DIDs this `Rule` was created with. + pub fn allowed(&self) -> &D { + &self.allow + } + + /// Get the set of threshold this `Rule` was created with. + pub fn threshold(&self) -> &T { + &self.threshold + } + + /// Get the extensions that may have been added to this `Rule`. + pub fn extensions(&self) -> &json::Map { + &self.extensions + } + + /// If the [`Rule::extensions`] is not set, the provided `extensions` will + /// be used. + /// + /// Otherwise, it expects that the JSON value is a `Map` and the + /// `extensions` are merged. If the existing value is any other kind of JSON + /// value, this is a no-op. + pub fn add_extensions(&mut self, extensions: impl Into>) { + self.extensions.extend(extensions.into()); + } +} + +#[derive(Debug, Error)] +pub enum PatternError { + #[error("cannot create rule for '{pattern}' since references under '{prefix}' are protected")] + ProtectedRef { + prefix: RefString, + pattern: QualifiedPattern<'static>, + }, +} + +#[derive(Debug, Error)] +pub enum ValidationError { + #[error(transparent)] + Threshold(#[from] doc::ThresholdError), + #[error(transparent)] + Delegates(#[from] doc::DelegatesError), + #[error("cannot create rule for reserved `rad` references '{pattern}'")] + RadRef { pattern: QualifiedPattern<'static> }, +} + +#[derive(Debug, Error)] +pub enum CanonicalError { + #[error(transparent)] + Git(#[from] git::raw::Error), + #[error(transparent)] + References(#[from] git::ext::Error), +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::collections::BTreeMap; + + use nonempty::nonempty; + + use crate::crypto::{test::signer::MockSigner, Signer}; + use crate::git; + use crate::git::refspec::qualified_pattern; + use crate::git::RefString; + use crate::identity::doc::Doc; + use crate::identity::Visibility; + use crate::node::device::Device; + use crate::rad; + use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH}; + use crate::storage::{git::transport, ReadStorage}; + use crate::test::{arbitrary, fixtures}; + use crate::Storage; + + use super::*; + + fn roundtrip(rule: &Rule) { + let json = serde_json::to_string(rule).unwrap(); + assert_eq!( + *rule, + serde_json::from_str(&json).unwrap(), + "failed to roundtrip: {json}" + ) + } + + fn did(s: &str) -> Did { + s.parse().unwrap() + } + + fn pattern(qp: QualifiedPattern<'static>) -> Pattern { + Pattern::try_from(qp).unwrap() + } + + fn resolve_from_doc(doc: &Doc) -> doc::Delegates { + doc.delegates().clone() + } + + fn tag(name: RefString, head: git2::Oid, repo: &git2::Repository) -> git::Oid { + let commit = fixtures::commit(name.as_str(), &[head], repo); + let target = repo.find_object(*commit, None).unwrap(); + let tagger = repo.signature().unwrap(); + repo.tag(name.as_str(), &target, &tagger, name.as_str(), false) + .unwrap() + .into() + } + + #[test] + fn test_roundtrip() { + let rule1 = Rule::new(Allowed::Delegates, 1); + let rule2 = Rule::new(Allowed::Delegates, 1); + let rule3 = Rule::new(Allowed::Delegates, 1); + let mut rule4 = Rule::new( + Allowed::Set(nonempty![ + did("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K"), + did("did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"), + ]), + 2, + ); + rule4.add_extensions( + serde_json::json!({ + "foo": "bar", + "quux": 5, + }) + .as_object() + .cloned() + .unwrap(), + ); + roundtrip(&rule1); + roundtrip(&rule2); + roundtrip(&rule3); + roundtrip(&rule4); + } + + #[test] + fn test_deserialization() { + let examples = r#" +{ + "refs/heads/main": { + "threshold": 2, + "allow": [ + "did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K", + "did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye" + ] + }, + "refs/tags/releases/*": { + "threshold": 2, + "allow": [ + "did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56", + "did:key:z6Mkq2E5Se5H9gk1DsL1EMwR2t4CqSg3GFkNN2UeG4FNqXoP", + "did:key:z6MkqRmXW5fbP9hJ1Y8j2N4CgVdJ2XJ6TsyXYf3FQ2NJgXax" + ] + }, + "refs/heads/development": { + "threshold": 1, + "allow": [ + "did:key:z6MkhH7ENYE62JAjTiRZPU71MGZ6xCwnbyHHWfrBu3fr6PVG" + ] + }, + "refs/heads/release/*": { + "threshold": 1, + "allow": "delegates" + } +} + "#; + let expected = [ + ( + pattern(qualified_pattern!("refs/heads/main")), + Rule::new( + Allowed::Set(nonempty![ + did("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K"), + did("did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"), + ]), + 2, + ), + ), + ( + pattern(qualified_pattern!("refs/tags/releases/*")), + Rule::new( + Allowed::Set(nonempty![ + did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"), + did("did:key:z6Mkq2E5Se5H9gk1DsL1EMwR2t4CqSg3GFkNN2UeG4FNqXoP"), + did("did:key:z6MkqRmXW5fbP9hJ1Y8j2N4CgVdJ2XJ6TsyXYf3FQ2NJgXax") + ]), + 2, + ), + ), + ( + pattern(qualified_pattern!("refs/heads/development")), + Rule::new( + Allowed::Set(nonempty![did( + "did:key:z6MkhH7ENYE62JAjTiRZPU71MGZ6xCwnbyHHWfrBu3fr6PVG" + )]), + 1, + ), + ), + ( + pattern(qualified_pattern!("refs/heads/release/*")), + Rule::new(Allowed::Delegates, 1), + ), + ] + .into_iter() + .collect::(); + let rules = serde_json::from_str::>(examples) + .unwrap() + .into(); + assert_eq!(expected, rules) + } + + #[test] + fn test_order() { + assert!( + pattern(qualified_pattern!("a/b/c/d/*")) < pattern(qualified_pattern!("*/x")), + "example 1" + ); + assert!( + pattern(qualified_pattern!("a")) < pattern(qualified_pattern!("*")), + "example 2.a" + ); + assert!( + pattern(qualified_pattern!("abc")) < pattern(qualified_pattern!("a*")), + "example 2.a" + ); + assert!( + pattern(qualified_pattern!("a/b/*")) < pattern(qualified_pattern!("a/*/c")), + "example 2.a" + ); + assert!( + pattern(qualified_pattern!("aa*")) < pattern(qualified_pattern!("a*")), + "example 2.b.A" + ); + assert!( + pattern(qualified_pattern!("a*b")) < pattern(qualified_pattern!("a*")), + "example 2.b.B" + ); + + let pattern01 = pattern(qualified_pattern!("refs/tags/*")); + let pattern02 = pattern(qualified_pattern!("refs/tags/v1")); + let pattern04 = pattern(qualified_pattern!("refs/tags/v1.0.0")); + let pattern05 = pattern(qualified_pattern!("refs/tags/release/v1.0.0")); + let pattern03 = pattern(qualified_pattern!("refs/heads/main")); + let pattern06 = pattern(qualified_pattern!("refs/tags/*/v1.0.0")); + + let pattern07 = pattern(qualified_pattern!("refs/tags/x*")); + let pattern08 = pattern(qualified_pattern!("refs/tags/xx*")); + + let pattern09 = pattern(qualified_pattern!("refs/foos/*")); + + let pattern10 = pattern(qualified_pattern!("a")); + let pattern11 = pattern(qualified_pattern!("b")); + + let pattern12 = pattern(qualified_pattern!("a/*")); + let pattern13 = pattern(qualified_pattern!("b/*")); + + let pattern14 = pattern(qualified_pattern!("a/*/ab")); + let pattern15 = pattern(qualified_pattern!("a/*/a")); + + let pattern16 = pattern(qualified_pattern!("a/*/b")); + let pattern17 = pattern(qualified_pattern!("a/*/a")); + + // Test priority for path specificity + assert!( + pattern06 < pattern02, + "match for 06 is always more specific since it has more components" + ); + assert!(pattern02 < pattern01, "match for 02 is also match for 01"); + assert!(pattern08 < pattern07, "match for 08 is also match for 07"); + // Test equality + assert!(pattern02 == pattern02); + // Test lexicographical fallback when paths are equally specific + assert!(pattern02 < pattern04); + assert!(pattern03 < pattern01); + assert!(pattern09 < pattern01); + assert!(pattern10 < pattern11); + assert!(pattern12 < pattern13); + assert!(pattern15 < pattern14); + assert!( + pattern17 < pattern16, + "matches have same length, but lexicographically, 'a' < 'b'" + ); + + // Test example from docs + let pattern18 = pattern(qualified_pattern!("refs/tags/release/candidates/*")); + let pattern19 = pattern(qualified_pattern!("refs/tags/release/*")); + let pattern20 = pattern(qualified_pattern!("refs/tags/*")); + + assert!(pattern18 < pattern19); + assert!(pattern19 < pattern20); + + let pattern21 = pattern(qualified_pattern!("refs/heads/dev")); + + assert!(pattern21 < pattern03); + + let mut patterns = [ + pattern01.clone(), + pattern02.clone(), + pattern03.clone(), + pattern04.clone(), + pattern05.clone(), + pattern06.clone(), + ]; + patterns.sort(); + + assert_eq!( + patterns, + [pattern05, pattern06, pattern03, pattern02, pattern04, pattern01] + ); + } + + #[test] + fn test_deserialize_extensions() { + let example = r#" +{ + "threshold": 2, + "allow": [ + "did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K", + "did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye" + ], + "foo": "bar", + "quux": 5 +} +"#; + let rule = serde_json::from_str::>(example).unwrap(); + assert!(!rule.extensions().is_empty()); + let extensions = rule.extensions(); + assert_eq!( + extensions.get("foo"), + Some(serde_json::Value::String("bar".to_string())).as_ref() + ); + assert_eq!( + extensions.get("quux"), + Some(serde_json::Value::Number(5.into())).as_ref() + ); + } + + #[test] + fn test_rule_validate_success() { + let doc = arbitrary::gen::(1); + let delegates = Allowed::Set(doc.delegates().as_ref().clone()); + let threshold = doc.majority(); + + let rule = Rule::new(delegates, threshold); + let result = rule.validate(&mut || resolve_from_doc(&doc)); + assert!(result.is_ok(), "failed to validate doc: {result:?}"); + + let rule = Rule::new(Allowed::Delegates, 1); + let result = rule.validate(&mut || resolve_from_doc(&doc)); + assert!(result.is_ok(), "failed to validate doc: {result:?}"); + } + + #[test] + fn test_rule_validate_failures() { + let doc = arbitrary::gen::(1); + let pattern = pattern(qualified_pattern!("refs/heads/main")); + + assert!(matches!( + Rule::new(Allowed::Delegates, 256).validate(&mut || resolve_from_doc(&doc)), + Err(ValidationError::Threshold(_)) + )); + + let threshold = doc.delegates().len().saturating_add(1); + assert!(matches!( + Rule::new(Allowed::Delegates, threshold).validate(&mut || resolve_from_doc(&doc)), + Err(ValidationError::Threshold(_)) + )); + + let delegates = NonEmpty::from_vec(arbitrary::vec::(256)).unwrap(); + assert!(matches!( + Rule::new(delegates.into(), 1).validate(&mut || resolve_from_doc(&doc)), + Err(ValidationError::Delegates(_)) + )); + + let delegates = nonempty![ + did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"), + did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56") + ]; + let expected = Rule { + allow: ResolvedDelegates::Set( + doc::Delegates::new(nonempty![did( + "did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56" + )]) + .unwrap(), + ), + threshold: doc::Threshold::MIN, + extensions: json::Map::new(), + }; + assert_eq!( + Rule::new(delegates.into(), 1) + .validate(&mut || resolve_from_doc(&doc)) + .unwrap(), + expected, + ); + + // Duplicate rules are overwritten + let rules = vec![ + (pattern.clone(), Rule::new(Allowed::Delegates, 1)), + ( + pattern.clone(), + Rule::new(doc.delegates().as_ref().clone().into(), 1), + ), + ]; + let expected = [( + pattern, + Rule::new( + ResolvedDelegates::Set(doc.delegates().clone()), + doc::Threshold::MIN, + ), + )] + .into_iter() + .collect::(); + assert_eq!( + Rules::from_raw(rules, &mut || resolve_from_doc(&doc)).unwrap(), + expected + ); + } + + #[test] + fn test_canonical() { + let tempdir = tempfile::tempdir().unwrap(); + let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap(); + + transport::local::register(storage.clone()); + + let delegate = Device::mock_from_seed([0xff; 32]); + let contributor = MockSigner::from_seed([0xfe; 32]); + let (repo, head) = fixtures::repository(tempdir.path().join("working")); + let (rid, doc, _) = rad::init( + &repo, + "heartwood".try_into().unwrap(), + "Radicle Heartwood Protocol & Stack", + git::refname!("master"), + Visibility::default(), + &delegate, + &storage, + ) + .unwrap(); + + let mut doc = doc.edit(); + // Ensure there is a second delegate for testing overlapping rules + doc.delegate(contributor.public_key().into()); + + // Create tags and keep track of their OIDs + // + // follows the `refs/tags/release/candidates/*` rule + let failing_tag = git::refname!("release/candidates/v1.0"); + let tags = [ + // follows the `refs/tags/*` rule + git::refname!("v1.0"), + // follows the `refs/tags/release/*` rule + git::refname!("release/v1.0"), + failing_tag.clone(), + // follows the `refs/tags/*` rule + git::refname!("qa/v1.0"), + ] + .into_iter() + .map(|name| { + ( + git::lit::refs_tags(name.clone()).into(), + tag(name, head, &repo), + ) + }) + .collect::>(); + + git::push( + &repo, + &rad::REMOTE_NAME, + [ + ( + &git::qualified!("refs/tags/v1.0"), + &git::qualified!("refs/tags/v1.0"), + ), + ( + &git::qualified!("refs/tags/release/v1.0"), + &git::qualified!("refs/tags/release/v1.0"), + ), + ( + &git::qualified!("refs/tags/release/candidates/v1.0"), + &git::qualified!("refs/tags/release/candidates/v1.0"), + ), + ( + &git::qualified!("refs/tags/qa/v1.0"), + &git::qualified!("refs/tags/qa/v1.0"), + ), + ], + ) + .unwrap(); + + let rules = Rules::from_raw( + [ + ( + pattern(qualified_pattern!("refs/tags/*")), + Rule::new(Allowed::Delegates, 1), + ), + ( + pattern(qualified_pattern!("refs/tags/release/*")), + Rule::new(Allowed::Delegates, 1), + ), + // Ensure that none of the other rules apply by ensuring we need + // both delegates to get the quorum of the + // `refs/tags/release/candidates/v1.0` reference + ( + pattern(qualified_pattern!("refs/tags/release/candidates/*")), + Rule::new(Allowed::Delegates, 2), + ), + ], + &mut || resolve_from_doc(&doc.clone().verified().unwrap()), + ) + .unwrap(); + + // All tags should succeed at getting their canonical commit other than the + // candidates tag. + let stored = storage.repository(rid).unwrap(); + let failing = git::Qualified::from(git::lit::refs_tags(failing_tag)); + for (refname, oid) in tags.iter() { + let matched = rules.matches(refname.clone()).unwrap_or_else(|| { + panic!("there should be a matching rule for {refname}, rules: {rules:#?}") + }); + let canonical = matched.canonical(&stored).unwrap(); + if *refname == failing { + assert!(canonical.quorum(&repo).is_err()); + } else { + assert_eq!( + canonical + .quorum(&repo) + .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")), + *oid, + ) + } + } + } + + #[test] + fn test_special_branches() { + assert!(Pattern::try_from((*IDENTITY_BRANCH).clone()).is_err()); + assert!(Pattern::try_from((*SIGREFS_BRANCH).clone()).is_err()); + assert!(Pattern::try_from((*IDENTITY_ROOT).clone()).is_err()); + } +} diff --git a/crates/radicle/src/identity/doc.rs b/crates/radicle/src/identity/doc.rs index a3b07274..f9da46d6 100644 --- a/crates/radicle/src/identity/doc.rs +++ b/crates/radicle/src/identity/doc.rs @@ -477,6 +477,12 @@ impl AsRef> for Delegates { } } +impl From for Delegates { + fn from(did: Did) -> Self { + Self(NonEmpty::new(did)) + } +} + impl TryFrom> for Delegates { type Error = DelegatesError;