Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle/crefs: Support Symbolic References
✓ CI success Lorenz Leutgeb committed 7 months ago
commit 0891ad955e8a68b689b56a1b5e1a9e1077147586
parent adc0f4fecbc2e2c4c806d2fd69c1fb98c9d3d3c1
1 passed (1 total) View logs
17 files changed +855 -113
modified CHANGELOG.md
@@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `git-remote-rad` now correctly reports the default branch to Git by listing
  the symbolic reference `HEAD`.
- `rad status` learned a new option `--only nid` for printing the Node ID.
+
- Symbolic references can now be handled by canonical references by coding them
+
  in the payload `xyz.radicle.crefs` under the key `symbolic`.

## Fixed Bugs

modified crates/radicle-node/src/worker/fetch.rs
@@ -12,8 +12,7 @@ use radicle::prelude::RepoId;
use radicle::storage::git::Repository;
use radicle::storage::refs::RefsAt;
use radicle::storage::{
-
    ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, RepositoryError,
-
    WriteRepository as _,
+
    ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, WriteRepository as _,
};
use radicle::{cob, git, node, Storage};
use radicle_fetch::git::refs::Applied;
@@ -116,15 +115,6 @@ impl Handle {
                // points to a repository that is temporary and gets moved by [`mv`].
                let repo = storage.repository(rid)?;
                repo.set_identity_head()?;
-
                match repo.set_head_to_default_branch() {
-
                    Ok(()) => {
-
                        log::trace!(target: "worker", "Set HEAD successfully");
-
                    }
-
                    Err(RepositoryError::Quorum(e)) => {
-
                        log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
-
                    }
-
                    Err(e) => return Err(e.into()),
-
                }

                let canonical = match set_canonical_refs(&repo, &applied) {
                    Ok(updates) => updates.unwrap_or_default(),
@@ -363,8 +353,21 @@ fn set_canonical_refs(
    repo: &Repository,
    applied: &Applied,
) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> {
+
    const LOG_MESSAGE: &str = "set-canonical from fetch (radicle)";
+

    let identity = repo.identity()?;
-
    let rules = identity.doc().canonical_refs()?.rules().clone();
+
    let crefs = identity.doc().canonical_refs()?;
+

+
    for (name, target) in crefs.symbolic().iter() {
+
        if let Err(e) = repo.set_symbolic_ref(name, target, LOG_MESSAGE) {
+
            log::warn!(
+
                target: "worker",
+
                "Failed to set canonical symbolic reference {name}->{target}: {e}"
+
            );
+
        }
+
    }
+

+
    let rules = crefs.rules().clone();

    let mut updated_refs = UpdatedCanonicalRefs::default();
    let refnames = applied
@@ -406,12 +409,10 @@ fn set_canonical_refs(
                refname, object, ..
            }) => {
                let oid = object.id();
-
                if let Err(e) = repo.backend.reference(
-
                    refname.clone().as_str(),
-
                    *oid,
-
                    true,
-
                    "set-canonical-reference from fetch (radicle)",
-
                ) {
+
                if let Err(e) =
+
                    repo.backend
+
                        .reference(refname.clone().as_str(), *oid, true, LOG_MESSAGE)
+
                {
                    log::warn!(
                        target: "worker",
                        "Failed to set canonical reference {refname}->{oid}: {e}"
modified crates/radicle-remote-helper/src/push.rs
@@ -250,6 +250,8 @@ pub fn run(
    stdin: &io::Stdin,
    opts: Options,
) -> Result<(), Error> {
+
    const LOG_MESSAGE: &str = "set-canonical from push (radicle)";
+

    // Don't allow push if either of these conditions is true:
    //
    // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
@@ -282,8 +284,7 @@ pub fn run(
        }
    }
    let delegates = stored.delegates()?;
-
    let identity = stored.identity()?;
-
    let canonical_ref = identity.default_branch()?;
+

    let mut set_canonical_refs: Vec<(git::Qualified, git::canonical::Object)> =
        Vec::with_capacity(specs.len());

@@ -395,6 +396,8 @@ pub fn run(
    if !ok.is_empty() {
        let _ = stored.sign_refs(&signer)?;

+
        stored.set_canonical_symbolic_refs(LOG_MESSAGE)?;
+

        for (refname, object) in &set_canonical_refs {
            let oid = object.id();
            let kind = object.object_type();
@@ -407,20 +410,11 @@ pub fn run(
                )
            };

-
            // N.b. special case for handling the canonical ref, since it
-
            // creates a symlink to HEAD
-
            if *refname == canonical_ref {
-
                stored.set_head_to_default_branch()?;
-
            }
-

            match stored.backend.refname_to_id(refname.as_str()) {
                Ok(new) if new != *oid => {
-
                    stored.backend.reference(
-
                        refname.as_str(),
-
                        *oid,
-
                        true,
-
                        "set-canonical-reference from git-push (radicle)",
-
                    )?;
+
                    stored
+
                        .backend
+
                        .reference(refname.as_str(), *oid, true, LOG_MESSAGE)?;
                    print_update();
                }
                Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
modified crates/radicle/src/git/canonical.rs
@@ -12,6 +12,7 @@ mod voting;
pub mod effects;
pub mod protect;
pub mod rules;
+
pub mod symbolic;

pub use rules::{MatchedRule, RawRule, Rules, ValidRule};

modified crates/radicle/src/git/canonical/protect.rs
@@ -42,6 +42,13 @@ impl<T: RefLike> Unprotected<T> {
    pub fn into_inner(self) -> T {
        self.0
    }
+

+
    /// Allows creation without any checking. Callers must ensure that
+
    /// `reflike` is indeed unprotected!
+
    #[inline]
+
    const fn new_unchecked(reflike: T) -> Self {
+
        Self(reflike)
+
    }
}

impl<T: RefLike> AsRef<T> for Unprotected<T> {
@@ -68,7 +75,9 @@ impl<T: RefLike + std::fmt::Display> std::fmt::Display for Unprotected<T> {
/// For types that are commonly used in conjunction with [`Unprotected`]
/// have some `impl`s and companion infallible injections.
mod impls {
-
    use crate::git::{refspec::QualifiedPattern, RefString};
+
    use crate::git::{
+
        refname, refs::branch, refspec::QualifiedPattern, Qualified, RefStr, RefString,
+
    };

    use super::*;

@@ -76,6 +85,33 @@ mod impls {
    /// means to be [`RefLike`].
    impl RefLike for RefString {}

+
    impl Unprotected<RefString> {
+
        /// The reference name `HEAD`.
+
        // We would like to have a `pub const HEAD`, but
+
        // [`crate::git::RefStr::from_str`] is private.
+
        #[inline]
+
        pub fn head() -> Self {
+
            // Calling [`Unprotected::new_unchecked`] here is legal,
+
            // because we know statically that `HEAD` is not protected.
+
            Unprotected::new_unchecked(refname!("HEAD"))
+
        }
+
    }
+

+
    /// [`Qualified`] is a restriction on [`RefString`].
+
    impl RefLike for Qualified<'_> {}
+

+
    impl Unprotected<Qualified<'_>> {
+
        /// Construct a qualified reference name for given branch, i.e.,
+
        /// return `/refs/heads/<name>`
+
        pub fn branch(name: &RefStr) -> Self {
+
            Self::new(branch(name)).expect("branches are never protected")
+
        }
+

+
        pub fn to_ref_string(&self) -> Unprotected<RefString> {
+
            Unprotected::new_unchecked(self.0.to_ref_string())
+
        }
+
    }
+

    /// A [`QualifiedPattern`] is [`RefLike`] in the sense that it matches a
    /// (possibly infinite) set of [`crate::git::Qualified`].
    impl RefLike for QualifiedPattern<'_> {}
modified crates/radicle/src/git/canonical/rules.rs
@@ -389,6 +389,22 @@ impl From<Did> for Allowed {
    }
}

+
impl std::fmt::Display for Allowed {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Allowed::Delegates => f.write_str("\"allowed\""),
+
            Allowed::Set(dids) => {
+
                let dids = dids
+
                    .iter()
+
                    .map(|did| did.to_string())
+
                    .collect::<Vec<_>>()
+
                    .join("\", \"");
+
                f.write_fmt(format_args!("[\"{dids}\"]"))
+
            }
+
        }
+
    }
+
}
+

/// A marker `enum` that is used in a [`ValidRule`].
///
/// It ensures that a rule that has been deserialized, resolving the `delegates`
added crates/radicle/src/git/canonical/symbolic.rs
@@ -0,0 +1,324 @@
+
//! Symbolic references, which link neither to nor from protected references.
+
//! The prototypical example is `HEAD → refs/heads/main`.
+

+
use std::collections::BTreeMap;
+

+
use serde::{Deserialize, Serialize};
+
use thiserror::Error;
+

+
use crate::git::fmt::RefString;
+

+
use super::protect::{self, Unprotected};
+

+
use reachability::reachable;
+

+
pub type RawName = RefString;
+

+
/// Names of symbolic references are unprotected references.
+
/// Requiring [`Unprotected`] makes symbolic references that link *from*
+
/// protected references unrepresentable.
+
pub(super) type Name = Unprotected<RefString>;
+

+
impl std::cmp::Ord for Name {
+
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+
        self.as_ref().cmp(other.as_ref())
+
    }
+
}
+

+
impl std::cmp::PartialOrd for Name {
+
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+
        Some(self.cmp(other))
+
    }
+
}
+

+
pub type RawTarget = RefString;
+

+
/// Targets for symbolic references are unprotected references.
+
/// Requiring [`Unprotected`] makes symbolic references that link *to*
+
/// protected references unrepresentable.
+
pub(super) type Target = Unprotected<RefString>;
+

+
/// Maintains a cycle-free set of symbolic references.
+
/// Note that dangling references are not detected.
+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(try_from = "BTreeMap<Name, Target>")]
+
pub struct SymbolicRefs(BTreeMap<Name, Target>);
+

+
// Read-only access.
+
impl SymbolicRefs {
+
    /// Returns an iterator over all contained symbolic references, as pairs of
+
    /// their name [`RawName`] and [`RawTarget`].
+
    pub fn iter(&self) -> impl Iterator<Item = (&RawName, &RawTarget)> {
+
        self.0
+
            .iter()
+
            .map(|(name, target)| (name.as_ref(), target.as_ref()))
+
    }
+

+
    /// Returns an iterator over all contained symbolic references, as pairs of
+
    /// their name [`RawName`] and resolved [`RawTarget`].
+
    pub fn iter_resolved(&self) -> impl Iterator<Item = (&RawName, &RawTarget)> {
+
        self.iter_resolved_unprotected()
+
            .map(|(name, target)| (name.as_ref(), target.as_ref()))
+
    }
+

+
    pub(super) fn iter_resolved_unprotected(&self) -> impl Iterator<Item = (&Name, &Target)> {
+
        self.0
+
            .keys()
+
            .filter_map(|name| self.resolve_unprotected(name).map(|target| (name, target)))
+
    }
+

+
    fn resolve_unprotected<'a, 'b>(&'a self, mut name: &'b Name) -> Option<&'a Target>
+
    where
+
        'a: 'b,
+
    {
+
        while let Some(target) = self.0.get(name) {
+
            match self.0.get(target) {
+
                Some(next) => {
+
                    name = next;
+
                }
+
                None => return Some(target),
+
            }
+
        }
+

+
        None
+
    }
+

+
    /// Returns `true` if the set of symbolic references is empty.
+
    pub fn is_empty(&self) -> bool {
+
        self.0.is_empty()
+
    }
+
}
+

+
// Utilities for handling of `HEAD`.
+
impl SymbolicRefs {
+
    /// Construct [`SymbolicRefs`] for the single symbolic reference `HEAD`
+
    /// targeting `/refs/heads/<branch_name>`.
+
    // This exists to encapsulate [`Unprotected`].
+
    pub fn head(branch_name: &RefString) -> Self {
+
        let mut result = Self::default();
+
        result
+
            .try_insert_unprotected(
+
                Unprotected::head().to_owned(),
+
                Unprotected::branch(branch_name).to_ref_string(),
+
            )
+
            .expect("not creating cycle");
+
        result
+
    }
+

+
    /// Convenience method to get the target of the `HEAD` reference.
+
    /// See also [`SymbolicRefs::head`].
+
    pub fn resolve_head(&self) -> Option<&RawTarget> {
+
        self.resolve_unprotected(&Unprotected::head())
+
            .map(|target| target.as_ref())
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum InsertionError {
+
    #[error("inserting symbolic reference '{name} → {target}' would create a cycle")]
+
    Cyclic { name: RawName, target: RawTarget },
+

+
    #[error(transparent)]
+
    Protected(#[from] protect::Error),
+
}
+

+
// Mutability.
+
impl SymbolicRefs {
+
    /// Insert a symbolic reference.
+
    /// Even though this method will never return [`InsertionError::Protected`]
+
    /// we opt to return that (slightly more general) error, as it allows
+
    /// construction of [`InsertionError::Cyclic`] by consuming `name` and
+
    /// `target`, avoiding an early copy in [`Self::try_insert`].
+
    pub(super) fn try_insert_unprotected(
+
        &mut self,
+
        name: Name,
+
        target: Target,
+
    ) -> Result<(), InsertionError> {
+
        if reachable(&self.0, &target, &name) {
+
            return Err(InsertionError::Cyclic {
+
                name: name.into_inner(),
+
                target: target.into_inner(),
+
            });
+
        }
+

+
        self.0.insert(name, target);
+
        Ok(())
+
    }
+

+
    /// Try to insert a symbolic reference.
+
    /// Errors if `name` or `target` is protected (see [`protect`]) or would
+
    /// cause infinite recursion (e.g. `A → B` and `B → A`).
+
    pub fn try_insert(&mut self, name: RawName, target: RawTarget) -> Result<(), InsertionError> {
+
        self.try_insert_unprotected(Unprotected::new(name)?, Unprotected::new(target)?)
+
    }
+

+
    /// Consume `other` by iteratively inserting into self.
+
    pub fn combine(&mut self, other: SymbolicRefs) -> Result<(), InsertionError> {
+
        for (name, target) in other.0 {
+
            self.try_insert_unprotected(name, target)?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[error("symbolic reference '{name}' is cyclic")]
+
pub struct Cyclic {
+
    name: RawName,
+
}
+

+
impl TryFrom<BTreeMap<Name, Target>> for SymbolicRefs {
+
    type Error = Cyclic;
+

+
    fn try_from(map: BTreeMap<Name, Target>) -> Result<Self, Self::Error> {
+
        for (name, target) in map.iter() {
+
            if reachable(&map, target, name) {
+
                return Err(Cyclic {
+
                    name: name.to_owned().into_inner(),
+
                });
+
            }
+
        }
+

+
        Ok(Self(map))
+
    }
+
}
+

+
mod reachability {
+
    pub(super) trait Get<'a, 'b, K, V> {
+
        fn get(&'a self, key: &'b K) -> Option<&'a V>;
+
    }
+

+
    impl<'a, 'b, K: Ord, V> Get<'a, 'b, K, V> for std::collections::BTreeMap<K, V> {
+
        fn get(&'a self, key: &'b K) -> Option<&'a V> {
+
            std::collections::BTreeMap::get(self, key)
+
        }
+
    }
+

+
    impl<'a, 'b, K: Eq + std::hash::Hash, V> Get<'a, 'b, K, V> for std::collections::HashMap<K, V> {
+
        fn get(&'a self, key: &'b K) -> Option<&'a V> {
+
            std::collections::HashMap::get(self, key)
+
        }
+
    }
+

+
    impl<'a, 'b, K: Eq + std::hash::Hash, V> Get<'a, 'b, K, V> for indexmap::IndexMap<K, V> {
+
        fn get(&'a self, key: &'b K) -> Option<&'a V> {
+
            indexmap::IndexMap::get(self, key)
+
        }
+
    }
+

+
    /// A reachability check linking
+
    /// from `K` to `V` using [`Get`], and
+
    /// from `V` to `K` using [`Into`].
+
    /// Note that the bound is trivial if `K = V`.
+
    ///
+
    /// This can be used to check whether inserting `key → val`
+
    /// would create a cycle.
+
    ///
+
    /// # Returns
+
    ///
+
    /// Whether `key == val` (under [`Into::into`]) or
+
    /// `key` is reachable from `val` (under [`Into::into`] and [`Get::get`]).
+
    pub(super) fn reachable<'a, 'b, K: PartialEq, V: 'a>(
+
        map: &'a impl Get<'a, 'b, K, V>,
+
        val: &'b V,
+
        key: &'b K,
+
    ) -> bool
+
    where
+
        'a: 'b,
+
        &'b V: Into<&'b K>,
+
    {
+
        // Self-Reference
+
        let src = val.into();
+
        if *src == *key {
+
            return true;
+
        }
+

+
        // Chase
+
        let mut src = src;
+
        while let Some(tmp) = map.get(src).map(|value| value.into()) {
+
            if *tmp == *key {
+
                return true;
+
            }
+
            src = tmp;
+
        }
+

+
        false
+
    }
+
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod test {
+
    use crate::assert_matches;
+
    use crate::git::refname;
+

+
    use super::*;
+

+
    #[test]
+
    fn infinite_single() {
+
        let mut symbolic = SymbolicRefs::default();
+

+
        assert_matches!(
+
            symbolic.try_insert(refname!("a"), refname!("a")),
+
            Err(InsertionError::Cyclic { .. })
+
        );
+

+
        assert!(symbolic.is_empty());
+
    }
+

+
    #[test]
+
    fn infinite_multi() {
+
        let mut symbolic = SymbolicRefs::default();
+

+
        assert_matches!(symbolic.try_insert(refname!("a"), refname!("b")), Ok(()));
+

+
        assert_matches!(symbolic.try_insert(refname!("b"), refname!("c")), Ok(()));
+

+
        assert_matches!(
+
            symbolic.try_insert(refname!("c"), refname!("a")),
+
            Err(InsertionError::Cyclic { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn deserialize_valid() {
+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "refs/heads/a": "refs/heads/b",
+
            })),
+
            Ok(_)
+
        );
+
    }
+

+
    #[test]
+
    fn deserialize_infinite() {
+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "refs/heads/a": "refs/heads/a",
+
            })),
+
            Err(_)
+
        );
+

+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "refs/heads/a": "refs/heads/b",
+
                "refs/heads/b": "refs/heads/c",
+
                "refs/heads/c": "refs/heads/a",
+
            })),
+
            Err(_)
+
        );
+
    }
+

+
    /// Motivates why we cannot simply delegate to [`BTreeMap::extend`]
+
    /// for combining [`SymbolicRefs`].
+
    #[test]
+
    fn infinite_extend() {
+
        let mut a = SymbolicRefs::default();
+
        assert_matches!(a.try_insert(refname!("a"), refname!("b")), Ok(()));
+

+
        let mut b = SymbolicRefs::default();
+
        assert_matches!(b.try_insert(refname!("b"), refname!("a")), Ok(()));
+

+
        assert_matches!(a.combine(b), Err(InsertionError::Cyclic { .. }));
+
    }
+
}
modified crates/radicle/src/identity/crefs.rs
@@ -1,9 +1,29 @@
use serde::{Deserialize, Serialize};
+
use thiserror::Error;

-
use crate::git::canonical::rules::{RawRules, Rules, ValidationError};
+
use crate::git::canonical::rules::{self, RawRules, Rules};
+
use crate::git::canonical::symbolic::{self, SymbolicRefs};
+
use crate::git::Qualified;

use super::doc::{Delegates, Payload};

+
#[derive(Debug, Error)]
+
pub enum ValidationError {
+
    #[error(transparent)]
+
    Rules(#[from] rules::ValidationError),
+

+
    #[error("the target of the symbolic reference '{name} → {target}' is not matched by any rule")]
+
    Dangling {
+
        name: symbolic::RawName,
+
        target: symbolic::RawTarget,
+
    },
+
    #[error("the symbolic reference name '{name}' is also matched by rule(s) with pattern(s) {patterns:?}")]
+
    Clash {
+
        patterns: Vec<String>,
+
        name: Qualified<'static>,
+
    },
+
}
+

/// Configuration for canonical references and their rules.
///
/// `RawCanonicalRefs` are verified into [`CanonicalRefs`].
@@ -11,12 +31,15 @@ use super::doc::{Delegates, Payload};
#[serde(rename_all = "camelCase")]
pub struct RawCanonicalRefs {
    rules: RawRules,
+

+
    #[serde(default)] // Default to empty for backwards compatibility.
+
    symbolic: SymbolicRefs,
}

impl RawCanonicalRefs {
    /// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`].
-
    pub fn new(rules: RawRules) -> Self {
-
        Self { rules }
+
    pub fn new(rules: RawRules, symbolic: SymbolicRefs) -> Self {
+
        Self { rules, symbolic }
    }

    /// Return the [`RawRules`].
@@ -24,6 +47,21 @@ impl RawCanonicalRefs {
        &self.rules
    }

+
    /// Return the [`RawRules`] for mutation.
+
    pub fn raw_rules_mut(&mut self) -> &mut RawRules {
+
        &mut self.rules
+
    }
+

+
    /// Return the [`SymbolicRefs`].
+
    pub fn symbolic(&self) -> &SymbolicRefs {
+
        &self.symbolic
+
    }
+

+
    /// Return the [`SymbolicRefs`] for mutation.
+
    pub fn symbolic_mut(&mut self) -> &mut SymbolicRefs {
+
        &mut self.symbolic
+
    }
+

    /// Validate the [`RawCanonicalRefs`] into a set of [`CanonicalRefs`].
    pub fn try_into_canonical_refs<R>(
        self,
@@ -33,7 +71,7 @@ impl RawCanonicalRefs {
        R: Fn() -> Delegates,
    {
        let rules = Rules::from_raw(self.rules, resolve)?;
-
        Ok(CanonicalRefs::new(rules))
+
        CanonicalRefs::new(rules, self.symbolic)
    }
}

@@ -41,22 +79,65 @@ impl RawCanonicalRefs {
///
/// [`CanonicalRefs`] can be converted into a [`Payload`] using its [`From`]
/// implementation.
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalRefs {
    rules: Rules,
+

+
    #[serde(default, skip_serializing_if = "SymbolicRefs::is_empty")]
+
    symbolic: SymbolicRefs,
}

impl CanonicalRefs {
-
    /// Construct a new [`CanonicalRefs`] from a set of [`Rules`].
-
    pub fn new(rules: Rules) -> Self {
-
        CanonicalRefs { rules }
+
    /// Construct a new [`CanonicalRefs`] from a set of [`Rules`] and
+
    /// [`SymbolicRefs`], validating that these may be evaluated to a well
+
    /// formed set of references when interpreted together.
+
    pub fn new(rules: Rules, symbolic: SymbolicRefs) -> Result<Self, ValidationError> {
+
        for (name, target) in symbolic.iter_resolved() {
+
            if Qualified::from_refstr(target)
+
                .and_then(|qualified| rules.matches(&qualified).next())
+
                .is_none()
+
            {
+
                return Err(ValidationError::Dangling {
+
                    name: name.to_owned(),
+
                    target: target.to_owned(),
+
                });
+
            }
+

+
            let Some(name) = Qualified::from_refstr(name) else {
+
                continue;
+
            };
+

+
            let patterns = rules
+
                .matches(&name)
+
                .map(|(pattern, _)| pattern.to_string())
+
                .collect::<Vec<_>>();
+
            if !patterns.is_empty() {
+
                return Err(ValidationError::Clash {
+
                    patterns,
+
                    name: name.to_owned(),
+
                });
+
            }
+
        }
+

+
        Ok(CanonicalRefs { rules, symbolic })
    }

    /// Return the [`Rules`].
    pub fn rules(&self) -> &Rules {
        &self.rules
    }
+

+
    /// Return the [`SymbolicRefs`].
+
    pub fn symbolic(&self) -> &SymbolicRefs {
+
        &self.symbolic
+
    }
+
}
+

+
impl Extend<(rules::RawPattern, rules::RawRule)> for RawCanonicalRefs {
+
    fn extend<T: IntoIterator<Item = (rules::RawPattern, rules::RawRule)>>(&mut self, iter: T) {
+
        self.rules.extend(iter)
+
    }
}

#[derive(Debug, thiserror::Error)]
@@ -74,3 +155,120 @@ impl TryFrom<CanonicalRefs> for Payload {
        Ok(Self::from(value))
    }
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod tests {
+
    use serde_json::json;
+

+
    use crate::assert_matches;
+

+
    use super::{ValidationError::*, *};
+

+
    fn from(value: serde_json::Value) -> Result<CanonicalRefs, super::ValidationError> {
+
        let delegates: Delegates = crate::test::arbitrary::gen::<crate::prelude::Did>(1).into();
+
        serde_json::from_value::<RawCanonicalRefs>(value)
+
            .unwrap()
+
            .try_into_canonical_refs(&mut || delegates.clone())
+
    }
+

+
    /// Backwards compatibility to before addition of symbolic references.
+
    #[test]
+
    fn omit_symbolic() {
+
        assert_matches!(
+
            from(json!({
+
                "rules": {},
+
            })),
+
            Ok(_)
+
        );
+
    }
+

+
    #[test]
+
    fn invalid_dangling() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "HEAD": "refs/heads/master"
+
                },
+
                "rules": {},
+
            })),
+
            Err(Dangling { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn invalid_clash() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "refs/heads/foo": "refs/heads/bar",
+
                },
+
                "rules": {
+
                    "refs/heads/foo": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                    "refs/heads/bar": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                },
+
            })),
+
            Err(Clash { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn invalid_clash_asterisk_name() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "refs/heads/foo": "refs/heads/bar",
+
                },
+
                "rules": {
+
                    "refs/heads/*": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                },
+
            })),
+
            Err(Clash { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn valid_asterisk_target() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "HEAD": "refs/heads/master",
+
                },
+
                "rules": {
+
                    "refs/heads/*": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                },
+
            })),
+
            Ok(_)
+
        );
+
    }
+

+
    #[test]
+
    fn valid() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "refs/heads/foo": "refs/heads/ruled/bar",
+
                },
+
                "rules": {
+
                    "refs/heads/ruled/*": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                },
+
            })),
+
            Ok(_)
+
        );
+
    }
+
}
modified crates/radicle/src/identity/doc.rs
@@ -21,7 +21,10 @@ use crate::cob::identity;
use crate::crypto;
use crate::crypto::Signature;
use crate::git;
-
use crate::git::canonical::rules::{self, RawRules};
+
use crate::git::canonical::rules;
+
use crate::git::canonical::symbolic;
+
use crate::git::Qualified;
+
use crate::identity::crefs;
use crate::identity::{project::Project, Did};
use crate::node::device::Device;
use crate::storage;
@@ -80,7 +83,7 @@ impl DocError {
}

#[derive(Debug, Error)]
-
pub enum DefaultBranchRuleError {
+
pub enum DefaultBranchError {
    #[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
    Payload(#[from] PayloadError),
}
@@ -761,41 +764,113 @@ impl Doc {
        Ok(proj)
    }

-
    /// Gets the qualified reference name of the default branch,
-
    /// according to the project payload in this document.
-
    pub fn default_branch(&self) -> Result<git::Qualified, PayloadError> {
-
        Ok(git::refs::branch(self.project()?.default_branch()))
-
    }
-

-
    pub fn default_branch_rule(&self) -> Result<rules::Rules, DefaultBranchRuleError> {
-
        let pattern = git::refspec::QualifiedPattern::from(git::refs::branch(
-
            self.project()?.default_branch(),
-
        ));
-
        let rule = rules::Rule::new(
-
            rules::ResolvedDelegates::Delegates(self.delegates.clone()),
-
            self.threshold,
-
        );
-
        Ok(rules::Rules::from_raw(
-
            rules::RawRules::from_iter([(pattern, rule.into())]),
-
            &mut || self.delegates.clone(),
-
        )
-
        .expect("default rules are valid"))
-
    }
-

    /// Construct the canonical references for this document.
-
    /// The implementation of [`crefs::RawCanonicalRefs`] is used to
-
    /// obtain the payload identified by [`PayloadId::canonical_refs`], if it
-
    /// exists.
-
    /// The resulting [`CanonicalRefs`] are constructed by extension with
-
    /// [`Self::default_branch_rule`].
+
    ///
+
    /// Starts by obtaining the payload identified by
+
    /// [`PayloadId::canonical_refs`].
+
    ///
+
    /// If the payload exists, and it contains both a symbolic reference with
+
    /// the name `HEAD` and a rule matching the corresponding target branch,
+
    /// then this rule is verified to be backwards compatible, i.e. that the
+
    /// value for `allowed` is [`rules::Allowed::Delegates`] and the threshold
+
    /// matches [`Self::threshold`].
+
    ///
+
    /// If the payload is missing, canonical references are synthesized from 
+
    /// the payload identified by [`PayloadId::project`]:
+
    /// - A rule exactly matching [`Project::default_branch`]
+
    ///   that is compatible with self.
+
    /// - A symbolic reference with name `HEAD`
+
    ///   (see [`symbolic::SymbolicRefs::head`]) that targets the same branch.
+
    ///
+
    /// The resulting [`CanonicalRefs`] must pass validation, and there are
+
    /// cases where the payload is valid as such, but invalid in combination
+
    /// with the synthesized rule and symbolic reference. For example, if
+
    /// there is a symbolic reference already, with the name of the default
+
    /// branch, this will clash with the synthesized rule.
    pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
        let raw_crefs = self.raw_canonical_refs()?.unwrap_or_default();

-
        let mut raw_rules = raw_crefs.raw_rules().clone();
-
        raw_rules.extend(RawRules::from(self.default_branch_rule()?));
+
        let resolve = &mut || self.delegates.clone();
+

+
        // If there is a symbolic reference with name `HEAD` in the crefs
+
        // payload, we do not need to access the project payload to obtain
+
        // the name of the default branch from there.
+
        // However, we must still ensure that the crefs payload, in particular
+
        // the rule matching the target brach of the symbolic reference with
+
        // name `HEAD`, is backwards compatible with the rest of the identity
+
        // document.
+
        // These conditions may only be relaxed by introducing a new version of
+
        // the identity document.
+
        if let Some(default_branch) = raw_crefs.symbolic().resolve_head() {
+
            if let Some(default_branch) = Qualified::from_refstr(default_branch) {
+
                if let Some((pattern, rule)) = raw_crefs.raw_rules().matches(&default_branch).next()
+
                {
+
                    if *rule.allowed() != rules::Allowed::Delegates {
+
                        return Err(CanonicalRefsError::DefaultBranchRuleError(
+
                            DefaultBranchRuleError::Allowed {
+
                                pattern: pattern.to_string(),
+
                                actual: rule.allowed().to_string(),
+
                            },
+
                        ));
+
                    }
+
                    if *rule.threshold() != self.threshold() {
+
                        return Err(CanonicalRefsError::DefaultBranchRuleError(
+
                            DefaultBranchRuleError::Threshold {
+
                                pattern: pattern.to_string(),
+
                                actual: *rule.threshold(),
+
                                expected: self.threshold(),
+
                            },
+
                        ));
+
                    }
+
                } else {
+
                    // There is a symbolic reference for `HEAD`, but not matching
+
                    // canonical reference rule. `HEAD` is dangling!
+
                    // `raw_crefs` is malformed and will not pass validation below.
+
                }
+
            }
+
            return Ok(raw_crefs.try_into_canonical_refs(resolve)?);
+
        }
+

+
        // Since there is no symbolic reference with name `HEAD`, we fall back
+
        // to the project payload for obtaining the default branch.
+
        let project = self.project().map_err(CanonicalRefsError::DefaultBranch)?;
+

+
        // Only now, once we know that we will be synthesizing, and have a
+
        // project to do so, make `raw_crefs` mutable.
+
        let mut raw_crefs = raw_crefs;
+

+
        let default_branch = project.default_branch_ref();
+

+
        if raw_crefs
+
            .raw_rules()
+
            .matches(default_branch.as_ref())
+
            .next()
+
            .is_none()
+
        {
+
            let rule = rules::Rule::new(rules::Allowed::Delegates, self.threshold());
+

+
            raw_crefs.raw_rules_mut().insert(
+
                git::refspec::QualifiedPattern::from(default_branch.to_owned()),
+
                rule,
+
            );
+
        }

-
        let raw_crefs = RawCanonicalRefs::new(raw_rules);
-
        Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?)
+
        raw_crefs
+
            .symbolic_mut()
+
            .combine(symbolic::SymbolicRefs::head(project.default_branch()))?;
+

+
        Ok(raw_crefs.try_into_canonical_refs(resolve)?)
+
    }
+

+
    /// Gets the qualified reference name of the default branch,
+
    /// according to the cref or project payload in this document.
+
    pub fn default_branch(&self) -> Result<git::Qualified, CanonicalRefsError> {
+
        self.canonical_refs()?
+
            .symbolic()
+
            .resolve_head()
+
            .cloned()
+
            .and_then(Qualified::from_refstr)
+
            .ok_or(CanonicalRefsError::MissingHead)
    }

    /// Return the associated [`Visibility`] of this document.
@@ -956,19 +1031,41 @@ impl Doc {
}

#[derive(Debug, Error)]
+
pub enum RawCanonicalRefsError {
+
    #[error(transparent)]
+
    Json(#[from] serde_json::Error),
+
}
+

+
#[derive(Debug, Error)]
pub enum CanonicalRefsError {
    #[error(transparent)]
    Raw(#[from] RawCanonicalRefsError),
    #[error(transparent)]
-
    CanonicalRefs(#[from] rules::ValidationError),
+
    CanonicalRefs(#[from] crefs::ValidationError),
+
    #[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
+
    DefaultBranch(PayloadError),
+

+
    #[error("no symbolic reference with name `HEAD` is defined")]
+
    MissingHead,
+

    #[error(transparent)]
-
    DefaultBranch(#[from] DefaultBranchRuleError),
+
    DefaultBranchRuleError(#[from] DefaultBranchRuleError),
+

+
    #[error("synthesizing canonical references from project payload failed: {0}")]
+
    Synthesis(#[from] symbolic::InsertionError),
}

#[derive(Debug, Error)]
-
pub enum RawCanonicalRefsError {
-
    #[error(transparent)]
-
    Json(#[from] serde_json::Error),
+
pub enum DefaultBranchRuleError {
+
    #[error("rule for pattern '{pattern}' which matches the target of symbolic reference 'HEAD' (possibly synthesized from payload 'xyz.radicle.project') must use 'allow' value of \"delegates\" but uses {actual}")]
+
    Allowed { pattern: String, actual: String },
+

+
    #[error("rule for pattern '{pattern}' which matches the target of symbolic reference 'HEAD' (possibly synthesized from payload 'xyz.radicle.project') must use a threshold of {expected} as required by the identity document but uses {actual}")]
+
    Threshold {
+
        pattern: String,
+
        actual: usize,
+
        expected: usize,
+
    },
}

pub trait GetRawCanonicalRefs: GetPayload {
@@ -1232,4 +1329,32 @@ mod test {
            serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] })
        );
    }
+

+
    #[test]
+
    fn test_default_branch_without_project() {
+
        let value = serde_json::json!(
+
            {
+
                "payload": {
+
                    "xyz.radicle.crefs": {
+
                        "symbolic": {
+
                            "HEAD": "refs/heads/main",
+
                        },
+
                        "rules": {
+
                            "refs/heads/main": {
+
                                "allow": "delegates",
+
                                "threshold": 1,
+
                            }
+
                        }
+
                    }
+
                },
+
                "delegates": [
+
                    "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM"
+
                ],
+
                "threshold": 1
+
            }
+
        );
+

+
        let doc = serde_json::from_value::<Doc>(value).unwrap();
+
        assert_eq!(doc.default_branch().unwrap().as_str(), "refs/heads/main");
+
    }
}
modified crates/radicle/src/identity/doc/update.rs
@@ -199,9 +199,13 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
            })
        }
    };
-
    // 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.
+

+
    // If we have both payloads `xyz.radicle.{project,crefs}` ensure that,
+
    // in the `crefs` payload there is no …
+
    //  1. … rule that matches the default branch from the  `project` payload.
+
    //     (This rule must be synthesized!)
+
    //  2. … symbolic reference with the name `HEAD`.
+
    //     (This reference must be synthesized!)
    use super::GetRawCanonicalRefs as _;
    match raw
        .raw_canonical_refs()
@@ -215,11 +219,19 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
                .map(|(pattern, _)| pattern.to_string())
                .collect::<Vec<_>>();
            if !matches.is_empty() {
-
                return Err(error::DocVerification::DisallowDefault { matches, default });
+
                return Err(error::DocVerification::DisallowDefaultBranchRule { matches, default });
+
            }
+

+
            if let Some(symbolic) = crefs.symbolic().resolve_head() {
+
                return Err(error::DocVerification::DisallowDefaultBranchSymbolic {
+
                    symbolic: symbolic.to_owned(),
+
                    default,
+
                });
            }
        }
        _ => { /* we validate below */ }
    }
