radicle/crefs: Support Symbolic References
Canonical references can not be used to model symbolic references. Relax this restriction by adding another member to the payload, named "symbolic". Its key-value/name-target pairs then translate directly to canonical symbolic references. Care is taken to not allow circular references, and that there always is a rule that could generate the target of a symbolic reference (or a chain of symbolic references). Still, a symbolic reference may dangle (for example when its target reference cannot be computed because of divergence), but at least it can be prevented that a symref *always* dangles, because there is no rule that would produce its target. Co-authored-by: Fintan Halpenny <fintan.halpenny@gmail.com>
This commit is contained in:
parent
080790d84e
commit
3f81e83d36
|
|
@ -326,6 +326,8 @@ With the introduction of `clap`, this helped with the introduction of a command
|
|||
environment variable `RAD_PASSPHRASE` (lower priority than the
|
||||
credential). The identifier of the credential is
|
||||
"xyz.radicle.node.passphrase".
|
||||
- Symbolic references can now be handled by canonical references by coding them
|
||||
in the payload `xyz.radicle.crefs` under the key `symbolic`.
|
||||
|
||||
## Fixed Bugs
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
``` ~alice
|
||||
$ rad id update --title "Add canonical branch for releases" --payload xyz.radicle.crefs rules '{ "refs/heads/releases/*": { "threshold": 1, "allow": [ "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk" ] }, "refs/tags/*": { "threshold": 1, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" }}'
|
||||
✓ Identity revision [..] created
|
||||
╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ Title Add canonical branch for releases │
|
||||
│ Revision 37a1aad231100cd206c49aed79e405ea2da9204b │
|
||||
│ Blob bbefd77cfeb456e500ad868c3b4effe1f7f818e2 │
|
||||
│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
|
||||
│ State active │
|
||||
│ Quorum no │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │
|
||||
│ ? did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob │
|
||||
╰────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
{
|
||||
"payload": {
|
||||
"xyz.radicle.crefs": {
|
||||
"rules": {
|
||||
+ "refs/heads/releases/*": {
|
||||
+ "allow": [
|
||||
+ "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
|
||||
+ ],
|
||||
+ "threshold": 1
|
||||
+ },
|
||||
"refs/tags/*": {
|
||||
"allow": "delegates",
|
||||
- "threshold": 2
|
||||
+ "threshold": 1
|
||||
},
|
||||
"refs/tags/qa/*": {
|
||||
"allow": "delegates",
|
||||
"threshold": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"xyz.radicle.project": {
|
||||
"defaultBranch": "master",
|
||||
"description": "Radicle Heartwood Protocol & Stack",
|
||||
"name": "heartwood"
|
||||
}
|
||||
},
|
||||
"delegates": [
|
||||
"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
||||
"did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
|
||||
],
|
||||
"threshold": 1
|
||||
}
|
||||
```
|
||||
|
||||
``` ~bob
|
||||
$ cd heartwood
|
||||
$ rad sync -f
|
||||
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
|
||||
✓ Target met: 1 seed(s)
|
||||
🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
||||
$ rad id accept 37a1aad231100cd206c49aed79e405ea2da9204b -q
|
||||
```
|
||||
|
||||
Bob immediately pushes a branch that matches the rule, thus populating and
|
||||
modifying the canonical namespace.
|
||||
|
||||
``` ~bob
|
||||
$ git checkout -b releases/2
|
||||
$ git commit --allow-empty --message "Release notes for version 2"
|
||||
[releases/2 afec366] Release notes for version 2
|
||||
```
|
||||
|
||||
``` ~bob (stderr)
|
||||
$ git push -u rad releases/2
|
||||
✓ Canonical reference refs/heads/releases/2 updated to target commit afec366785ed3651cdc66975c0fec41866c9ce62
|
||||
✓ Synced with 1 seed(s)
|
||||
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
|
||||
* [new branch] releases/2 -> releases/2
|
||||
```
|
||||
|
||||
``` ~alice
|
||||
$ rad sync -f
|
||||
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
|
||||
✓ Target met: 1 seed(s)
|
||||
🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
|
||||
$ git ls-remote rad
|
||||
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 HEAD
|
||||
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
|
||||
afec366785ed3651cdc66975c0fec41866c9ce62 refs/heads/releases/2
|
||||
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/tags/qa/v2.1
|
||||
ac51a0746a5e8311829bc481202909a1e3acc0c2 refs/tags/v1.0-hotfix
|
||||
89f935f27a16f8ed97915ade4accab8fe48057aa refs/tags/v2.0
|
||||
```
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
Bob overhears that the new name for the default branch is "main", not "master"
|
||||
as it used to be. To make tooling that expect "main" work, without the hassle
|
||||
of having to push to such branch manually, he opts to create a symbolic
|
||||
reference.
|
||||
|
||||
``` ~bob
|
||||
$ cd heartwood
|
||||
$ rad id update --title "Add canonical symbolic ref" --payload xyz.radicle.crefs symbolic '{ "refs/heads/main": "refs/heads/master" }'
|
||||
✓ Identity revision [..] created
|
||||
╭────────────────────────────────────────────────────────────────────────╮
|
||||
│ Title Add canonical symbolic ref │
|
||||
│ Revision 62e2cb60c6df9ad9908b6697b5d126760a855484 │
|
||||
│ Blob b20de7b184673eb0d9227be17640c923d8ef3f3e │
|
||||
│ Author did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │
|
||||
│ State active │
|
||||
│ Quorum no │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ ✓ did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob (you) │
|
||||
│ ? did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice │
|
||||
╰────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
{
|
||||
"payload": {
|
||||
"xyz.radicle.crefs": {
|
||||
"rules": {
|
||||
"refs/heads/releases/*": {
|
||||
"allow": [
|
||||
"did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
|
||||
],
|
||||
"threshold": 1
|
||||
},
|
||||
"refs/tags/*": {
|
||||
"allow": "delegates",
|
||||
"threshold": 1
|
||||
},
|
||||
"refs/tags/qa/*": {
|
||||
"allow": "delegates",
|
||||
"threshold": 1
|
||||
}
|
||||
+ },
|
||||
+ "symbolic": {
|
||||
+ "refs/heads/main": "refs/heads/master"
|
||||
}
|
||||
},
|
||||
"xyz.radicle.project": {
|
||||
"defaultBranch": "master",
|
||||
"description": "Radicle Heartwood Protocol & Stack",
|
||||
"name": "heartwood"
|
||||
}
|
||||
},
|
||||
"delegates": [
|
||||
"did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
|
||||
"did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
|
||||
],
|
||||
"threshold": 1
|
||||
}
|
||||
```
|
||||
|
||||
Alice is happy with the new revision and accepts it.
|
||||
|
||||
``` ~alice
|
||||
$ rad sync -f
|
||||
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
|
||||
✓ Target met: 1 seed(s)
|
||||
🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
|
||||
$ rad id accept 62e2cb60c6df9ad9908b6697b5d126760a855484 -q
|
||||
```
|
||||
|
||||
As usual, alice works on "master".
|
||||
|
||||
``` ~alice
|
||||
$ git commit --allow-empty --message "Whew, new feature!"
|
||||
[master 4dc510d] Whew, new feature!
|
||||
```
|
||||
|
||||
And updating the canonical reference for "master" also works as usual.
|
||||
|
||||
``` ~alice (stderr)
|
||||
$ git push rad
|
||||
✓ Canonical reference refs/heads/master updated to target commit 4dc510ddea5fd66499d1d2e996b8a97c8d57be54
|
||||
✓ Synced with 1 seed(s)
|
||||
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
||||
f2de534..4dc510d master -> master
|
||||
```
|
||||
|
||||
Then, Alice is curious about the new symbolic reference.
|
||||
She inspects the remote and sees that indeed a new branch named "main" now exists.
|
||||
|
||||
``` ~alice
|
||||
$ git ls-remote rad
|
||||
4dc510ddea5fd66499d1d2e996b8a97c8d57be54 HEAD
|
||||
4dc510ddea5fd66499d1d2e996b8a97c8d57be54 refs/heads/main
|
||||
4dc510ddea5fd66499d1d2e996b8a97c8d57be54 refs/heads/master
|
||||
afec366785ed3651cdc66975c0fec41866c9ce62 refs/heads/releases/2
|
||||
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/tags/qa/v2.1
|
||||
ac51a0746a5e8311829bc481202909a1e3acc0c2 refs/tags/v1.0-hotfix
|
||||
89f935f27a16f8ed97915ade4accab8fe48057aa refs/tags/v2.0
|
||||
```
|
||||
|
||||
Of course, she can also fetch it to her working copy as usual.
|
||||
|
||||
``` ~alice (stderr)
|
||||
$ git fetch rad
|
||||
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
|
||||
* [new branch] main -> rad/main
|
||||
* [new branch] releases/2 -> rad/releases/2
|
||||
* [new tag] qa/v2.1 -> rad/tags/qa/v2.1
|
||||
* [new tag] qa/v2.1 -> qa/v2.1
|
||||
```
|
||||
|
||||
Bob fetches Alice's changes.
|
||||
|
||||
``` ~bob
|
||||
$ rad sync -f
|
||||
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
|
||||
✓ Target met: 1 seed(s)
|
||||
🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
||||
```
|
||||
|
||||
And, sure enough, there is the new branch just as he wanted it.
|
||||
|
||||
``` ~bob (stderr)
|
||||
$ git fetch rad
|
||||
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
|
||||
* [new branch] main -> rad/main
|
||||
f2de534..4dc510d master -> rad/master
|
||||
```
|
||||
|
||||
Note that neither Alice nor Bob pushed directly to "main".
|
||||
|
|
@ -301,8 +301,12 @@ fn git_push_canonical_lightweight_tags() {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
/// This test exercises a large surface of the "Canonical References" feature:
|
||||
/// - Annotated Tags
|
||||
/// - Branches
|
||||
/// - Symbolic References
|
||||
#[test]
|
||||
fn git_push_canonical_annotated_tags() {
|
||||
fn git_push_canonical() {
|
||||
let mut environment = Environment::new();
|
||||
let alice = environment.node("alice");
|
||||
let bob = environment.node("bob");
|
||||
|
|
@ -341,4 +345,40 @@ fn git_push_canonical_annotated_tags() {
|
|||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
|
||||
formula(
|
||||
&environment.tempdir(),
|
||||
"examples/git/git-push-canonical-branch.md",
|
||||
)
|
||||
.unwrap()
|
||||
.home(
|
||||
"alice",
|
||||
environment.work(&alice),
|
||||
[("RAD_HOME", alice.home.path().display())],
|
||||
)
|
||||
.home(
|
||||
"bob",
|
||||
environment.work(&bob),
|
||||
[("RAD_HOME", bob.home.path().display())],
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
|
||||
formula(
|
||||
&environment.tempdir(),
|
||||
"examples/git/git-push-canonical-symbolic-ref.md",
|
||||
)
|
||||
.unwrap()
|
||||
.home(
|
||||
"alice",
|
||||
environment.work(&alice),
|
||||
[("RAD_HOME", alice.home.path().display())],
|
||||
)
|
||||
.home(
|
||||
"bob",
|
||||
environment.work(&bob),
|
||||
[("RAD_HOME", bob.home.path().display())],
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,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::{Storage, cob, git, node};
|
||||
use radicle_fetch::git::refs::Applied;
|
||||
|
|
@ -128,15 +127,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 for {rid}: {e}")
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
let canonical = match set_canonical_refs(&repo, &applied) {
|
||||
Ok(updates) => updates.unwrap_or_default(),
|
||||
|
|
@ -346,8 +336,21 @@ fn set_canonical_refs(
|
|||
repo: &Repository,
|
||||
applied: &Applied,
|
||||
) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> {
|
||||
const LOG_MESSAGE: &str = "set-canonical-reference 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
|
||||
|
|
@ -389,12 +392,10 @@ fn set_canonical_refs(
|
|||
refname, object, ..
|
||||
}) => {
|
||||
let oid = object.id();
|
||||
if let Err(e) = repo.backend.reference(
|
||||
refname.clone().as_str(),
|
||||
oid.into(),
|
||||
true,
|
||||
"set-canonical-reference from fetch (radicle)",
|
||||
) {
|
||||
if let Err(e) =
|
||||
repo.backend
|
||||
.reference(refname.clone().as_str(), oid.into(), true, LOG_MESSAGE)
|
||||
{
|
||||
log::warn!(
|
||||
target: "worker",
|
||||
"Failed to set canonical reference {refname}->{oid}: {e}"
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ pub(super) fn run(
|
|||
git: &impl GitService,
|
||||
node: &mut impl NodeSession,
|
||||
) -> Result<Vec<String>, Error> {
|
||||
const LOG_MESSAGE: &str = "set-canonical-reference from git-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.
|
||||
|
|
@ -290,9 +292,6 @@ pub(super) fn run(
|
|||
}
|
||||
}
|
||||
let delegates = stored.delegates()?;
|
||||
let identity = stored.identity()?;
|
||||
let project = identity.project()?;
|
||||
let canonical_ref = git::refs::branch(project.default_branch());
|
||||
let mut set_canonical_refs: Vec<(git::fmt::Qualified, git::canonical::Object)> =
|
||||
Vec::with_capacity(specs.len());
|
||||
|
||||
|
|
@ -407,6 +406,8 @@ pub(super) 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();
|
||||
|
|
@ -419,20 +420,11 @@ pub(super) 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 oid != new => {
|
||||
stored.backend.reference(
|
||||
refname.as_str(),
|
||||
oid.into(),
|
||||
true,
|
||||
"set-canonical-reference from git-push (radicle)",
|
||||
)?;
|
||||
stored
|
||||
.backend
|
||||
.reference(refname.as_str(), oid.into(), true, LOG_MESSAGE)?;
|
||||
print_update();
|
||||
}
|
||||
Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub enum Error {
|
|||
}
|
||||
|
||||
/// A witnesses that the inner reference-like value is not protected.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, Hash)]
|
||||
#[repr(transparent)]
|
||||
#[serde(transparent)]
|
||||
pub(super) struct Unprotected<T: RefLike>(T);
|
||||
|
|
@ -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> {
|
||||
|
|
@ -50,6 +57,20 @@ impl<T: RefLike> AsRef<T> for Unprotected<T> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<T: RefLike> std::borrow::Borrow<T> for Unprotected<T> {
|
||||
fn borrow(&self) -> &T {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables looking up entries in a map keyed by `Unprotected<RefString>` using
|
||||
/// a `&RefStr`.
|
||||
impl std::borrow::Borrow<crate::git::fmt::RefStr> for Unprotected<crate::git::fmt::RefString> {
|
||||
fn borrow(&self) -> &crate::git::fmt::RefStr {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: RefLike + serde::Deserialize<'de>> serde::Deserialize<'de> for Unprotected<T> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
|
|
@ -68,7 +89,10 @@ 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::fmt::{RefString, refspec::QualifiedPattern};
|
||||
use crate::git::{
|
||||
fmt::{Qualified, RefStr, RefString, refname, refspec::QualifiedPattern},
|
||||
refs::branch,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
|
|
@ -76,6 +100,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<'_> {}
|
||||
|
|
|
|||
|
|
@ -386,6 +386,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("\"delegates\""),
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,754 @@
|
|||
//! Symbolic references, which link neither to nor from protected references.
|
||||
//! The prototypical example is `HEAD → refs/heads/main`.
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::git::fmt::Qualified;
|
||||
use crate::git::fmt::RefStr;
|
||||
use crate::git::fmt::RefString;
|
||||
|
||||
use super::protect::Unprotected;
|
||||
|
||||
/// A type alias for a [`RefString`] that has yet to be validated into a
|
||||
/// a symbolic reference name.
|
||||
pub type RawName = RefString;
|
||||
|
||||
/// A type alias for a [`RefString`] that has yet to be validated into a
|
||||
/// symbolic reference target.
|
||||
pub type RawTarget = RefString;
|
||||
|
||||
/// The target of a symbolic reference.
|
||||
///
|
||||
/// A target is either:
|
||||
/// - [`Direct`](Target::Direct): a concrete qualified reference
|
||||
/// (e.g. `refs/heads/main`).
|
||||
/// - [`Symbolic`](Target::Symbolic): another symbolic reference name
|
||||
/// (e.g. `MAIN`) that must itself resolve through the chain.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// A concrete qualified reference — the end of a chain.
|
||||
Direct(Direct),
|
||||
/// Another symbolic reference name — an intermediate link.
|
||||
Symbolic(Symbolic),
|
||||
}
|
||||
|
||||
impl AsRef<RefStr> for Target {
|
||||
fn as_ref(&self) -> &RefStr {
|
||||
match self {
|
||||
Target::Direct(direct) => direct.0.as_ref(),
|
||||
Target::Symbolic(symbolic) => symbolic.0.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A concrete qualified reference — the end of a chain.
|
||||
// `Unprotected` has `super` visibility, so `Direct` is used to allow `Target`
|
||||
// to be `pub`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Direct(Unprotected<Qualified<'static>>);
|
||||
|
||||
impl Direct {
|
||||
fn to_ref_string(&self) -> Unprotected<RefString> {
|
||||
self.0.to_ref_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<RefString> for Direct {
|
||||
fn eq(&self, other: &RefString) -> bool {
|
||||
**self.0.as_ref() == **other
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Qualified<'static>> for Direct {
|
||||
fn as_ref(&self) -> &Qualified<'static> {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// A concrete qualified reference — the end of a chain.
|
||||
// `Unprotected` has `super` visibility, so `Symbolic` is used to allow `Target`
|
||||
// to be `pub`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Symbolic(Unprotected<RefString>);
|
||||
|
||||
impl AsRef<RefString> for Symbolic {
|
||||
fn as_ref(&self) -> &RefString {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// Returns the underlying reference as a `&RefStr`.
|
||||
pub fn as_refstr(&self) -> &RefStr {
|
||||
match self {
|
||||
Target::Direct(q) => q.as_ref().as_ref(),
|
||||
Target::Symbolic(s) => s.as_ref().as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn direct(d: Unprotected<Qualified<'static>>) -> Self {
|
||||
Self::Direct(Direct(d))
|
||||
}
|
||||
|
||||
fn symbolic(s: Unprotected<RefString>) -> Self {
|
||||
Self::Symbolic(Symbolic(s))
|
||||
}
|
||||
|
||||
/// Classify an [`Unprotected<RefString>`] as either
|
||||
/// [`Direct`](Target::Direct) or [`Symbolic`](Target::Symbolic)
|
||||
/// based on whether it is [`Qualified`].
|
||||
///
|
||||
/// The [`Unprotected`] proof is preserved in the resulting variant.
|
||||
fn classify(s: Unprotected<RefString>) -> Self {
|
||||
match Qualified::from_refstr(s.as_ref()) {
|
||||
// Safety: the Qualified is derived from an Unprotected string,
|
||||
// so it is also unprotected.
|
||||
Some(q) => Target::direct(
|
||||
Unprotected::new(q.to_owned())
|
||||
.expect("qualified derived from unprotected is unprotected"),
|
||||
),
|
||||
None => Target::symbolic(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Target {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_refstr().as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Target {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_refstr().as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Maintains an acyclic set of symbolic references.
|
||||
/// Note that dangling references are not detected.
|
||||
///
|
||||
/// Internally, targets are stored as [`Target`], which distinguishes
|
||||
/// direct (qualified) targets from symbolic (intermediate) ones. This
|
||||
/// means resolution and cycle-checking can pattern-match on the variant
|
||||
/// rather than re-parsing the string.
|
||||
///
|
||||
/// # Deserialization Order
|
||||
///
|
||||
/// Deserialization validates entries in iteration order via
|
||||
/// [`TryFrom<IndexMap>`]. This means deserialization only succeeds if
|
||||
/// symbolic reference whose target is another symbolic
|
||||
/// reference appears *after* that target in the JSON.
|
||||
/// For example,
|
||||
/// `{"MAIN": "refs/heads/master", "HEAD": "MAIN"}` is valid, but
|
||||
/// `{"HEAD": "MAIN", "MAIN": "refs/heads/master"}` is not.
|
||||
///
|
||||
/// To ensure that serialization and deserialization are inverses,
|
||||
/// we must ensure that insertion order and iteration order are the same.
|
||||
/// We do this by using [`IndexMap`] internally, which preserves this property,
|
||||
/// and enabling the feature `preserve_order` of the `serde_json` crate.
|
||||
///
|
||||
/// Normally, JSON objects are unordered (see [RFC 8259, Sec. 4]).
|
||||
/// Any compatible (de-)serializer must preserve key order.
|
||||
///
|
||||
/// [RFC 8259, Sec. 4]: <https://datatracker.ietf.org/doc/html/rfc8259#section-4>
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(try_from = "IndexMap<Name, Unprotected<RefString>>")]
|
||||
pub struct SymbolicRefs(IndexMap<Name, Target>);
|
||||
|
||||
/// Read-only access.
|
||||
impl SymbolicRefs {
|
||||
/// Returns an iterator over all contained symbolic references, as pairs
|
||||
/// of their name and [`Target`].
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&RawName, &Target)> {
|
||||
self.0.iter().map(|(name, target)| (name.as_ref(), target))
|
||||
}
|
||||
|
||||
/// Returns an iterator over all contained symbolic references that
|
||||
/// resolve to a direct (qualified) target. The yielded target is the
|
||||
/// final [`Qualified`] reference after chasing through any intermediate
|
||||
/// symbolic references.
|
||||
pub fn iter_resolved(&self) -> impl Iterator<Item = (&RawName, &Qualified<'static>)> {
|
||||
self.0.keys().filter_map(|name| {
|
||||
self.resolve(name.as_ref())
|
||||
.map(|target| (name.as_ref(), target))
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a name through the chain of symbolic references until a
|
||||
/// [`Target::Direct`] target is reached. Returns `None` if the
|
||||
/// name is not in the map or if the chain dangles (ends at a
|
||||
/// [`Target::Symbolic`] whose name is not a key).
|
||||
fn resolve(&self, name: &RefString) -> Option<&Qualified<'static>> {
|
||||
let mut current = self.0.get(name)?;
|
||||
loop {
|
||||
match current {
|
||||
Target::Direct(q) => return Some(q.as_ref()),
|
||||
Target::Symbolic(s) => match self.0.get(s.as_ref()) {
|
||||
Some(next) => current = next,
|
||||
None => return 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 [`super::protect::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 resolved target of the `HEAD` reference.
|
||||
/// Returns the final [`Qualified`] reference after chasing the chain.
|
||||
/// See also [`SymbolicRefs::head`].
|
||||
pub fn resolve_head(&self) -> Option<&Qualified<'static>> {
|
||||
self.resolve(Unprotected::head().as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum InsertionError {
|
||||
#[error("inserting symbolic reference '{name} → {target}' would create a cycle")]
|
||||
Cyclic { name: RawName, target: RawName },
|
||||
|
||||
#[error(
|
||||
"inserting symbolic reference '{name} → {target}' would result in a symbolic reference targeting an unqualified reference"
|
||||
)]
|
||||
TargetNotQualified { name: RawName, target: RawName },
|
||||
}
|
||||
|
||||
/// Mutability.
|
||||
impl SymbolicRefs {
|
||||
/// Insert a symbolic reference from the given `name` to the given `target`.
|
||||
///
|
||||
/// Internally, this classifies the `target` into the [`Target`] and
|
||||
/// delegates the insertion.
|
||||
pub(super) fn try_insert_unprotected(
|
||||
&mut self,
|
||||
name: Name,
|
||||
target: Unprotected<RefString>,
|
||||
) -> Result<(), InsertionError> {
|
||||
self.insert(name, Target::classify(target))
|
||||
}
|
||||
|
||||
/// Check whether `name` is reachable from `start` by chasing through
|
||||
/// the map. Used to detect cycles before insertion.
|
||||
///
|
||||
/// Unlike [`resolve`](SymbolicRefs::resolve), this chases through
|
||||
/// *all* entries regardless of whether the target is [`Direct`](Target::Direct)
|
||||
/// or [`Symbolic`](Target::Symbolic), since a qualified ref string can
|
||||
/// also be a key in the map and thus part of a cycle.
|
||||
fn is_reachable_from(&self, start: &RefString, name: &Name) -> bool {
|
||||
let name = name.as_ref();
|
||||
if start == name {
|
||||
return true;
|
||||
}
|
||||
let mut current: &RefStr = start;
|
||||
loop {
|
||||
match self.0.get(current) {
|
||||
None => return false,
|
||||
Some(target) => {
|
||||
let next = target.as_refstr();
|
||||
if *next == **name {
|
||||
return true;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to insert a symbolic reference.
|
||||
/// Errors if `name` or `target` is protected (see [`super::protect`]) or would
|
||||
/// cause infinite recursion (e.g. `A → B` and `B → A`).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If `name` or `target` is not unprotected.
|
||||
#[cfg(test)]
|
||||
fn try_insert(&mut self, name: RawName, target: RawTarget) -> Result<(), InsertionError> {
|
||||
self.try_insert_unprotected(
|
||||
Unprotected::new(name).expect("name is unprotected"),
|
||||
Unprotected::new(target).expect("target is unprotected"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Consume `other` by iteratively inserting into self.
|
||||
pub fn combine(&mut self, other: SymbolicRefs) -> Result<(), InsertionError> {
|
||||
for (name, target) in other.0 {
|
||||
self.insert(name, target)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a symbolic reference from the given `name` to the given `target`.
|
||||
///
|
||||
/// The targets in the map can change their classification from
|
||||
/// [`Target::Direct`] to [`Target::Symbolic`], if the new insertion of the
|
||||
/// `name` or `target` matches an existing key or existing entries.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The `target` reference must either:
|
||||
/// - be a direct [`Qualified`] reference, or
|
||||
/// - resolve to a direct [`Qualified`] reference, if it is a keyed entry in [`SymbolicRefs`].
|
||||
///
|
||||
/// If neither of these is satisfied then an
|
||||
/// [`InsertionError::TargetNotQualified`] error is returned.
|
||||
///
|
||||
/// If the `name` and `target` end up in a cycle, e.g., `a → b → a`, then an
|
||||
/// [`InsertionError::Cyclic`] error is returned.
|
||||
fn insert(&mut self, name: Name, mut target: Target) -> Result<(), InsertionError> {
|
||||
let target_str = match &target {
|
||||
Target::Direct(q) => q.as_ref().to_ref_string(),
|
||||
Target::Symbolic(s) => s.as_ref().clone(),
|
||||
};
|
||||
|
||||
if self.is_reachable_from(&target_str, &name) {
|
||||
return Err(InsertionError::Cyclic {
|
||||
name: name.into_inner(),
|
||||
target: target_str,
|
||||
});
|
||||
}
|
||||
|
||||
// A [`Target::Direct`] whose string is already a key is actually
|
||||
// an intermediate link — downgrade to [`Target::Symbolic`].
|
||||
if let Target::Direct(q) = &target {
|
||||
if self.0.contains_key(&q.as_ref().to_ref_string()) {
|
||||
target = Target::symbolic(q.to_ref_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Target::Symbolic(s) = &target {
|
||||
if self.resolve(s.as_ref()).is_none() {
|
||||
return Err(InsertionError::TargetNotQualified {
|
||||
name: name.into_inner(),
|
||||
target: target_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.reclassify_targets(&name);
|
||||
self.0.insert(name, target);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When a new key is inserted, any existing [`Target::Direct`] whose
|
||||
/// qualified string matches the new key is no longer terminal — it is
|
||||
/// now an intermediate link and must be reclassified as [`Target::Symbolic`].
|
||||
fn reclassify_targets(&mut self, new_key: &Name) {
|
||||
let key = new_key.as_ref();
|
||||
for existing in self.0.values_mut() {
|
||||
if let Target::Direct(q) = existing {
|
||||
if q == key {
|
||||
*existing = Target::symbolic(q.to_ref_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<IndexMap<Name, Unprotected<RefString>>> for SymbolicRefs {
|
||||
type Error = InsertionError;
|
||||
|
||||
fn try_from(map: IndexMap<Name, Unprotected<RefString>>) -> Result<Self, Self::Error> {
|
||||
map.into_iter()
|
||||
.try_fold(Self::default(), |mut result, (name, target)| {
|
||||
result.try_insert_unprotected(name, target)?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod test {
|
||||
use crate::assert_matches;
|
||||
use crate::git::fmt::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!("refs/heads/b")),
|
||||
Ok(())
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
symbolic.try_insert(refname!("refs/heads/b"), refname!("refs/heads/c")),
|
||||
Ok(())
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
symbolic.try_insert(refname!("refs/heads/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_order() {
|
||||
assert_matches!(
|
||||
serde_json::from_value::<SymbolicRefs>(serde_json::json!({
|
||||
"MAIN": "refs/heads/master",
|
||||
"HEAD": "MAIN",
|
||||
})),
|
||||
Ok(_)
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
serde_json::from_value::<SymbolicRefs>(serde_json::json!({
|
||||
"HEAD": "MAIN",
|
||||
"MAIN": "refs/heads/master",
|
||||
})),
|
||||
Err(_)
|
||||
);
|
||||
}
|
||||
|
||||
#[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(_)
|
||||
);
|
||||
|
||||
assert_matches!(
|
||||
serde_json::from_value::<SymbolicRefs>(serde_json::json!({
|
||||
"HEAD": "b",
|
||||
})),
|
||||
Err(_)
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies that resolution works correctly for chains with 2 links
|
||||
/// (even-length), e.g. `HEAD → MAIN → refs/heads/master`.
|
||||
#[test]
|
||||
fn resolve_two_hop_chain() {
|
||||
let symrefs = serde_json::from_value::<SymbolicRefs>(serde_json::json!({
|
||||
"MAIN": "refs/heads/master",
|
||||
"HEAD": "MAIN",
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// HEAD → MAIN → refs/heads/master should resolve to refs/heads/master
|
||||
assert_eq!(
|
||||
symrefs.resolve_head().map(|r| r.as_str()),
|
||||
Some("refs/heads/master"),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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!("refs/heads/a"), refname!("refs/heads/b")),
|
||||
Ok(())
|
||||
);
|
||||
|
||||
let mut b = SymbolicRefs::default();
|
||||
assert_matches!(
|
||||
b.try_insert(refname!("refs/heads/b"), refname!("refs/heads/a")),
|
||||
Ok(())
|
||||
);
|
||||
|
||||
assert_matches!(a.combine(b), Err(InsertionError::Cyclic { .. }));
|
||||
}
|
||||
|
||||
/// Verifies that direct targets are stored as [`Target::Direct`].
|
||||
#[test]
|
||||
fn target_classification() {
|
||||
let symrefs = serde_json::from_value::<SymbolicRefs>(serde_json::json!({
|
||||
"HEAD": "refs/heads/main",
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let (_, target) = symrefs.iter().next().unwrap();
|
||||
assert_matches!(target, Target::Direct(_));
|
||||
}
|
||||
|
||||
/// Verifies that symbolic targets are stored as [`Target::Symbolic`].
|
||||
#[test]
|
||||
fn target_classification_symbolic() {
|
||||
let symrefs = serde_json::from_value::<SymbolicRefs>(serde_json::json!({
|
||||
"MAIN": "refs/heads/master",
|
||||
"HEAD": "MAIN",
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let head_entry = symrefs
|
||||
.iter()
|
||||
.find_map(|(name, target)| (name.as_str() == "HEAD").then_some(target))
|
||||
.unwrap();
|
||||
assert_matches!(head_entry, Target::Symbolic(_));
|
||||
|
||||
let main_entry = symrefs
|
||||
.iter()
|
||||
.find_map(|(name, target)| (name.as_str() == "MAIN").then_some(target))
|
||||
.unwrap();
|
||||
assert_matches!(main_entry, Target::Direct(_));
|
||||
}
|
||||
|
||||
/// Verifies that an existing direct target can become a symbolic target
|
||||
/// during a new insertion.
|
||||
#[test]
|
||||
fn target_reclassification() {
|
||||
let mut symrefs = SymbolicRefs::default();
|
||||
symrefs
|
||||
.try_insert(refname!("HEAD"), refname!("refs/heads/main"))
|
||||
.unwrap();
|
||||
symrefs
|
||||
.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
|
||||
.unwrap();
|
||||
let main = symrefs
|
||||
.iter()
|
||||
.find_map(|(_, target)| {
|
||||
(target.as_refstr().as_str() == "refs/heads/main").then_some(target)
|
||||
})
|
||||
.unwrap();
|
||||
assert_matches!(main, Target::Symbolic(_));
|
||||
}
|
||||
|
||||
/// Verifies that an existing direct target can become a symbolic target
|
||||
/// during a new insertion.
|
||||
#[test]
|
||||
fn target_reclassification_commutative() {
|
||||
let mut symrefs = SymbolicRefs::default();
|
||||
symrefs
|
||||
.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
|
||||
.unwrap();
|
||||
symrefs
|
||||
.try_insert(refname!("HEAD"), refname!("refs/heads/main"))
|
||||
.unwrap();
|
||||
let main = symrefs
|
||||
.iter()
|
||||
.find_map(|(_, target)| {
|
||||
(target.as_refstr().as_str() == "refs/heads/main").then_some(target)
|
||||
})
|
||||
.unwrap();
|
||||
assert_matches!(main, Target::Symbolic(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclassification_reverse_chain() {
|
||||
let mut symrefs = SymbolicRefs::default();
|
||||
|
||||
// Build the chain in reverse: terminal first, origin last.
|
||||
for (name, target) in [
|
||||
(refname!("refs/heads/c"), refname!("refs/heads/d")),
|
||||
(refname!("refs/heads/b"), refname!("refs/heads/c")),
|
||||
(refname!("refs/heads/a"), refname!("refs/heads/b")),
|
||||
] {
|
||||
symrefs.try_insert(name, target).unwrap();
|
||||
}
|
||||
|
||||
// Only refs/heads/d (the terminal) should be Direct.
|
||||
// refs/heads/b and refs/heads/c are both keys AND targets — Symbolic.
|
||||
for (_, target) in symrefs.iter() {
|
||||
match target.as_refstr().as_str() {
|
||||
"refs/heads/d" => assert_matches!(target, Target::Direct(_)),
|
||||
other => {
|
||||
assert_matches!(target, Target::Symbolic(_), "expected Symbolic for {other}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution should still work through the full chain.
|
||||
assert_eq!(
|
||||
symrefs
|
||||
.resolve(&refname!("refs/heads/a"))
|
||||
.map(|q| q.as_str()),
|
||||
Some("refs/heads/d"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclassification_diamond() {
|
||||
let mut symrefs = SymbolicRefs::default();
|
||||
symrefs
|
||||
.try_insert(refname!("HEAD"), refname!("refs/heads/main"))
|
||||
.unwrap();
|
||||
symrefs
|
||||
.try_insert(refname!("DEFAULT"), refname!("refs/heads/main"))
|
||||
.unwrap();
|
||||
|
||||
// Both targets are Direct — refs/heads/main is not a key yet.
|
||||
// Now make it a key:
|
||||
symrefs
|
||||
.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
|
||||
.unwrap();
|
||||
|
||||
// Both HEAD and DEFAULT's targets should now be Symbolic.
|
||||
let targets_for_main: Vec<_> = symrefs
|
||||
.iter()
|
||||
.filter(|(_, t)| t.as_refstr().as_str() == "refs/heads/main")
|
||||
.collect();
|
||||
assert_eq!(targets_for_main.len(), 2);
|
||||
for (name, target) in targets_for_main {
|
||||
assert_matches!(
|
||||
target,
|
||||
Target::Symbolic(_),
|
||||
"expected Symbolic for {name}'s target"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclassification_order_invariant() {
|
||||
// Order A: HEAD first, then the chain link.
|
||||
let a = {
|
||||
let mut s = SymbolicRefs::default();
|
||||
s.try_insert(refname!("HEAD"), refname!("refs/heads/main"))
|
||||
.unwrap();
|
||||
s.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
|
||||
.unwrap();
|
||||
s
|
||||
};
|
||||
|
||||
// Order B: chain link first, then HEAD.
|
||||
let b = {
|
||||
let mut s = SymbolicRefs::default();
|
||||
s.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
|
||||
.unwrap();
|
||||
s.try_insert(refname!("HEAD"), refname!("refs/heads/main"))
|
||||
.unwrap();
|
||||
s
|
||||
};
|
||||
|
||||
// Both should resolve HEAD to the same place.
|
||||
assert_eq!(a.resolve_head(), b.resolve_head());
|
||||
|
||||
// Both should have the same classification for the refs/heads/main target.
|
||||
let classify_a = a
|
||||
.iter()
|
||||
.find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main")
|
||||
.unwrap()
|
||||
.1;
|
||||
let classify_b = b
|
||||
.iter()
|
||||
.find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_matches!(classify_a, Target::Symbolic(_));
|
||||
assert_matches!(classify_b, Target::Symbolic(_));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclassification_combine() {
|
||||
// A has HEAD → refs/heads/main (Direct)
|
||||
let mut a = SymbolicRefs::head(&refname!("main"));
|
||||
|
||||
// B has refs/heads/main → refs/heads/master (Direct)
|
||||
let mut b = SymbolicRefs::default();
|
||||
b.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
|
||||
.unwrap();
|
||||
|
||||
a.combine(b).unwrap();
|
||||
|
||||
// After combine, HEAD's target refs/heads/main should be Symbolic.
|
||||
let main_target = a
|
||||
.iter()
|
||||
.find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main")
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_matches!(main_target, Target::Symbolic(_));
|
||||
assert_eq!(
|
||||
a.resolve_head().map(|q| q.as_str()),
|
||||
Some("refs/heads/master")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclassification_combine_reverse() {
|
||||
// B has refs/heads/main → refs/heads/master (Direct)
|
||||
let mut b = SymbolicRefs::default();
|
||||
b.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
|
||||
.unwrap();
|
||||
|
||||
// A has HEAD → refs/heads/main (Direct)
|
||||
let a = SymbolicRefs::head(&refname!("main"));
|
||||
|
||||
b.combine(a).unwrap();
|
||||
|
||||
// HEAD's target refs/heads/main IS a key — should be Symbolic.
|
||||
let main_target = b
|
||||
.iter()
|
||||
.find_map(|(_, t)| (t.as_refstr().as_str() == "refs/heads/main").then_some(t))
|
||||
.unwrap();
|
||||
assert_matches!(main_target, Target::Symbolic(_));
|
||||
assert_eq!(
|
||||
b.resolve_head().map(|q| q.as_str()),
|
||||
Some("refs/heads/master")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,48 @@
|
|||
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::fmt::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: Qualified<'static>,
|
||||
},
|
||||
|
||||
#[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`].
|
||||
/// [`RawCanonicalRefs`] are verified into [`CanonicalRefs`].
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[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 +50,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 +74,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 +82,62 @@ 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 rules.matches(target).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 mut patterns = rules
|
||||
.matches(&name)
|
||||
.map(|(pattern, _)| pattern.to_string())
|
||||
.peekable();
|
||||
if patterns.peek().is_some() {
|
||||
return Err(ValidationError::Clash {
|
||||
patterns: patterns.collect(),
|
||||
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::r#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(_)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ use crate::crypto;
|
|||
use crate::crypto::Signature;
|
||||
use crate::git;
|
||||
use crate::git::canonical::rules;
|
||||
use crate::git::canonical::symbolic;
|
||||
use crate::git::fmt::Qualified;
|
||||
use crate::git::fmt::RefString;
|
||||
use crate::git::raw::ErrorExt as _;
|
||||
use crate::identity::crefs;
|
||||
use crate::identity::{Did, project::Project};
|
||||
use crate::node::device::Device;
|
||||
use crate::storage;
|
||||
|
|
@ -75,9 +79,20 @@ impl DocError {
|
|||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DefaultBranchRuleError {
|
||||
#[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
|
||||
Payload(#[from] PayloadError),
|
||||
pub enum DefaultBranchError {
|
||||
#[error(
|
||||
"could not find default branch in any of the payloads `xyz.radicle.project` ({project}) or `xyz.radicle.crefs` ({crefs})"
|
||||
)]
|
||||
Payload {
|
||||
project: PayloadError,
|
||||
crefs: PayloadError,
|
||||
},
|
||||
|
||||
#[error("no symbolic reference with name `HEAD` is defined")]
|
||||
MissingHead,
|
||||
|
||||
#[error(transparent)]
|
||||
CanonicalRefsError(#[from] CanonicalRefsError),
|
||||
}
|
||||
|
||||
/// The version number of the identity document.
|
||||
|
|
@ -757,42 +772,133 @@ impl Doc {
|
|||
}
|
||||
|
||||
/// Gets the qualified reference name of the default branch,
|
||||
/// according to the project payload in this document.
|
||||
pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, PayloadError> {
|
||||
Ok(git::refs::branch(self.project()?.default_branch()))
|
||||
}
|
||||
|
||||
pub fn default_branch_rule(&self) -> Result<rules::Rules, DefaultBranchRuleError> {
|
||||
let pattern = git::fmt::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"))
|
||||
/// according to payloads `xyz.radicle.project` and `xyz.radicle.crefs`
|
||||
/// in this document.
|
||||
pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, DefaultBranchError> {
|
||||
let crefs = self.canonical_refs()?;
|
||||
let qualified = crefs
|
||||
.symbolic()
|
||||
.resolve_head()
|
||||
.ok_or(DefaultBranchError::MissingHead)?;
|
||||
Ok(qualified.to_owned())
|
||||
}
|
||||
|
||||
/// Construct the canonical references for this document.
|
||||
/// The implementation of [`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`].
|
||||
///
|
||||
/// Uses the `xyz.radicle.crefs` payload (if present) and the
|
||||
/// `xyz.radicle.project` payload to determine the `HEAD` symbolic
|
||||
/// reference and its associated rule.
|
||||
///
|
||||
/// There are three cases, depending on whether `HEAD` is already
|
||||
/// defined in the crefs payload and whether a project payload exists:
|
||||
///
|
||||
/// 1. **Explicit HEAD + project**: `HEAD` must agree with
|
||||
/// [`Project::default_branch_qualified`], and the matching rule must
|
||||
/// use `delegates` with the document's threshold.
|
||||
/// 2. **Explicit HEAD, no project**: The matching rule must use
|
||||
/// `delegates` with the document's threshold.
|
||||
/// 3. **No HEAD**: A rule and `HEAD` symbolic reference are synthesized
|
||||
/// from the project payload (which must exist).
|
||||
///
|
||||
/// In all cases the result must pass [`RawCanonicalRefs::try_into_canonical_refs`]
|
||||
/// validation. If a rule for `HEAD`'s target is missing, it will be
|
||||
/// caught as a dangling reference there.
|
||||
///
|
||||
/// [`RawCanonicalRefs`]: super::crefs::RawCanonicalRefs
|
||||
pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
|
||||
let raw_crefs = self.raw_canonical_refs()?.unwrap_or_default();
|
||||
let mut raw_crefs = self.raw_canonical_refs()?.unwrap_or_default();
|
||||
let resolve = &mut || self.delegates.clone();
|
||||
|
||||
let mut raw_rules = raw_crefs.raw_rules().clone();
|
||||
raw_rules.extend(rules::RawRules::from(self.default_branch_rule()?));
|
||||
// Determine where `HEAD` comes from. The `resolve_head()` result
|
||||
// borrows `raw_crefs`, so clone to allow mutation in the synthesis
|
||||
// path.
|
||||
let head: Option<Qualified<'static>> = raw_crefs.symbolic().resolve_head().cloned();
|
||||
|
||||
let raw_crefs = RawCanonicalRefs::new(raw_rules);
|
||||
Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?)
|
||||
match (head, self.project()) {
|
||||
(Some(ref default_branch), Ok(project)) => {
|
||||
let project_branch = project.default_branch_qualified();
|
||||
if project_branch != *default_branch {
|
||||
return Err(DefaultBranchRuleError::HeadMismatch {
|
||||
cref: default_branch.to_ref_string(),
|
||||
project: project_branch.to_ref_string(),
|
||||
})?;
|
||||
}
|
||||
self.validate_head_rule(&raw_crefs, default_branch)?;
|
||||
}
|
||||
(Some(ref default_branch), Err(_)) => {
|
||||
self.validate_head_rule(&raw_crefs, default_branch)?;
|
||||
}
|
||||
(None, Ok(project)) => {
|
||||
self.synthesize_head(&mut raw_crefs, &project)?;
|
||||
}
|
||||
(None, Err(err)) => {
|
||||
return Err(CanonicalRefsError::SynthesisPayloadMissing(err));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(raw_crefs.try_into_canonical_refs(resolve)?)
|
||||
}
|
||||
|
||||
/// Validate that the rule matching `HEAD`'s target branch uses
|
||||
/// `delegates` and the document's threshold.
|
||||
///
|
||||
/// If no rule matches the target, `HEAD` will dangle — this is caught
|
||||
/// later by [`RawCanonicalRefs::try_into_canonical_refs`] validation.
|
||||
fn validate_head_rule(
|
||||
&self,
|
||||
raw_crefs: &RawCanonicalRefs,
|
||||
default_branch: &Qualified,
|
||||
) -> Result<(), CanonicalRefsError> {
|
||||
let Some((pattern, rule)) = raw_crefs.raw_rules().matches(default_branch).next() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let allowed = rule.allowed();
|
||||
if *allowed != rules::Allowed::Delegates {
|
||||
return Err(DefaultBranchRuleError::Allowed {
|
||||
pattern: pattern.to_string(),
|
||||
actual: allowed.to_string(),
|
||||
})?;
|
||||
}
|
||||
let actual = *rule.threshold();
|
||||
let expected = self.threshold();
|
||||
if actual != expected {
|
||||
return Err(DefaultBranchRuleError::Threshold {
|
||||
pattern: pattern.to_string(),
|
||||
actual,
|
||||
expected,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synthesize a `HEAD` symbolic reference and a default branch rule
|
||||
/// from the project payload.
|
||||
fn synthesize_head(
|
||||
&self,
|
||||
raw_crefs: &mut RawCanonicalRefs,
|
||||
project: &Project,
|
||||
) -> Result<(), CanonicalRefsError> {
|
||||
let default_branch = project.default_branch_qualified();
|
||||
|
||||
if raw_crefs
|
||||
.raw_rules()
|
||||
.matches(&default_branch)
|
||||
.next()
|
||||
.is_none()
|
||||
{
|
||||
raw_crefs.raw_rules_mut().insert(
|
||||
git::fmt::refspec::QualifiedPattern::from(default_branch.to_owned()),
|
||||
rules::Rule::new(rules::Allowed::Delegates, self.threshold()),
|
||||
);
|
||||
}
|
||||
|
||||
raw_crefs
|
||||
.symbolic_mut()
|
||||
.combine(symbolic::SymbolicRefs::head(project.default_branch()))
|
||||
.map_err(|source| CanonicalRefsError::SynthesisCycle { source })?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the associated [`Visibility`] of this document.
|
||||
|
|
@ -952,22 +1058,52 @@ impl Doc {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CanonicalRefsError {
|
||||
#[error(transparent)]
|
||||
Raw(#[from] RawCanonicalRefsError),
|
||||
#[error(transparent)]
|
||||
CanonicalRefs(#[from] rules::ValidationError),
|
||||
#[error(transparent)]
|
||||
DefaultBranch(#[from] DefaultBranchRuleError),
|
||||
}
|
||||
|
||||
#[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] crefs::ValidationError),
|
||||
|
||||
#[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
|
||||
SynthesisPayloadMissing(PayloadError),
|
||||
|
||||
#[error(transparent)]
|
||||
DefaultBranchRuleError(#[from] DefaultBranchRuleError),
|
||||
|
||||
#[error("synthesizing canonical references from project payload failed: {source}")]
|
||||
SynthesisCycle { source: symbolic::InsertionError },
|
||||
}
|
||||
|
||||
#[derive(Debug, 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,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"target symbolic reference 'HEAD' ('{cref}') does not match `defaultBranch` from payload `xyz.radicle.project` ('{project}')"
|
||||
)]
|
||||
HeadMismatch { cref: RefString, project: RefString },
|
||||
}
|
||||
|
||||
pub trait GetRawCanonicalRefs: GetPayload {
|
||||
/// Retrieve the [`RawCanonicalRefs`] by deserializing from the payload
|
||||
/// (if present).
|
||||
|
|
@ -1232,4 +1368,72 @@ mod test {
|
|||
serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_branch_clash() {
|
||||
let value = serde_json::json!(
|
||||
{
|
||||
"payload": {
|
||||
"xyz.radicle.project": {
|
||||
"name": "example",
|
||||
"description": "An example project",
|
||||
"defaultBranch": "main",
|
||||
},
|
||||
"xyz.radicle.crefs": {
|
||||
"symbolic": {
|
||||
"HEAD": "refs/heads/master",
|
||||
},
|
||||
"rules": {
|
||||
"refs/heads/master": {
|
||||
"allow": "delegates",
|
||||
"threshold": 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delegates": [
|
||||
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM"
|
||||
],
|
||||
"threshold": 1
|
||||
}
|
||||
);
|
||||
|
||||
let doc = serde_json::from_value::<Doc>(value).unwrap();
|
||||
assert_matches!(
|
||||
doc.default_branch(),
|
||||
Err(DefaultBranchError::CanonicalRefsError(
|
||||
CanonicalRefsError::DefaultBranchRuleError(
|
||||
DefaultBranchRuleError::HeadMismatch { .. }
|
||||
)
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,9 +208,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()
|
||||
|
|
@ -225,11 +229,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_ref_string(),
|
||||
default,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => { /* we validate below */ }
|
||||
}
|
||||
|
||||
// Verify that the canonical references payload is valid
|
||||
if let Err(e) = proposal.canonical_refs() {
|
||||
return Err(error::DocVerification::PayloadError {
|
||||
|
|
@ -332,7 +344,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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,10 +31,17 @@ pub enum DocVerification {
|
|||
#[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::fmt::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::fmt::Qualified<'static>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use thiserror::Error;
|
|||
|
||||
use crate::crypto;
|
||||
use crate::git::BranchName;
|
||||
use crate::git::{fmt::Qualified, refs::branch};
|
||||
use crate::identity::doc;
|
||||
use crate::identity::doc::Payload;
|
||||
|
||||
|
|
@ -254,6 +255,12 @@ impl Project {
|
|||
pub fn default_branch(&self) -> &BranchName {
|
||||
&self.default_branch
|
||||
}
|
||||
|
||||
/// Return the qualified name of the default branch.
|
||||
#[inline]
|
||||
pub fn default_branch_qualified(&self) -> Qualified<'_> {
|
||||
branch(&self.default_branch)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Project> for Payload {
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ pub enum RepositoryError {
|
|||
#[error("missing canonical reference rule for default branch")]
|
||||
MissingBranchRule,
|
||||
#[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)]
|
||||
|
|
@ -668,11 +668,30 @@ 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 canonical references (thus the `xyz.radicle.crefs`
|
||||
/// payload, and possibly the `xyz.radicle.project` payload in the identity
|
||||
/// document). The targeted canonical references are not computed and might
|
||||
/// not 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<Name, Target>(
|
||||
&self,
|
||||
name: &Name,
|
||||
target: &Target,
|
||||
message: &str,
|
||||
) -> Result<(), RepositoryError>
|
||||
where
|
||||
Name: AsRef<RefStr>,
|
||||
Target: AsRef<RefStr>;
|
||||
|
||||
/// Computes the head of the default branch based on the delegate set,
|
||||
/// and sets it.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ use crate::{git, git::Oid, node};
|
|||
use crate::git::RefError;
|
||||
use crate::git::UserInfo;
|
||||
use crate::git::fmt::{
|
||||
Qualified, RefString, refname, refspec, refspec::PatternStr, refspec::PatternString,
|
||||
Qualified, RefStr, RefString, refspec, refspec::PatternStr, refspec::PatternString,
|
||||
};
|
||||
pub use crate::storage::{Error, RepositoryError};
|
||||
|
||||
|
|
@ -852,20 +852,17 @@ 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()?;
|
||||
|
||||
Ok(crefs
|
||||
Ok(doc
|
||||
.canonical_refs()?
|
||||
.rules()
|
||||
.canonical(refname, self)
|
||||
.canonical(doc.default_branch()?, self)
|
||||
.ok_or(RepositoryError::MissingBranchRule)?
|
||||
.find_objects()?
|
||||
.quorum()?)
|
||||
.map(
|
||||
|Quorum {
|
||||
refname, object, ..
|
||||
}| (refname, object.id()),
|
||||
}| (refname.to_owned(), object.id()),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -939,32 +936,42 @@ 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()) {
|
||||
Ok(mut head_ref) => {
|
||||
if head_ref
|
||||
.symbolic_target()
|
||||
.is_some_and(|t| t != branch_ref.as_str())
|
||||
fn set_symbolic_ref<Name, Target>(
|
||||
&self,
|
||||
name: &Name,
|
||||
target: &Target,
|
||||
message: &str,
|
||||
) -> Result<(), RepositoryError>
|
||||
where
|
||||
Name: AsRef<RefStr>,
|
||||
Target: AsRef<RefStr>,
|
||||
{
|
||||
head_ref.symbolic_set_target(branch_ref.as_str(), "set-head (radicle)")?;
|
||||
let name = name.as_ref();
|
||||
let target = target.as_ref();
|
||||
match self.raw().find_reference(name.as_str()) {
|
||||
Ok(mut existing) => match existing.symbolic_target() {
|
||||
Some(current) if current == target.as_str() => {
|
||||
// Already points to the correct target, nothing to do.
|
||||
}
|
||||
Ok(())
|
||||
Some(_) => {
|
||||
// Symbolic ref pointing to a different target, update it.
|
||||
existing.symbolic_set_target(target.as_str(), message)?;
|
||||
}
|
||||
None => {
|
||||
// A direct (non-symbolic) ref exists where we expect a
|
||||
// symbolic one. Overwrite it with force.
|
||||
self.raw()
|
||||
.reference_symbolic(name.as_str(), target.as_str(), true, message)?;
|
||||
}
|
||||
},
|
||||
Err(err) if err.is_not_found() => {
|
||||
self.raw().reference_symbolic(
|
||||
head_ref.as_str(),
|
||||
branch_ref.as_str(),
|
||||
true,
|
||||
"set-head (radicle)",
|
||||
)?;
|
||||
self.raw()
|
||||
.reference_symbolic(name.as_str(), target.as_str(), true, message)?;
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_default_branch_to_canonical_head(&self) -> Result<SetHead, RepositoryError> {
|
||||
let (branch_ref, new) = self.canonical_head()?;
|
||||
|
|
|
|||
|
|
@ -59,7 +59,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()?;
|
||||
|
|
|
|||
|
|
@ -344,7 +344,16 @@ impl WriteRepository for MockRepository {
|
|||
todo!()
|
||||
}
|
||||
|
||||
fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> {
|
||||
fn set_symbolic_ref<Name, Target>(
|
||||
&self,
|
||||
_name: &Name,
|
||||
_target: &Target,
|
||||
_message: &str,
|
||||
) -> Result<(), RepositoryError>
|
||||
where
|
||||
Name: AsRef<fmt::RefStr>,
|
||||
Target: AsRef<fmt::RefStr>,
|
||||
{
|
||||
todo!()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue