fetch: Allow upgrading Signed References

After feature levels are introduced to Signed References, and downgrade
attack protections are implemented, new errors are encountered when
reading Signed References that are in a downgraded state.

Such downgrade errors are recoverable by advancing history in a way that
restores the feature level that the history requires.

However, the implementation in `radicle-fetch` must be adjusted to not
abort fetching on downgraded histories, but rather inspect the error in
this case and decide whether continuing the fetching process would
actually help recover from the downgraded state.

The two cases are treated slightly differently for non-delegates and
delegates. Since delegates are crucial for evaluating repository
identity, also the fact that they cannot recover from a downgraded state
is logged at the "info" level.
This commit is contained in:
Lorenz Leutgeb 2026-03-24 00:48:37 +01:00 committed by Fintan Halpenny
parent e245e3115b
commit 81ca4b9eda
2 changed files with 72 additions and 33 deletions

View File

@ -527,18 +527,38 @@ impl FetchState {
self.prune(&remote);
}
(Ok(Some(refs)), false) => {
if let Some(SignedRefsAt { at, .. }) =
SignedRefsAt::load(remote, handle.repository())?
{
// Prune non-delegates if they're behind or
// diverged. A diverged case is non-fatal for
// delegates.
if matches!(
repository::ancestry(handle.repository(), at, refs.at)?,
repository::Ancestry::Behind | repository::Ancestry::Diverged
) {
self.prune(&remote);
continue;
let level_reachable = refs.feature_level();
match SignedRefsAt::load(remote, handle.repository()) {
Ok(Some(SignedRefsAt { at, .. })) => {
// Prune non-delegates if they're behind or
// diverged. A diverged case is non-fatal for
// delegates.
if matches!(
repository::ancestry(handle.repository(), at, refs.at)?,
repository::Ancestry::Behind | repository::Ancestry::Diverged
) {
self.prune(&remote);
continue;
}
}
Err(radicle::storage::refs::sigrefs::read::error::Read::Downgrade {
levels,
actual,
..
}) => {
let level_required = levels.max();
if level_reachable >= level_required {
log::info!("Non-delegate {remote} has downgraded history, currently stuck at '{actual}', expects to be upgraded to '{level_required}' and will be upgraded to '{level_reachable}'.")
} else {
log::debug!("Non-delegate {remote} has downgraded history, currently stuck at '{actual}', expects to be upgraded to '{level_required}' but only level '{level_reachable}' was advertised.");
self.prune(&remote);
continue;
}
}
Err(err) => return Err(error::Protocol::Refs(err)),
Ok(None) => {
// We see signed references for this non-delegate for the first time.
}
}
@ -554,27 +574,46 @@ impl FetchState {
}
}
(Ok(Some(refs)), true) => {
if let Some(SignedRefsAt { at, .. }) =
SignedRefsAt::load(remote, handle.repository())?
{
let ancestry = repository::ancestry(handle.repository(), at, refs.at)?;
if matches!(ancestry, repository::Ancestry::Behind) {
log::trace!(
"Advertised `rad/sigrefs` {} is behind {at} for {remote}",
refs.at
);
self.prune(&remote);
continue;
} else if matches!(ancestry, repository::Ancestry::Diverged) {
return Err(error::Protocol::Diverged {
remote,
current: at,
received: refs.at,
});
let level_reachable = refs.feature_level();
match SignedRefsAt::load(remote, handle.repository()) {
Ok(Some(SignedRefsAt { at, .. })) => {
let ancestry = repository::ancestry(handle.repository(), at, refs.at)?;
if matches!(ancestry, repository::Ancestry::Behind) {
log::trace!(
"Advertised `rad/sigrefs` {} is behind {at} for {remote}",
refs.at
);
self.prune(&remote);
continue;
} else if matches!(ancestry, repository::Ancestry::Diverged) {
return Err(error::Protocol::Diverged {
remote,
current: at,
received: refs.at,
});
}
}
Err(radicle::storage::refs::sigrefs::read::error::Read::Downgrade {
levels,
actual,
..
}) => {
let level_required = levels.max();
if level_reachable >= level_required {
log::info!("Delegate {remote} has downgraded history, currently stuck at '{actual}', expects to be upgraded to '{level_required}' and will be upgraded to '{level_reachable}'.")
} else {
log::info!("Delegate {remote} has downgraded history, currently stuck at '{actual}', expects to be upgraded to '{level_required}' but only level '{level_reachable}' was advertised.");
self.prune(&remote);
continue;
}
}
Err(err) => return Err(error::Protocol::Refs(err)),
Ok(None) => {
// We see signed references for this delegate for the first time.
}
}
let level = refs.feature_level();
let cache = self.as_cached(handle);
let mut fails =
sigrefs::validate(&cache, refs)?.unwrap_or(Validations::default());
@ -589,8 +628,8 @@ impl FetchState {
remotes.insert(remote);
}
if level < FeatureLevel::LATEST {
log::warn!("Delegate {remote} is on feature level '{level}' which is lower than '{}', they should consider upgrading Radicle.", FeatureLevel::LATEST)
if level_reachable < FeatureLevel::LATEST {
log::warn!("Delegate {remote} is on feature level '{level_reachable}' which is lower than '{}', they should consider upgrading Radicle.", FeatureLevel::LATEST)
}
}
}

View File

@ -99,7 +99,7 @@ impl FeatureLevels {
Self(BTreeMap::new())
}
fn max(&self) -> FeatureLevel {
pub fn max(&self) -> FeatureLevel {
self.0.last_key_value().map(|(k, _)| *k).unwrap_or_default()
}