+

    // Verify that the canonical references payload is valid
    if let Err(e) = proposal.canonical_refs() {
        return Err(error::DocVerification::PayloadError {
@@ -321,7 +333,7 @@ mod test {
        assert!(
            matches!(
                super::verify(raw),
-
                Err(error::DocVerification::DisallowDefault { .. })
+
                Err(error::DocVerification::DisallowDefaultBranchRule { .. })
            ),
            "Verification should be rejected for including default branch rule"
        )
modified crates/radicle/src/identity/doc/update/error.rs
@@ -29,10 +29,15 @@ pub enum DocVerification {
    #[error(transparent)]
    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 {
+
    DisallowDefaultBranchRule {
        matches: Vec<String>,
        default: git::Qualified<'static>,
    },
+
    #[error("incompatible payloads: The symbolic reference xyz.radicle.crefs.symbolic.HEAD → '{symbolic}' conflicts with xyz.radicle.project.defaultBranch ('{default}'). Possible resolutions: Remove either of the two.")]
+
    DisallowDefaultBranchSymbolic {
+
        symbolic: RefString,
+
        default: git::Qualified<'static>,
+
    },
}

#[derive(Clone, Debug)]
modified crates/radicle/src/identity/project.rs
@@ -7,6 +7,7 @@ use serde::{
use thiserror::Error;

use crate::crypto;
+
use crate::git::{refs::branch, Qualified};
use crate::identity::doc;
use crate::identity::doc::Payload;
use crate::storage::BranchName;
@@ -254,6 +255,11 @@ impl Project {
    pub fn default_branch(&self) -> &BranchName {
        &self.default_branch
    }
+

+
    #[inline]
+
    pub fn default_branch_ref(&self) -> Qualified {
+
        branch(&self.default_branch)
+
    }
}

impl From<Project> for Payload {
modified crates/radicle/src/rad.rs
@@ -142,7 +142,7 @@ where
    )?;
    stored.set_remote_identity_root_to(pk, identity)?;
    stored.set_identity_head_to(identity)?;
-
    stored.set_head_to_default_branch()?;
+
    stored.set_canonical_symbolic_refs("set-canonical from init (radicle)")?;
    stored.set_default_branch_to_canonical_head()?;

    let signed = stored.sign_refs(signer)?;
modified crates/radicle/src/storage.rs
@@ -122,8 +122,10 @@ pub enum RepositoryError {
    Refs(#[from] refs::Error),
    #[error("missing canonical reference rule for default branch")]
    MissingBranchRule,
+
    #[error("missing canonical symbolic reference for default branch (`HEAD`)")]
+
    MissingBranchSymbolic,
    #[error("could not get the default branch rule: {0}")]
-
    DefaultBranchRule(#[from] doc::DefaultBranchRuleError),
+
    DefaultBranchRule(#[from] doc::DefaultBranchError),
    #[error("failed to get canonical reference rules: {0}")]
    CanonicalRefs(#[from] doc::CanonicalRefsError),
    #[error(transparent)]
@@ -523,7 +525,7 @@ pub trait ReadRepository: Sized + ValidateRepository {
    fn head(&self) -> Result<(Qualified, Oid), RepositoryError>;

    /// Gets the qualified reference name of the default branch of self,
-
    /// according to the project payload in the identity document.
+
    /// according to the identity document.
    fn default_branch(&self) -> Result<Qualified, RepositoryError> {
        Ok(self.identity_doc()?.default_branch()?.to_owned())
    }
@@ -670,11 +672,26 @@ where

/// Allows read-write access to a repository.
pub trait WriteRepository: ReadRepository + SignRepository {
-
    /// Sets the symbolic reference `HEAD` to target the default branch.
-
    /// This only depends on the value for the default branch in the identity
-
    /// document, and does not require the canonical reference behind the
-
    /// default branch to be computed, or even exist.
-
    fn set_head_to_default_branch(&self) -> Result<(), RepositoryError>;
+
    /// Sets the canonical symbolic references.
+
    ///
+
    /// This only depends on the cref payload in the identity document, and does
+
    /// not require the targeted canonical references to be computed, or even
+
    /// exist.
+
    fn set_canonical_symbolic_refs(&self, message: &str) -> Result<(), RepositoryError> {
+
        for (name, target) in self.identity_doc()?.canonical_refs()?.symbolic().iter() {
+
            self.set_symbolic_ref(name, target, message)?;
+
        }
+
        Ok(())
+
    }
+

+
    /// Sets a symbolic reference, if it does not exist or its target is different
+
    /// from the given one.
+
    fn set_symbolic_ref(
+
        &self,
+
        name: &RefStr,
+
        target: &RefStr,
+
        message: &str,
+
    ) -> Result<(), RepositoryError>;

    /// Computes the head of the default branch based on the delegate set,
    /// and sets it.
modified crates/radicle/src/storage/git.rs
@@ -26,7 +26,7 @@ use crate::storage::{
use crate::{git, node};

pub use crate::git::{
-
    ext, raw, refname, refspec, Oid, PatternStr, Qualified, RefError, RefString, UserInfo,
+
    ext, raw, refname, refspec, Oid, PatternStr, Qualified, RefError, RefStr, RefString, UserInfo,
};
pub use crate::storage::{Error, RepositoryError};

@@ -785,13 +785,16 @@ impl ReadRepository for Repository {

    fn canonical_head(&self) -> Result<(Qualified, Oid), RepositoryError> {
        let doc = self.identity_doc()?;
-
        let refname = doc.default_branch()?.to_owned();

        let crefs = doc.canonical_refs()?;

+
        let Some(refname) = crefs.symbolic().resolve_head().and_then(Qualified::from_refstr) else {
+
            return Err(RepositoryError::MissingBranchSymbolic);
+
        };
+

        Ok(crefs
            .rules()
-
            .canonical(refname, self)
+
            .canonical(refname.to_owned(), self)
            .ok_or(RepositoryError::MissingBranchRule)?
            .find_objects()?
            .quorum()?)
@@ -874,31 +877,28 @@ impl ReadRepository for Repository {
}

impl WriteRepository for Repository {
-
    fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> {
-
        let head_ref = refname!("HEAD");
-
        let branch_ref = self.default_branch()?;
-

-
        match self.raw().find_reference(head_ref.as_str()) {
+
    fn set_symbolic_ref(
+
        &self,
+
        name: &RefStr,
+
        target: &RefStr,
+
        message: &str,
+
    ) -> Result<(), RepositoryError> {
+
        match self.raw().find_reference(name.as_str()) {
            Ok(mut head_ref) => {
                if head_ref
                    .symbolic_target()
-
                    .is_some_and(|t| t != branch_ref.as_str())
+
                    .is_some_and(|t| t != target.as_str())
                {
-
                    head_ref.symbolic_set_target(branch_ref.as_str(), "set-head (radicle)")?;
+
                    head_ref.symbolic_set_target(target.as_str(), message)?;
                }
-
                Ok(())
            }
            Err(err) if git::ext::is_not_found_err(&err) => {
-
                self.raw().reference_symbolic(
-
                    head_ref.as_str(),
-
                    branch_ref.as_str(),
-
                    true,
-
                    "set-head (radicle)",
-
                )?;
-
                Ok(())
+
                self.raw()
+
                    .reference_symbolic(name.as_str(), target.as_str(), true, message)?;
            }
-
            Err(err) => Err(err.into()),
+
            Err(err) => return Err(err.into()),
        }
+
        Ok(())
    }

    fn set_default_branch_to_canonical_head(&self) -> Result<SetHead, RepositoryError> {
modified crates/radicle/src/test.rs
@@ -58,7 +58,7 @@ pub fn fetch<W: WriteRepository>(
    drop(opts);

    repo.set_identity_head()?;
-
    repo.set_head_to_default_branch()?;
+
    repo.set_canonical_symbolic_refs("set-canonical test (radicle)")?;
    repo.set_default_branch_to_canonical_head()?;

    let validations = repo.validate()?;
modified crates/radicle/src/test/storage.rs
@@ -326,7 +326,12 @@ impl WriteRepository for MockRepository {
        todo!()
    }

-
    fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> {
+
    fn set_symbolic_ref(
+
        &self,
+
        _name: &fmt::RefStr,
+
        _target: &fmt::RefStr,
+
        _message: &str,
+
    ) -> Result<(), RepositoryError> {
        todo!()
    }