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
|
environment variable `RAD_PASSPHRASE` (lower priority than the
|
||||||
credential). The identifier of the credential is
|
credential). The identifier of the credential is
|
||||||
"xyz.radicle.node.passphrase".
|
"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
|
## 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();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This test exercises a large surface of the "Canonical References" feature:
|
||||||
|
/// - Annotated Tags
|
||||||
|
/// - Branches
|
||||||
|
/// - Symbolic References
|
||||||
#[test]
|
#[test]
|
||||||
fn git_push_canonical_annotated_tags() {
|
fn git_push_canonical() {
|
||||||
let mut environment = Environment::new();
|
let mut environment = Environment::new();
|
||||||
let alice = environment.node("alice");
|
let alice = environment.node("alice");
|
||||||
let bob = environment.node("bob");
|
let bob = environment.node("bob");
|
||||||
|
|
@ -341,4 +345,40 @@ fn git_push_canonical_annotated_tags() {
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
.unwrap();
|
.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::git::Repository;
|
||||||
use radicle::storage::refs::RefsAt;
|
use radicle::storage::refs::RefsAt;
|
||||||
use radicle::storage::{
|
use radicle::storage::{
|
||||||
ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, RepositoryError,
|
ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, WriteRepository as _,
|
||||||
WriteRepository as _,
|
|
||||||
};
|
};
|
||||||
use radicle::{Storage, cob, git, node};
|
use radicle::{Storage, cob, git, node};
|
||||||
use radicle_fetch::git::refs::Applied;
|
use radicle_fetch::git::refs::Applied;
|
||||||
|
|
@ -128,15 +127,6 @@ impl Handle {
|
||||||
// points to a repository that is temporary and gets moved by [`mv`].
|
// points to a repository that is temporary and gets moved by [`mv`].
|
||||||
let repo = storage.repository(rid)?;
|
let repo = storage.repository(rid)?;
|
||||||
repo.set_identity_head()?;
|
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) {
|
let canonical = match set_canonical_refs(&repo, &applied) {
|
||||||
Ok(updates) => updates.unwrap_or_default(),
|
Ok(updates) => updates.unwrap_or_default(),
|
||||||
|
|
@ -346,8 +336,21 @@ fn set_canonical_refs(
|
||||||
repo: &Repository,
|
repo: &Repository,
|
||||||
applied: &Applied,
|
applied: &Applied,
|
||||||
) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> {
|
) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> {
|
||||||
|
const LOG_MESSAGE: &str = "set-canonical-reference from fetch (radicle)";
|
||||||
|
|
||||||
let identity = repo.identity()?;
|
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 mut updated_refs = UpdatedCanonicalRefs::default();
|
||||||
let refnames = applied
|
let refnames = applied
|
||||||
|
|
@ -389,12 +392,10 @@ fn set_canonical_refs(
|
||||||
refname, object, ..
|
refname, object, ..
|
||||||
}) => {
|
}) => {
|
||||||
let oid = object.id();
|
let oid = object.id();
|
||||||
if let Err(e) = repo.backend.reference(
|
if let Err(e) =
|
||||||
refname.clone().as_str(),
|
repo.backend
|
||||||
oid.into(),
|
.reference(refname.clone().as_str(), oid.into(), true, LOG_MESSAGE)
|
||||||
true,
|
{
|
||||||
"set-canonical-reference from fetch (radicle)",
|
|
||||||
) {
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
target: "worker",
|
target: "worker",
|
||||||
"Failed to set canonical reference {refname}->{oid}: {e}"
|
"Failed to set canonical reference {refname}->{oid}: {e}"
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,8 @@ pub(super) fn run(
|
||||||
git: &impl GitService,
|
git: &impl GitService,
|
||||||
node: &mut impl NodeSession,
|
node: &mut impl NodeSession,
|
||||||
) -> Result<Vec<String>, Error> {
|
) -> 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:
|
// 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.
|
// 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 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)> =
|
let mut set_canonical_refs: Vec<(git::fmt::Qualified, git::canonical::Object)> =
|
||||||
Vec::with_capacity(specs.len());
|
Vec::with_capacity(specs.len());
|
||||||
|
|
||||||
|
|
@ -407,6 +406,8 @@ pub(super) fn run(
|
||||||
if !ok.is_empty() {
|
if !ok.is_empty() {
|
||||||
let _ = stored.sign_refs(&signer)?;
|
let _ = stored.sign_refs(&signer)?;
|
||||||
|
|
||||||
|
stored.set_canonical_symbolic_refs(LOG_MESSAGE)?;
|
||||||
|
|
||||||
for (refname, object) in &set_canonical_refs {
|
for (refname, object) in &set_canonical_refs {
|
||||||
let oid = object.id();
|
let oid = object.id();
|
||||||
let kind = object.object_type();
|
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()) {
|
match stored.backend.refname_to_id(refname.as_str()) {
|
||||||
Ok(new) if oid != new => {
|
Ok(new) if oid != new => {
|
||||||
stored.backend.reference(
|
stored
|
||||||
refname.as_str(),
|
.backend
|
||||||
oid.into(),
|
.reference(refname.as_str(), oid.into(), true, LOG_MESSAGE)?;
|
||||||
true,
|
|
||||||
"set-canonical-reference from git-push (radicle)",
|
|
||||||
)?;
|
|
||||||
print_update();
|
print_update();
|
||||||
}
|
}
|
||||||
Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
|
Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ mod voting;
|
||||||
pub mod effects;
|
pub mod effects;
|
||||||
pub mod protect;
|
pub mod protect;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
|
pub mod symbolic;
|
||||||
|
|
||||||
pub use rules::{MatchedRule, RawRule, Rules, ValidRule};
|
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.
|
/// 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)]
|
#[repr(transparent)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub(super) struct Unprotected<T: RefLike>(T);
|
pub(super) struct Unprotected<T: RefLike>(T);
|
||||||
|
|
@ -42,6 +42,13 @@ impl<T: RefLike> Unprotected<T> {
|
||||||
pub fn into_inner(self) -> T {
|
pub fn into_inner(self) -> T {
|
||||||
self.0
|
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> {
|
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> {
|
impl<'de, T: RefLike + serde::Deserialize<'de>> serde::Deserialize<'de> for Unprotected<T> {
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
where
|
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`]
|
/// For types that are commonly used in conjunction with [`Unprotected`]
|
||||||
/// have some `impl`s and companion infallible injections.
|
/// have some `impl`s and companion infallible injections.
|
||||||
mod impls {
|
mod impls {
|
||||||
use crate::git::fmt::{RefString, refspec::QualifiedPattern};
|
use crate::git::{
|
||||||
|
fmt::{Qualified, RefStr, RefString, refname, refspec::QualifiedPattern},
|
||||||
|
refs::branch,
|
||||||
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
@ -76,6 +100,33 @@ mod impls {
|
||||||
/// means to be [`RefLike`].
|
/// means to be [`RefLike`].
|
||||||
impl RefLike for RefString {}
|
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
|
/// A [`QualifiedPattern`] is [`RefLike`] in the sense that it matches a
|
||||||
/// (possibly infinite) set of [`crate::git::Qualified`].
|
/// (possibly infinite) set of [`crate::git::Qualified`].
|
||||||
impl RefLike for QualifiedPattern<'_> {}
|
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`].
|
/// A marker `enum` that is used in a [`ValidRule`].
|
||||||
///
|
///
|
||||||
/// It ensures that a rule that has been deserialized, resolving the `delegates`
|
/// 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 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};
|
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.
|
/// Configuration for canonical references and their rules.
|
||||||
///
|
///
|
||||||
/// `RawCanonicalRefs` are verified into [`CanonicalRefs`].
|
/// [`RawCanonicalRefs`] are verified into [`CanonicalRefs`].
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RawCanonicalRefs {
|
pub struct RawCanonicalRefs {
|
||||||
rules: RawRules,
|
rules: RawRules,
|
||||||
|
|
||||||
|
#[serde(default)] // Default to empty for backwards compatibility.
|
||||||
|
symbolic: SymbolicRefs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RawCanonicalRefs {
|
impl RawCanonicalRefs {
|
||||||
/// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`].
|
/// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`].
|
||||||
pub fn new(rules: RawRules) -> Self {
|
pub fn new(rules: RawRules, symbolic: SymbolicRefs) -> Self {
|
||||||
Self { rules }
|
Self { rules, symbolic }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the [`RawRules`].
|
/// Return the [`RawRules`].
|
||||||
|
|
@ -24,6 +50,21 @@ impl RawCanonicalRefs {
|
||||||
&self.rules
|
&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`].
|
/// Validate the [`RawCanonicalRefs`] into a set of [`CanonicalRefs`].
|
||||||
pub fn try_into_canonical_refs<R>(
|
pub fn try_into_canonical_refs<R>(
|
||||||
self,
|
self,
|
||||||
|
|
@ -33,7 +74,7 @@ impl RawCanonicalRefs {
|
||||||
R: Fn() -> Delegates,
|
R: Fn() -> Delegates,
|
||||||
{
|
{
|
||||||
let rules = Rules::from_raw(self.rules, resolve)?;
|
let rules = Rules::from_raw(self.rules, resolve)?;
|
||||||
Ok(CanonicalRefs::new(rules))
|
CanonicalRefs::new(rules, self.symbolic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,22 +82,62 @@ impl RawCanonicalRefs {
|
||||||
///
|
///
|
||||||
/// [`CanonicalRefs`] can be converted into a [`Payload`] using its [`From`]
|
/// [`CanonicalRefs`] can be converted into a [`Payload`] using its [`From`]
|
||||||
/// implementation.
|
/// implementation.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CanonicalRefs {
|
pub struct CanonicalRefs {
|
||||||
rules: Rules,
|
rules: Rules,
|
||||||
|
|
||||||
|
#[serde(default, skip_serializing_if = "SymbolicRefs::is_empty")]
|
||||||
|
symbolic: SymbolicRefs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CanonicalRefs {
|
impl CanonicalRefs {
|
||||||
/// Construct a new [`CanonicalRefs`] from a set of [`Rules`].
|
/// Construct a new [`CanonicalRefs`] from a set of [`Rules`] and
|
||||||
pub fn new(rules: Rules) -> Self {
|
/// [`SymbolicRefs`], validating that these may be evaluated to a well
|
||||||
CanonicalRefs { rules }
|
/// 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`].
|
/// Return the [`Rules`].
|
||||||
pub fn rules(&self) -> &Rules {
|
pub fn rules(&self) -> &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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|
@ -74,3 +155,120 @@ impl TryFrom<CanonicalRefs> for Payload {
|
||||||
Ok(Self::from(value))
|
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::crypto::Signature;
|
||||||
use crate::git;
|
use crate::git;
|
||||||
use crate::git::canonical::rules;
|
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::git::raw::ErrorExt as _;
|
||||||
|
use crate::identity::crefs;
|
||||||
use crate::identity::{Did, project::Project};
|
use crate::identity::{Did, project::Project};
|
||||||
use crate::node::device::Device;
|
use crate::node::device::Device;
|
||||||
use crate::storage;
|
use crate::storage;
|
||||||
|
|
@ -75,9 +79,20 @@ impl DocError {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum DefaultBranchRuleError {
|
pub enum DefaultBranchError {
|
||||||
#[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
|
#[error(
|
||||||
Payload(#[from] PayloadError),
|
"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.
|
/// The version number of the identity document.
|
||||||
|
|
@ -757,42 +772,133 @@ impl Doc {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the qualified reference name of the default branch,
|
/// Gets the qualified reference name of the default branch,
|
||||||
/// according to the project payload in this document.
|
/// according to payloads `xyz.radicle.project` and `xyz.radicle.crefs`
|
||||||
pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, PayloadError> {
|
/// in this document.
|
||||||
Ok(git::refs::branch(self.project()?.default_branch()))
|
pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, DefaultBranchError> {
|
||||||
}
|
let crefs = self.canonical_refs()?;
|
||||||
|
let qualified = crefs
|
||||||
pub fn default_branch_rule(&self) -> Result<rules::Rules, DefaultBranchRuleError> {
|
.symbolic()
|
||||||
let pattern = git::fmt::refspec::QualifiedPattern::from(git::refs::branch(
|
.resolve_head()
|
||||||
self.project()?.default_branch(),
|
.ok_or(DefaultBranchError::MissingHead)?;
|
||||||
));
|
Ok(qualified.to_owned())
|
||||||
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.
|
/// Construct the canonical references for this document.
|
||||||
/// The implementation of [`RawCanonicalRefs`] is used to
|
///
|
||||||
/// obtain the payload identified by [`PayloadId::canonical_refs`], if it
|
/// Uses the `xyz.radicle.crefs` payload (if present) and the
|
||||||
/// exists.
|
/// `xyz.radicle.project` payload to determine the `HEAD` symbolic
|
||||||
/// The resulting [`CanonicalRefs`] are constructed by extension with
|
/// reference and its associated rule.
|
||||||
/// [`Self::default_branch_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
|
/// [`RawCanonicalRefs`]: super::crefs::RawCanonicalRefs
|
||||||
pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
|
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();
|
// Determine where `HEAD` comes from. The `resolve_head()` result
|
||||||
raw_rules.extend(rules::RawRules::from(self.default_branch_rule()?));
|
// 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);
|
match (head, self.project()) {
|
||||||
Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?)
|
(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.
|
/// 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)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RawCanonicalRefsError {
|
pub enum RawCanonicalRefsError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Json(#[from] serde_json::Error),
|
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 {
|
pub trait GetRawCanonicalRefs: GetPayload {
|
||||||
/// Retrieve the [`RawCanonicalRefs`] by deserializing from the payload
|
/// Retrieve the [`RawCanonicalRefs`] by deserializing from the payload
|
||||||
/// (if present).
|
/// (if present).
|
||||||
|
|
@ -1232,4 +1368,72 @@ mod test {
|
||||||
serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] })
|
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
|
// If we have both payloads `xyz.radicle.{project,crefs}` ensure that,
|
||||||
// constructing the canonical reference rules.
|
// 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 _;
|
use super::GetRawCanonicalRefs as _;
|
||||||
match raw
|
match raw
|
||||||
.raw_canonical_refs()
|
.raw_canonical_refs()
|
||||||
|
|
@ -225,11 +229,19 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
|
||||||
.map(|(pattern, _)| pattern.to_string())
|
.map(|(pattern, _)| pattern.to_string())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if !matches.is_empty() {
|
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 */ }
|
_ => { /* we validate below */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that the canonical references payload is valid
|
// Verify that the canonical references payload is valid
|
||||||
if let Err(e) = proposal.canonical_refs() {
|
if let Err(e) = proposal.canonical_refs() {
|
||||||
return Err(error::DocVerification::PayloadError {
|
return Err(error::DocVerification::PayloadError {
|
||||||
|
|
@ -332,7 +344,7 @@ mod test {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
super::verify(raw),
|
super::verify(raw),
|
||||||
Err(error::DocVerification::DisallowDefault { .. })
|
Err(error::DocVerification::DisallowDefaultBranchRule { .. })
|
||||||
),
|
),
|
||||||
"Verification should be rejected for including default branch rule"
|
"Verification should be rejected for including default branch rule"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,17 @@ pub enum DocVerification {
|
||||||
#[error(
|
#[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)."
|
"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>,
|
matches: Vec<String>,
|
||||||
default: git::fmt::Qualified<'static>,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use thiserror::Error;
|
||||||
|
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::git::BranchName;
|
use crate::git::BranchName;
|
||||||
|
use crate::git::{fmt::Qualified, refs::branch};
|
||||||
use crate::identity::doc;
|
use crate::identity::doc;
|
||||||
use crate::identity::doc::Payload;
|
use crate::identity::doc::Payload;
|
||||||
|
|
||||||
|
|
@ -254,6 +255,12 @@ impl Project {
|
||||||
pub fn default_branch(&self) -> &BranchName {
|
pub fn default_branch(&self) -> &BranchName {
|
||||||
&self.default_branch
|
&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 {
|
impl From<Project> for Payload {
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ where
|
||||||
)?;
|
)?;
|
||||||
stored.set_remote_identity_root_to(pk, identity)?;
|
stored.set_remote_identity_root_to(pk, identity)?;
|
||||||
stored.set_identity_head_to(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()?;
|
stored.set_default_branch_to_canonical_head()?;
|
||||||
|
|
||||||
let signed = stored.sign_refs(signer)?;
|
let signed = stored.sign_refs(signer)?;
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ pub enum RepositoryError {
|
||||||
#[error("missing canonical reference rule for default branch")]
|
#[error("missing canonical reference rule for default branch")]
|
||||||
MissingBranchRule,
|
MissingBranchRule,
|
||||||
#[error("could not get the default branch rule: {0}")]
|
#[error("could not get the default branch rule: {0}")]
|
||||||
DefaultBranchRule(#[from] doc::DefaultBranchRuleError),
|
DefaultBranchRule(#[from] doc::DefaultBranchError),
|
||||||
#[error("failed to get canonical reference rules: {0}")]
|
#[error("failed to get canonical reference rules: {0}")]
|
||||||
CanonicalRefs(#[from] doc::CanonicalRefsError),
|
CanonicalRefs(#[from] doc::CanonicalRefsError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
|
|
@ -668,11 +668,30 @@ where
|
||||||
|
|
||||||
/// Allows read-write access to a repository.
|
/// Allows read-write access to a repository.
|
||||||
pub trait WriteRepository: ReadRepository + SignRepository {
|
pub trait WriteRepository: ReadRepository + SignRepository {
|
||||||
/// Sets the symbolic reference `HEAD` to target the default branch.
|
/// Sets the canonical symbolic references.
|
||||||
/// This only depends on the value for the default branch in the identity
|
///
|
||||||
/// document, and does not require the canonical reference behind the
|
/// This only depends on canonical references (thus the `xyz.radicle.crefs`
|
||||||
/// default branch to be computed, or even exist.
|
/// payload, and possibly the `xyz.radicle.project` payload in the identity
|
||||||
fn set_head_to_default_branch(&self) -> Result<(), RepositoryError>;
|
/// 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,
|
/// Computes the head of the default branch based on the delegate set,
|
||||||
/// and sets it.
|
/// and sets it.
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ use crate::{git, git::Oid, node};
|
||||||
use crate::git::RefError;
|
use crate::git::RefError;
|
||||||
use crate::git::UserInfo;
|
use crate::git::UserInfo;
|
||||||
use crate::git::fmt::{
|
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};
|
pub use crate::storage::{Error, RepositoryError};
|
||||||
|
|
||||||
|
|
@ -852,20 +852,17 @@ impl ReadRepository for Repository {
|
||||||
|
|
||||||
fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
|
fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
|
||||||
let doc = self.identity_doc()?;
|
let doc = self.identity_doc()?;
|
||||||
let refname = doc.default_branch()?.to_owned();
|
Ok(doc
|
||||||
|
.canonical_refs()?
|
||||||
let crefs = doc.canonical_refs()?;
|
|
||||||
|
|
||||||
Ok(crefs
|
|
||||||
.rules()
|
.rules()
|
||||||
.canonical(refname, self)
|
.canonical(doc.default_branch()?, self)
|
||||||
.ok_or(RepositoryError::MissingBranchRule)?
|
.ok_or(RepositoryError::MissingBranchRule)?
|
||||||
.find_objects()?
|
.find_objects()?
|
||||||
.quorum()?)
|
.quorum()?)
|
||||||
.map(
|
.map(
|
||||||
|Quorum {
|
|Quorum {
|
||||||
refname, object, ..
|
refname, object, ..
|
||||||
}| (refname, object.id()),
|
}| (refname.to_owned(), object.id()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -939,32 +936,42 @@ impl ReadRepository for Repository {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WriteRepository for Repository {
|
impl WriteRepository for Repository {
|
||||||
fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> {
|
fn set_symbolic_ref<Name, Target>(
|
||||||
let head_ref = refname!("HEAD");
|
&self,
|
||||||
let branch_ref = self.default_branch()?;
|
name: &Name,
|
||||||
|
target: &Target,
|
||||||
match self.raw().find_reference(head_ref.as_str()) {
|
message: &str,
|
||||||
Ok(mut head_ref) => {
|
) -> Result<(), RepositoryError>
|
||||||
if head_ref
|
where
|
||||||
.symbolic_target()
|
Name: AsRef<RefStr>,
|
||||||
.is_some_and(|t| t != branch_ref.as_str())
|
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() => {
|
Err(err) if err.is_not_found() => {
|
||||||
self.raw().reference_symbolic(
|
self.raw()
|
||||||
head_ref.as_str(),
|
.reference_symbolic(name.as_str(), target.as_str(), true, message)?;
|
||||||
branch_ref.as_str(),
|
}
|
||||||
true,
|
Err(err) => return Err(err.into()),
|
||||||
"set-head (radicle)",
|
}
|
||||||
)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(err) => Err(err.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_default_branch_to_canonical_head(&self) -> Result<SetHead, RepositoryError> {
|
fn set_default_branch_to_canonical_head(&self) -> Result<SetHead, RepositoryError> {
|
||||||
let (branch_ref, new) = self.canonical_head()?;
|
let (branch_ref, new) = self.canonical_head()?;
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ pub fn fetch<W: WriteRepository>(
|
||||||
drop(opts);
|
drop(opts);
|
||||||
|
|
||||||
repo.set_identity_head()?;
|
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()?;
|
repo.set_default_branch_to_canonical_head()?;
|
||||||
|
|
||||||
let validations = repo.validate()?;
|
let validations = repo.validate()?;
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,16 @@ impl WriteRepository for MockRepository {
|
||||||
todo!()
|
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!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue