From 81ca4b9eda4a618ee3c68ec1ac59b74e22f7cc44 Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Tue, 24 Mar 2026 00:48:37 +0100 Subject: [PATCH] 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. --- crates/radicle-fetch/src/state.rs | 103 ++++++++++++------ .../radicle/src/storage/refs/sigrefs/read.rs | 2 +- 2 files changed, 72 insertions(+), 33 deletions(-) diff --git a/crates/radicle-fetch/src/state.rs b/crates/radicle-fetch/src/state.rs index 9bba498f..e2762b3e 100644 --- a/crates/radicle-fetch/src/state.rs +++ b/crates/radicle-fetch/src/state.rs @@ -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) } } } diff --git a/crates/radicle/src/storage/refs/sigrefs/read.rs b/crates/radicle/src/storage/refs/sigrefs/read.rs index adc72808..69212195 100644 --- a/crates/radicle/src/storage/refs/sigrefs/read.rs +++ b/crates/radicle/src/storage/refs/sigrefs/read.rs @@ -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() }