fetch: Ignore sigrefs that are behind
Previously, if a delegate's namespace was fetched and its rad/sigrefs was behind the current state, then it would fail to perform the fetch. Instead, when the ancestry path is checked the outcome can be used during the update: 1. If the tip is equal, then the update can be marked as skipped. 2. If the tip is behind, then the update can be rejected, iff the policy does not allow it. 3. If the tip is ahead, then the update is a fast-forward. 4. If the tip is diverged, then the update results in a failure, iff the policy does not allow it. The protocol also prunes any delegates's updates when the `rad/sigrefs` is behind and returns an error if they are diverged. For any non-delegates, it will simply prune them. Signed-off-by: Fintan Halpenny <fintan.halpenny@gmail.com>
This commit is contained in:
parent
9b7c48ab40
commit
703a6d8025
|
|
@ -1,15 +1,36 @@
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
use either::{
|
use either::Either;
|
||||||
Either,
|
|
||||||
Either::{Left, Right},
|
|
||||||
};
|
|
||||||
use radicle::git::{Namespaced, Oid, Qualified};
|
use radicle::git::{Namespaced, Oid, Qualified};
|
||||||
use radicle::storage::git::Repository;
|
use radicle::storage::git::Repository;
|
||||||
use radicle::storage::ReadRepository;
|
|
||||||
|
|
||||||
use super::refs::{Applied, Policy, RefUpdate, Update};
|
use super::refs::{Applied, Policy, RefUpdate, Update};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Ancestry {
|
||||||
|
Equal,
|
||||||
|
Ahead,
|
||||||
|
Behind,
|
||||||
|
Diverged,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Updated<'a> {
|
||||||
|
Accepted(RefUpdate),
|
||||||
|
Rejected(Update<'a>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<RefUpdate> for Updated<'a> {
|
||||||
|
fn from(up: RefUpdate) -> Self {
|
||||||
|
Updated::Accepted(up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<Update<'a>> for Updated<'a> {
|
||||||
|
fn from(up: Update<'a>) -> Self {
|
||||||
|
Updated::Rejected(up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contains(repo: &Repository, oid: Oid) -> Result<bool, error::Contains> {
|
pub fn contains(repo: &Repository, oid: Oid) -> Result<bool, error::Contains> {
|
||||||
repo.backend
|
repo.backend
|
||||||
.odb()
|
.odb()
|
||||||
|
|
@ -17,17 +38,27 @@ pub fn contains(repo: &Repository, oid: Oid) -> Result<bool, error::Contains> {
|
||||||
.map_err(error::Contains)
|
.map_err(error::Contains)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_in_ancestry_path(repo: &Repository, old: Oid, new: Oid) -> Result<bool, error::Ancestry> {
|
pub fn ancestry(repo: &Repository, old: Oid, new: Oid) -> Result<Ancestry, error::Ancestry> {
|
||||||
if !contains(repo, old)? || !contains(repo, new)? {
|
if !contains(repo, old)? || !contains(repo, new)? {
|
||||||
return Ok(false);
|
return Err(error::Ancestry::Missing { a: old, b: new });
|
||||||
}
|
}
|
||||||
|
|
||||||
if old == new {
|
if old == new {
|
||||||
return Ok(true);
|
return Ok(Ancestry::Equal);
|
||||||
}
|
}
|
||||||
|
|
||||||
repo.is_ancestor_of(old, new)
|
let (ahead, behind) = repo
|
||||||
.map_err(|err| error::Ancestry::Check { old, new, err })
|
.backend
|
||||||
|
.graph_ahead_behind(new.into(), old.into())
|
||||||
|
.map_err(|err| error::Ancestry::Check { old, new, err })?;
|
||||||
|
|
||||||
|
if ahead > 0 && behind == 0 {
|
||||||
|
Ok(Ancestry::Ahead)
|
||||||
|
} else if ahead == 0 && behind > 0 {
|
||||||
|
Ok(Ancestry::Behind)
|
||||||
|
} else {
|
||||||
|
Ok(Ancestry::Diverged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refname_to_id<'a, N>(repo: &Repository, refname: N) -> Result<Option<Oid>, error::Resolve>
|
pub fn refname_to_id<'a, N>(repo: &Repository, refname: N) -> Result<Option<Oid>, error::Resolve>
|
||||||
|
|
@ -59,12 +90,12 @@ where
|
||||||
target,
|
target,
|
||||||
no_ff,
|
no_ff,
|
||||||
} => match direct(repo, name, target, no_ff)? {
|
} => match direct(repo, name, target, no_ff)? {
|
||||||
Left(r) => applied.rejected.push(r),
|
Updated::Rejected(r) => applied.rejected.push(r),
|
||||||
Right(u) => applied.updated.push(u),
|
Updated::Accepted(u) => applied.updated.push(u),
|
||||||
},
|
},
|
||||||
Update::Prune { name, prev } => match prune(repo, name, prev)? {
|
Update::Prune { name, prev } => match prune(repo, name, prev)? {
|
||||||
Left(r) => applied.rejected.push(r),
|
Updated::Rejected(r) => applied.rejected.push(r),
|
||||||
Right(u) => applied.updated.push(u),
|
Updated::Accepted(u) => applied.updated.push(u),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -77,49 +108,62 @@ fn direct<'a>(
|
||||||
name: Namespaced<'a>,
|
name: Namespaced<'a>,
|
||||||
target: Oid,
|
target: Oid,
|
||||||
no_ff: Policy,
|
no_ff: Policy,
|
||||||
) -> Result<Either<Update<'a>, RefUpdate>, error::Update> {
|
) -> Result<Updated<'a>, error::Update> {
|
||||||
let tip = refname_to_id(repo, name.clone())?;
|
let tip = refname_to_id(repo, name.clone())?;
|
||||||
match tip {
|
match tip {
|
||||||
Some(prev) => {
|
Some(prev) => {
|
||||||
let is_ff = is_in_ancestry_path(repo, prev, target)?;
|
let ancestry = ancestry(repo, prev, target)?;
|
||||||
if !is_ff {
|
|
||||||
match no_ff {
|
match ancestry {
|
||||||
Policy::Abort => {
|
Ancestry::Equal => Ok(RefUpdate::Skipped {
|
||||||
return Err(error::Update::NonFF {
|
name: name.to_ref_string(),
|
||||||
name: name.to_owned(),
|
oid: target,
|
||||||
new: target,
|
|
||||||
cur: prev,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Policy::Reject => Ok(Left(Update::Direct {
|
|
||||||
name,
|
|
||||||
target,
|
|
||||||
no_ff,
|
|
||||||
})),
|
|
||||||
Policy::Allow => {
|
|
||||||
// N.b. the update is a non-fast-forward but
|
|
||||||
// we allow it, so we pass `force: true`.
|
|
||||||
repo.backend
|
|
||||||
.reference(name.as_ref(), target.into(), true, "radicle: update")
|
|
||||||
.map_err(|err| error::Update::Create {
|
|
||||||
name: name.to_owned(),
|
|
||||||
target,
|
|
||||||
err,
|
|
||||||
})?;
|
|
||||||
Ok(Right(RefUpdate::from(name.to_ref_string(), prev, target)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
.into()),
|
||||||
// N.b. the update is a fast-forward so we can safely
|
Ancestry::Ahead => {
|
||||||
// pass `force: true`.
|
// N.b. the update is a fast-forward so we can safely
|
||||||
repo.backend
|
// pass `force: true`.
|
||||||
.reference(name.as_ref(), target.into(), true, "radicle: update")
|
repo.backend
|
||||||
.map_err(|err| error::Update::Create {
|
.reference(name.as_ref(), target.into(), true, "radicle: update")
|
||||||
|
.map_err(|err| error::Update::Create {
|
||||||
|
name: name.to_owned(),
|
||||||
|
target,
|
||||||
|
err,
|
||||||
|
})?;
|
||||||
|
Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
|
||||||
|
}
|
||||||
|
Ancestry::Behind | Ancestry::Diverged if matches!(no_ff, Policy::Allow) => {
|
||||||
|
// N.b. the update is a non-fast-forward but
|
||||||
|
// we allow it, so we pass `force: true`.
|
||||||
|
repo.backend
|
||||||
|
.reference(name.as_ref(), target.into(), true, "radicle: update")
|
||||||
|
.map_err(|err| error::Update::Create {
|
||||||
|
name: name.to_owned(),
|
||||||
|
target,
|
||||||
|
err,
|
||||||
|
})?;
|
||||||
|
Ok(RefUpdate::from(name.to_ref_string(), prev, target).into())
|
||||||
|
}
|
||||||
|
// N.b. if the target is behind, we simply reject the update
|
||||||
|
Ancestry::Behind => Ok(Update::Direct {
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
no_ff,
|
||||||
|
}
|
||||||
|
.into()),
|
||||||
|
Ancestry::Diverged if matches!(no_ff, Policy::Reject) => Ok(Update::Direct {
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
no_ff,
|
||||||
|
}
|
||||||
|
.into()),
|
||||||
|
Ancestry::Diverged => {
|
||||||
|
return Err(error::Update::NonFF {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
target,
|
new: target,
|
||||||
err,
|
cur: prev,
|
||||||
})?;
|
})
|
||||||
Ok(Right(RefUpdate::from(name.to_ref_string(), prev, target)))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|
@ -132,10 +176,11 @@ fn direct<'a>(
|
||||||
target,
|
target,
|
||||||
err,
|
err,
|
||||||
})?;
|
})?;
|
||||||
Ok(Right(RefUpdate::Created {
|
Ok(RefUpdate::Created {
|
||||||
name: name.to_ref_string(),
|
name: name.to_ref_string(),
|
||||||
oid: target,
|
oid: target,
|
||||||
}))
|
}
|
||||||
|
.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +189,7 @@ fn prune<'a>(
|
||||||
repo: &Repository,
|
repo: &Repository,
|
||||||
name: Namespaced<'a>,
|
name: Namespaced<'a>,
|
||||||
prev: Either<Oid, Qualified<'a>>,
|
prev: Either<Oid, Qualified<'a>>,
|
||||||
) -> Result<Either<Update<'a>, RefUpdate>, error::Update> {
|
) -> Result<Updated<'a>, error::Update> {
|
||||||
use radicle::git::raw::ObjectType;
|
use radicle::git::raw::ObjectType;
|
||||||
|
|
||||||
match find(repo, &name)? {
|
match find(repo, &name)? {
|
||||||
|
|
@ -160,12 +205,13 @@ fn prune<'a>(
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
err,
|
err,
|
||||||
})?;
|
})?;
|
||||||
Ok(Right(RefUpdate::Deleted {
|
Ok(RefUpdate::Deleted {
|
||||||
name: name.to_ref_string(),
|
name: name.to_ref_string(),
|
||||||
oid: prev,
|
oid: prev,
|
||||||
}))
|
}
|
||||||
|
.into())
|
||||||
}
|
}
|
||||||
None => Ok(Left(Update::Prune { name, prev })),
|
None => Ok(Update::Prune { name, prev }.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ pub struct Contains(#[source] pub raw::Error);
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum Ancestry {
|
pub enum Ancestry {
|
||||||
|
#[error("missing one of {a} or {b} while checking ancestry")]
|
||||||
|
Missing { a: Oid, b: Oid },
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Contains(#[from] Contains),
|
Contains(#[from] Contains),
|
||||||
#[error("failed to check ancestry for {old} and {new}")]
|
#[error("failed to check ancestry for {old} and {new}")]
|
||||||
|
|
@ -14,7 +16,7 @@ pub enum Ancestry {
|
||||||
old: Oid,
|
old: Oid,
|
||||||
new: Oid,
|
new: Oid,
|
||||||
#[source]
|
#[source]
|
||||||
err: ext::Error,
|
err: raw::Error,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ pub const DEFAULT_FETCH_DATA_REFS_LIMIT: u64 = 1024 * 1024 * 1024 * 5;
|
||||||
pub mod error {
|
pub mod error {
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
|
use radicle::git::Oid;
|
||||||
|
use radicle::prelude::PublicKey;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{git, git::repository, handle, sigrefs, stage};
|
use crate::{git, git::repository, handle, sigrefs, stage};
|
||||||
|
|
@ -47,8 +49,16 @@ pub mod error {
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum Protocol {
|
pub enum Protocol {
|
||||||
|
#[error(transparent)]
|
||||||
|
Ancestry(#[from] repository::error::Ancestry),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Canonical(#[from] Canonical),
|
Canonical(#[from] Canonical),
|
||||||
|
#[error("delegate '{remote}' has diverged 'rad/sigrefs': {current} -> {received}")]
|
||||||
|
Diverged {
|
||||||
|
remote: PublicKey,
|
||||||
|
current: Oid,
|
||||||
|
received: Oid,
|
||||||
|
},
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error("canonical 'refs/rad/id' is missing")]
|
#[error("canonical 'refs/rad/id' is missing")]
|
||||||
|
|
@ -371,6 +381,20 @@ impl FetchState {
|
||||||
remote,
|
remote,
|
||||||
data: Some(sigrefs),
|
data: Some(sigrefs),
|
||||||
} => {
|
} => {
|
||||||
|
if let Some(SignedRefsAt { at, .. }) = SignedRefsAt::load(remote, &handle.repo)?
|
||||||
|
{
|
||||||
|
// Prune non-delegates if they're behind or
|
||||||
|
// diverged. A diverged case is non-fatal for
|
||||||
|
// delegates.
|
||||||
|
if matches!(
|
||||||
|
repository::ancestry(&handle.repo, at, sigrefs.at)?,
|
||||||
|
repository::Ancestry::Behind | repository::Ancestry::Diverged
|
||||||
|
) {
|
||||||
|
self.prune(&remote);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let cache = self.as_cached(handle);
|
let cache = self.as_cached(handle);
|
||||||
if let Some(warns) = sigrefs::validate(&cache, sigrefs)?.as_mut() {
|
if let Some(warns) = sigrefs::validate(&cache, sigrefs)?.as_mut() {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
|
|
@ -385,6 +409,21 @@ impl FetchState {
|
||||||
remote,
|
remote,
|
||||||
data: Some(sigrefs),
|
data: Some(sigrefs),
|
||||||
} => {
|
} => {
|
||||||
|
if let Some(SignedRefsAt { at, .. }) = SignedRefsAt::load(remote, &handle.repo)?
|
||||||
|
{
|
||||||
|
let ancestry = repository::ancestry(&handle.repo, at, sigrefs.at)?;
|
||||||
|
if matches!(ancestry, repository::Ancestry::Behind) {
|
||||||
|
self.prune(&remote);
|
||||||
|
continue;
|
||||||
|
} else if matches!(ancestry, repository::Ancestry::Diverged) {
|
||||||
|
return Err(error::Protocol::Diverged {
|
||||||
|
remote,
|
||||||
|
current: at,
|
||||||
|
received: sigrefs.at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let cache = self.as_cached(handle);
|
let cache = self.as_cached(handle);
|
||||||
if let Some(fails) = sigrefs::validate(&cache, sigrefs)?.as_mut() {
|
if let Some(fails) = sigrefs::validate(&cache, sigrefs)?.as_mut() {
|
||||||
log::warn!(target: "fetch", "Pruning delegate {remote} tips, due to validation failures");
|
log::warn!(target: "fetch", "Pruning delegate {remote} tips, due to validation failures");
|
||||||
|
|
|
||||||
|
|
@ -855,7 +855,7 @@ fn test_non_fastforward_sigrefs() {
|
||||||
// Eve has old refs.
|
// Eve has old refs.
|
||||||
assert_matches!(
|
assert_matches!(
|
||||||
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap(),
|
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap(),
|
||||||
FetchResult::Failed { .. }
|
FetchResult::Success { updated, .. } if updated.is_empty()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -949,3 +949,93 @@ fn test_outdated_sigrefs() {
|
||||||
assert_ne!(eves_refs, old_refs);
|
assert_ne!(eves_refs, old_refs);
|
||||||
assert_eq!(eves_refs_expected, eves_refs);
|
assert_eq!(eves_refs_expected, eves_refs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_outdated_delegate_sigrefs() {
|
||||||
|
logger::init(log::Level::Debug);
|
||||||
|
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
|
||||||
|
let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
|
||||||
|
let eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
|
||||||
|
|
||||||
|
let rid = alice.project("acme", "");
|
||||||
|
|
||||||
|
let mut alice = alice.spawn();
|
||||||
|
let mut bob = bob.spawn();
|
||||||
|
let mut eve = eve.spawn();
|
||||||
|
|
||||||
|
bob.handle.track_repo(rid, Scope::All).unwrap();
|
||||||
|
eve.handle.track_repo(rid, Scope::All).unwrap();
|
||||||
|
alice.connect(&bob);
|
||||||
|
bob.connect(&eve);
|
||||||
|
eve.connect(&alice);
|
||||||
|
converge([&alice, &bob, &eve]);
|
||||||
|
|
||||||
|
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
|
||||||
|
assert!(bob.storage.contains(&rid).unwrap());
|
||||||
|
rad::fork(rid, &bob.signer, &bob.storage).unwrap();
|
||||||
|
|
||||||
|
eve.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
|
||||||
|
assert!(eve.storage.contains(&rid).unwrap());
|
||||||
|
rad::fork(rid, &eve.signer, &eve.storage).unwrap();
|
||||||
|
|
||||||
|
alice
|
||||||
|
.handle
|
||||||
|
.track_node(eve.id, Some(Alias::new("eve")))
|
||||||
|
.unwrap();
|
||||||
|
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap();
|
||||||
|
let repo = alice.storage.repository(rid).unwrap();
|
||||||
|
assert!(repo.remote(&eve.id).is_ok());
|
||||||
|
|
||||||
|
log::debug!(target: "test", "Bob fetches from Eve..");
|
||||||
|
assert_matches!(
|
||||||
|
bob.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap(),
|
||||||
|
FetchResult::Success { .. }
|
||||||
|
);
|
||||||
|
let repo = bob.storage.repository(rid).unwrap();
|
||||||
|
let alice_remote = repo.remote(&alice.id).unwrap();
|
||||||
|
let old_refs = alice_remote.refs;
|
||||||
|
|
||||||
|
// At this stage, Alice and Bob have Eve's fork and Eve does not
|
||||||
|
// have Bob's fork
|
||||||
|
|
||||||
|
alice.issue(
|
||||||
|
rid,
|
||||||
|
"Outdated Sigrefs",
|
||||||
|
"Outdated sigrefs are harshing my vibes",
|
||||||
|
);
|
||||||
|
let repo = alice.storage.repository(rid).unwrap();
|
||||||
|
let alice_refs = repo.remote(&alice.id).unwrap().refs;
|
||||||
|
|
||||||
|
// Get the current state of eve's refs in alice's storage
|
||||||
|
log::debug!(target: "test", "Alice fetches from Eve..");
|
||||||
|
assert_matches!(
|
||||||
|
eve.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap(),
|
||||||
|
FetchResult::Success { .. }
|
||||||
|
);
|
||||||
|
let repo = eve.storage.repository(rid).unwrap();
|
||||||
|
let alice_remote = repo.remote(&alice.id).unwrap();
|
||||||
|
let alice_refs_expected = alice_remote.refs;
|
||||||
|
assert_ne!(alice_refs_expected, old_refs);
|
||||||
|
assert_eq!(alice_refs_expected, alice_refs);
|
||||||
|
|
||||||
|
log::debug!(target: "test", "Alice fetches from Bob..");
|
||||||
|
|
||||||
|
eve.handle
|
||||||
|
.track_node(bob.id, Some(Alias::new("bob")))
|
||||||
|
.unwrap();
|
||||||
|
assert_matches!(
|
||||||
|
eve.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap(),
|
||||||
|
FetchResult::Success { .. }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure that Eve's refs have not changed after fetching the old refs from Bob.
|
||||||
|
let repo = eve.storage.repository(rid).unwrap();
|
||||||
|
let alice_remote = repo.remote(&alice.id).unwrap();
|
||||||
|
let alice_refs = alice_remote.refs;
|
||||||
|
|
||||||
|
assert_ne!(alice_refs, old_refs);
|
||||||
|
assert_eq!(alice_refs_expected, alice_refs);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue