radicle: Teach `rad sync` and `rad clone` to accept feature levels

Teach the `rad sync` and `rad clone` commands to accept the
`--signed-refs-feature-level` option. This option configures that
fetch to use the minimum feature level provided when fetching from
other nodes. This overrides the value of
`node.fetch.signedReferences.featureLevel.minimum`, and should only be
used in scenarios where it is necessary to downgrade the security of a
fetch for backwards compatibility.


To achieve this, the `Handle::fetch` method introduces an optional
parameter for threading through the `FeatureLevel`.
This commit is contained in:
Fintan Halpenny 2026-03-24 10:38:47 +00:00
parent ef4ddf06c3
commit 33db6637b4
19 changed files with 403 additions and 82 deletions

View File

@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## New Features
- Teach the `rad sync` and `rad clone` commands to accept the
`--signed-refs-feature-level` option. This option configures that fetch to use
the minimum feature level provided when fetching from other nodes. This
overrides the value of `node.fetch.signedReferences.featureLevel.minimum`, and
should only be used in scenarios where it is necessary to tolerate a lower
minimum feature level for a fetch to achieve backwards compatibility.
- Fix the signed references reading process by correctly choosing the first,
non-replayed commit. This only occurs if duplicate signatures are found and
the process needs to find the first legitimate commit of the namespace.

View File

@ -2,12 +2,17 @@ use std::path::PathBuf;
use clap::Parser;
use crate::node::SyncSettings;
use radicle::identity::doc::RepoId;
use radicle::identity::IdError;
use radicle::node::policy::Scope;
use radicle::prelude::*;
use radicle::storage::refs;
use crate::common_args::{
SignedReferencesFeatureLevel, SignedReferencesFeatureLevelParser,
ABOUT_FETCH_SIGNED_REFERENCES_FEATURE_LEVEL_MINIMUM,
};
use crate::node::SyncSettings;
use crate::terminal;
const ABOUT: &str = "Clone a Radicle repository";
@ -35,6 +40,13 @@ pub(super) struct SyncArgs {
/// Valid arguments are for example "10s", "5min" or "2h 37min"
#[arg(long, value_parser = humantime::parse_duration, default_value = "9s")]
timeout: std::time::Duration,
#[arg(
long,
value_parser = SignedReferencesFeatureLevelParser,
help = ABOUT_FETCH_SIGNED_REFERENCES_FEATURE_LEVEL_MINIMUM
)]
signed_refs_feature_level: Option<SignedReferencesFeatureLevel>,
}
impl From<SyncArgs> for SyncSettings {
@ -42,6 +54,9 @@ impl From<SyncArgs> for SyncSettings {
SyncSettings {
timeout: args.timeout,
seeds: args.seeds.into_iter().collect(),
signed_references_minimum_feature_level: args
.signed_refs_feature_level
.map(refs::FeatureLevel::from),
..SyncSettings::default()
}
}

View File

@ -324,7 +324,12 @@ pub fn fetch(
}
if let Some((nid, addr)) = fetcher.next_fetch() {
spinner.emit_fetching(&nid, &addr, &progress);
let result = node.fetch(rid, nid, settings.timeout)?;
let result = node.fetch(
rid,
nid,
settings.timeout,
settings.signed_references_minimum_feature_level,
)?;
match fetcher.fetch_complete(nid, result) {
std::ops::ControlFlow::Continue(update) => {
spinner.emit_progress(&update);

View File

@ -6,8 +6,13 @@ use clap::{Parser, Subcommand, ValueEnum};
use radicle::{
node::{sync, NodeId},
prelude::RepoId,
storage::refs,
};
use crate::common_args::{
SignedReferencesFeatureLevel, SignedReferencesFeatureLevelParser,
ABOUT_FETCH_SIGNED_REFERENCES_FEATURE_LEVEL_MINIMUM,
};
use crate::node::SyncSettings;
const ABOUT: &str = "Sync repositories to the network";
@ -136,6 +141,14 @@ pub(super) struct SyncArgs {
/// <RID> is ignored with `--inventory`
#[arg(long, short)]
inventory: bool,
#[arg(
long,
requires = "fetch",
value_parser = SignedReferencesFeatureLevelParser,
help = ABOUT_FETCH_SIGNED_REFERENCES_FEATURE_LEVEL_MINIMUM
)]
signed_refs_feature_level: Option<SignedReferencesFeatureLevel>,
}
impl SyncArgs {
@ -220,9 +233,13 @@ impl From<SyncArgs> for SyncMode {
} else {
assert!(!args.inventory);
let direction = args.direction();
let timeout = args.timeout();
let replicas = args.replication();
let feature_level = args.signed_refs_feature_level.map(refs::FeatureLevel::from);
let mut settings = SyncSettings::default()
.timeout(args.timeout())
.replicas(args.replication());
.timeout(timeout)
.replicas(replicas)
.minimum_feature_level(feature_level);
if !args.seeds.is_empty() {
settings.seeds = args.seeds.into_iter().collect();
}

View File

@ -0,0 +1,79 @@
use std::str::FromStr;
use radicle::storage::refs;
pub const ABOUT_FETCH_SIGNED_REFERENCES_FEATURE_LEVEL_MINIMUM: &str = r#"
Perform the fetch using the provided Signed References minimum feature
level.
The options for this value provide the following behavior:
`parent`: Only the namespaces that use the parent feature level will be
accepted. This prevents graft attacks and replay attacks from
occurring. This provides the most security.
`root`: Only the namespaces that use the root feature level will be
accepted. This prevents graft attacks from occurring. This option
should be only used if you trust the node you are fetching from, and
want to bypass security for backwards compatibility.
`none`: All namespaces will be fetched regardless of feature level
detected, and provides no security against graft attacks or replay
attacks. This option should be only used if you trust the node you are
fetching from, and want to bypass security for backwards compatibility.
"#;
#[derive(Clone, Copy, Debug)]
pub struct SignedReferencesFeatureLevel {
inner: refs::FeatureLevel,
}
impl From<SignedReferencesFeatureLevel> for refs::FeatureLevel {
fn from(SignedReferencesFeatureLevel { inner }: SignedReferencesFeatureLevel) -> Self {
inner
}
}
impl FromStr for SignedReferencesFeatureLevel {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let inner = match s {
"none" => refs::FeatureLevel::None,
"root" => refs::FeatureLevel::Root,
"parent" => refs::FeatureLevel::Parent,
_ => return Err("invalid feature level"),
};
Ok(Self { inner })
}
}
#[derive(Clone, Debug)]
pub struct SignedReferencesFeatureLevelParser;
impl clap::builder::TypedValueParser for SignedReferencesFeatureLevelParser {
type Value = SignedReferencesFeatureLevel;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
<SignedReferencesFeatureLevel as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
}
fn possible_values(
&self,
) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
use clap::builder::PossibleValue;
Some(Box::new(
[
PossibleValue::new("parent"),
PossibleValue::new("root"),
PossibleValue::new("none"),
]
.into_iter(),
))
}
}

View File

@ -8,6 +8,7 @@ pub mod pager;
pub mod project;
pub mod terminal;
mod common_args;
mod warning;
extern crate radicle_localtime as localtime;

View File

@ -4,7 +4,7 @@ use std::io::Write;
use radicle::node::sync;
use radicle::node::{Handle as _, NodeId};
use radicle::storage::{ReadRepository, RepositoryError};
use radicle::storage::{refs, ReadRepository, RepositoryError};
use radicle::{Node, Profile};
use crate::terminal as term;
@ -21,6 +21,8 @@ pub struct SyncSettings {
pub seeds: BTreeSet<NodeId>,
/// How long to wait for syncing to complete.
pub timeout: time::Duration,
/// The minimum feature level to accept when fetching signed references.
pub signed_references_minimum_feature_level: Option<refs::FeatureLevel>,
}
impl SyncSettings {
@ -31,6 +33,13 @@ impl SyncSettings {
self
}
/// Set minimum feature level for fetching signed references.
#[must_use]
pub fn minimum_feature_level(mut self, feature_level: Option<refs::FeatureLevel>) -> Self {
self.signed_references_minimum_feature_level = feature_level;
self
}
/// Set replicas.
#[must_use]
pub fn replicas(mut self, replicas: sync::ReplicationFactor) -> Self {
@ -69,6 +78,7 @@ impl Default for SyncSettings {
replicas: sync::ReplicationFactor::default(),
seeds: BTreeSet::new(),
timeout: DEFAULT_SYNC_TIMEOUT,
signed_references_minimum_feature_level: None,
}
}
}

View File

@ -220,7 +220,7 @@ fn test_cob_deletion() {
radicle::assert_matches!(
bob.handle
.fetch(rid, alice.id, radicle::node::DEFAULT_TIMEOUT)
.fetch(rid, alice.id, radicle::node::DEFAULT_TIMEOUT, None)
.unwrap(),
radicle::node::FetchResult::Success { .. }
);

View File

@ -87,7 +87,9 @@ fn rad_id_threshold() {
alice.connect(&seed).connect(&bob);
bob.connect(&seed);
alice.routes_to(&[(acme, seed.id)]);
seed.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
seed.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
formula(&environment.tempdir(), "examples/rad-id-threshold.md")
.unwrap()
@ -323,10 +325,12 @@ fn rad_id_collaboration() {
.connect(&distrustful)
.converge([&seed, &distrustful]);
seed.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
seed.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
distrustful
.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT)
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
formula(&environment.tempdir(), "examples/rad-id-collaboration.md")

View File

@ -87,7 +87,9 @@ fn rad_clean() {
bob.connect(&alice).converge([&alice]);
eve.connect(&alice).converge([&alice]);
eve.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
eve.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
bob.fork(acme, bob.home.path()).unwrap();
bob.announce(acme, 1, bob.home.path()).unwrap();

View File

@ -5,6 +5,7 @@ use std::io::LineWriter;
use std::path::PathBuf;
use std::{io, net, time};
use radicle::storage::refs;
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
#[cfg(windows)]
@ -112,8 +113,20 @@ where
CommandResult::ok().to_writer(writer).ok();
}
},
Command::Fetch { rid, nid, timeout } => {
fetch(rid, nid, timeout, writer, &mut handle)?;
Command::Fetch {
rid,
nid,
timeout,
signed_references_minimum_feature_level,
} => {
fetch(
rid,
nid,
timeout,
signed_references_minimum_feature_level,
writer,
&mut handle,
)?;
}
Command::Config => {
let config = handle.config()?;
@ -248,10 +261,11 @@ fn fetch<W: Write, H: Handle<Error = runtime::HandleError>>(
id: RepoId,
node: NodeId,
timeout: time::Duration,
signed_references_minimum_feature_level: Option<refs::FeatureLevel>,
mut writer: W,
handle: &mut H,
) -> Result<(), CommandError> {
match handle.fetch(id, node, timeout) {
match handle.fetch(id, node, timeout, signed_references_minimum_feature_level) {
Ok(result) => {
json::to_writer(&mut writer, &result)?;
}

View File

@ -15,6 +15,7 @@ use radicle::node::events::{Event, Events};
use radicle::node::policy;
use radicle::node::{Config, NodeId};
use radicle::node::{ConnectOptions, ConnectResult, Seeds};
use radicle::storage::refs;
use serde_json::json;
use thiserror::Error;
@ -229,9 +230,16 @@ impl radicle::node::Handle for Handle {
id: RepoId,
from: NodeId,
timeout: time::Duration,
signed_references_minimum_feature_level: Option<refs::FeatureLevel>,
) -> Result<FetchResult, Error> {
let (responder, receiver) = service::command::Responder::oneshot();
self.command(service::Command::Fetch(id, from, timeout, responder))?;
self.command(service::Command::Fetch(
id,
from,
timeout,
signed_references_minimum_feature_level,
responder,
))?;
Ok(receiver.recv()??)
}

View File

@ -5,7 +5,7 @@ use std::time;
use radicle::crypto::PublicKey;
use radicle::git::Oid;
use radicle::storage::refs::RefsAt;
use radicle::storage::refs::{self, RefsAt};
use crate::identity::RepoId;
use crate::node::{Alias, Config, ConnectOptions, ConnectResult, Event, FetchResult, Seeds};
@ -69,6 +69,7 @@ impl radicle::node::Handle for Handle {
_id: RepoId,
_from: NodeId,
_timeout: time::Duration,
_signed_references_minimum_feature_level: Option<refs::FeatureLevel>,
) -> Result<FetchResult, Self::Error> {
Ok(FetchResult::Success {
updated: vec![],

View File

@ -1496,15 +1496,15 @@ fn test_queued_fetch_max_capacity() {
alice.connect_to(&bob);
// Send the first fetch.
let (cmd, _recv1) = Command::fetch(rid1, bob.id, DEFAULT_TIMEOUT);
let (cmd, _recv1) = Command::fetch(rid1, bob.id, DEFAULT_TIMEOUT, None);
alice.command(cmd);
// Send the 2nd fetch that will be queued.
let (cmd, _recv2) = Command::fetch(rid2, bob.id, DEFAULT_TIMEOUT);
let (cmd, _recv2) = Command::fetch(rid2, bob.id, DEFAULT_TIMEOUT, None);
alice.command(cmd);
// Send the 3rd fetch that will be queued.
let (cmd, _recv3) = Command::fetch(rid3, bob.id, DEFAULT_TIMEOUT);
let (cmd, _recv3) = Command::fetch(rid3, bob.id, DEFAULT_TIMEOUT, None);
alice.command(cmd);
// The first fetch is initiated.
@ -1618,15 +1618,15 @@ fn test_queued_fetch_from_command_same_rid() {
alice.connect_to(&carol);
// Send the first fetch.
let (cmd, _recv1) = Command::fetch(rid1, bob.id, DEFAULT_TIMEOUT);
let (cmd, _recv1) = Command::fetch(rid1, bob.id, DEFAULT_TIMEOUT, None);
alice.command(cmd);
// Send the 2nd fetch that will be queued.
let (cmd, _recv2) = Command::fetch(rid1, eve.id, DEFAULT_TIMEOUT);
let (cmd, _recv2) = Command::fetch(rid1, eve.id, DEFAULT_TIMEOUT, None);
alice.command(cmd);
// Send the 3rd fetch that will be queued.
let (cmd, _recv3) = Command::fetch(rid1, carol.id, DEFAULT_TIMEOUT);
let (cmd, _recv3) = Command::fetch(rid1, carol.id, DEFAULT_TIMEOUT, None);
alice.command(cmd);
// Peers Alice will fetch from.

View File

@ -189,7 +189,10 @@ fn test_replication() {
let seeds = alice.handle.seeds_for(acme, None).unwrap();
assert!(seeds.is_connected(&bob.id));
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
let updated = match result {
@ -266,7 +269,10 @@ fn test_replication_ref_in_sigrefs() {
converge([&alice, &bob]);
alice.handle.seed(acme, Scope::All).unwrap();
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert_matches!(result, FetchResult::Success { .. });
@ -325,7 +331,10 @@ fn test_replication_invalid() {
alice.handle.follow(*carol.public_key(), None).unwrap();
alice.handle.seed(acme, Scope::Followed).unwrap();
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
// Fetch is successful despite not fetching Carol's refs, since she isn't a delegate.
assert!(result.is_success());
@ -355,7 +364,10 @@ fn test_migrated_clone() {
let updated = bob.handle.seed(acme, Scope::All).unwrap();
assert!(updated);
let result = bob.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
log::debug!(target: "test", "Fetch complete with {}", alice.id);
@ -366,7 +378,10 @@ fn test_migrated_clone() {
std::fs::remove_dir_all(path).unwrap();
}
assert!(!alice.storage.contains(&acme).unwrap());
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
let alice_repo = alice.storage.repository(acme).unwrap();
@ -405,13 +420,19 @@ fn test_dont_fetch_owned_refs() {
assert!(bob.handle.seed(acme, Scope::Followed).unwrap());
let result = bob.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
log::debug!(target: "test", "Fetch complete with {}", bob.id);
alice.issue(acme, Title::new("Don't fetch self").unwrap(), "Use ^");
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success())
}
@ -451,7 +472,10 @@ fn test_fetch_followed_remotes() {
assert!(bob.handle.follow(*nid, None).unwrap());
}
let result = bob.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
log::debug!(target: "test", "Fetch complete with {}", bob.id);
@ -484,7 +508,10 @@ fn test_missing_remote() {
assert!(bob.handle.seed(acme, Scope::Followed).unwrap());
assert!(bob.handle.follow(*carol.public_key(), None).unwrap());
let result = bob.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
log::debug!(target: "test", "Fetch complete with {}", bob.id);
rad::fork_remote(acme, &alice.id, &carol, &bob.storage).unwrap();
@ -494,7 +521,10 @@ fn test_missing_remote() {
Title::new("Missing Remote").unwrap(),
"Fixing the missing remote issue",
);
let result = bob.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
log::debug!(target: "test", "Fetch complete with {}", bob.id);
}
@ -514,7 +544,10 @@ fn test_fetch_preserve_owned_refs() {
assert!(bob.handle.seed(acme, Scope::Followed).unwrap());
assert!(bob.handle.follow(alice.id, None).unwrap());
let result = bob.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
log::debug!(target: "test", "Fetch complete with {}", bob.id);
@ -529,7 +562,10 @@ fn test_fetch_preserve_owned_refs() {
.unwrap();
// Fetch shouldn't prune any of our own refs.
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
let (updated, _) = result.success().unwrap();
assert_eq!(updated, vec![]);
@ -562,7 +598,10 @@ fn test_clone() {
let seeds = alice.handle.seeds_for(acme, None).unwrap();
assert!(seeds.is_connected(&bob.id));
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
rad::fork(acme, &alice.signer, &alice.storage).unwrap();
@ -616,11 +655,17 @@ fn test_fetch_up_to_date() {
transport::local::register(alice.storage.clone());
let _ = alice.handle.seed(acme, Scope::All).unwrap();
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
// Fetch again! This time, everything's up to date.
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert_matches!(
result.success(),
Some((updates, _fetched)) if updates.iter().all(|update| matches!(update, RefUpdate::Skipped { .. }))
@ -643,14 +688,20 @@ fn test_fetch_unseeded() {
transport::local::register(alice.storage.clone());
let _ = alice.handle.seed(acme, Scope::All).unwrap();
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
// Bob stops seeding the repository
assert!(bob.handle.unseed(acme).unwrap());
// Alice attempts to fetch but is unauthorized
let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap();
let result = alice
.handle
.fetch(acme, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert_matches!(result, FetchResult::Failed { .. });
}
@ -896,9 +947,14 @@ fn test_non_fastforward_sigrefs() {
converge([&alice, &bob, &eve]);
// Eve fetches the initial project from Bob.
eve.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap();
eve.handle
.fetch(rid, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
// Alice fetches it too.
let old_bob = alice.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap();
let old_bob = alice
.handle
.fetch(rid, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
let bob_sigrefs = bob
.storage
.repository(rid)
@ -940,7 +996,10 @@ fn test_non_fastforward_sigrefs() {
"Updated sigrefs are harshing my vibes",
);
// Alice fetches from Bob.
let new_bob = alice.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap();
let new_bob = alice
.handle
.fetch(rid, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
let bob_sigrefs = bob
.storage
.repository(rid)
@ -972,7 +1031,7 @@ fn test_non_fastforward_sigrefs() {
}
assert_matches!(
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap(),
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT, None).unwrap(),
FetchResult::Success { updated, .. }
if updated.iter().all(|u| u.is_skipped())
);
@ -999,11 +1058,15 @@ fn test_outdated_sigrefs() {
eve.connect(&alice);
converge([&alice, &bob, &eve]);
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
bob.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(bob.storage.contains(&rid).unwrap());
rad::fork(rid, &bob.signer, &bob.storage).unwrap();
eve.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
eve.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(eve.storage.contains(&rid).unwrap());
rad::fork(rid, &eve.signer, &eve.storage).unwrap();
@ -1011,13 +1074,18 @@ fn test_outdated_sigrefs() {
.handle
.follow(eve.id, Some(Alias::new("eve")))
.unwrap();
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap();
alice
.handle
.fetch(rid, eve.id, DEFAULT_TIMEOUT, None)
.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(),
bob.handle
.fetch(rid, eve.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
let repo = bob.storage.repository(rid).unwrap();
@ -1038,7 +1106,10 @@ fn test_outdated_sigrefs() {
// Get the current state of eve's refs in alice's storage
log::debug!(target: "test", "Alice fetches from Eve..");
assert_matches!(
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap(),
alice
.handle
.fetch(rid, eve.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
let repo = alice.storage.repository(rid).unwrap();
@ -1059,7 +1130,10 @@ fn test_outdated_sigrefs() {
.follow(bob.id, Some(Alias::new("bob")))
.unwrap();
assert_matches!(
alice.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap(),
alice
.handle
.fetch(rid, bob.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
@ -1093,11 +1167,15 @@ fn test_outdated_delegate_sigrefs() {
eve.connect(&alice);
converge([&alice, &bob, &eve]);
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
bob.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(bob.storage.contains(&rid).unwrap());
rad::fork(rid, &bob.signer, &bob.storage).unwrap();
eve.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
eve.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(eve.storage.contains(&rid).unwrap());
rad::fork(rid, &eve.signer, &eve.storage).unwrap();
@ -1105,13 +1183,18 @@ fn test_outdated_delegate_sigrefs() {
.handle
.follow(eve.id, Some(Alias::new("eve")))
.unwrap();
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap();
alice
.handle
.fetch(rid, eve.id, DEFAULT_TIMEOUT, None)
.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(),
bob.handle
.fetch(rid, eve.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
let repo = bob.storage.repository(rid).unwrap();
@ -1132,7 +1215,9 @@ fn test_outdated_delegate_sigrefs() {
// 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(),
eve.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
let repo = eve.storage.repository(rid).unwrap();
@ -1145,7 +1230,9 @@ fn test_outdated_delegate_sigrefs() {
eve.handle.follow(bob.id, Some(Alias::new("bob"))).unwrap();
assert_matches!(
eve.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap(),
eve.handle
.fetch(rid, bob.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
@ -1175,7 +1262,9 @@ fn missing_default_branch() {
alice.connect(&bob);
converge([&alice, &bob]);
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
bob.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(bob.storage.contains(&rid).unwrap());
// Fetching from still works despite not having
@ -1185,7 +1274,10 @@ fn missing_default_branch() {
Title::new("Hello, Acme").unwrap(),
"Popping in to say hello",
);
alice.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap();
alice
.handle
.fetch(rid, bob.id, DEFAULT_TIMEOUT, None)
.unwrap();
{
let repo = bob.storage.repository(rid).unwrap();
@ -1207,7 +1299,9 @@ fn missing_default_branch() {
// Fetching from her will still succeed.
assert_matches!(
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap(),
bob.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
let repo = bob.storage.repository(rid).unwrap();
@ -1240,7 +1334,9 @@ fn missing_delegate_default_branch() {
converge([&seed]);
bob.connect(&seed);
bob.handle.fetch(rid, seed.id, DEFAULT_TIMEOUT).unwrap();
bob.handle
.fetch(rid, seed.id, DEFAULT_TIMEOUT, None)
.unwrap();
bob_events
.wait(
|e| {
@ -1312,7 +1408,9 @@ fn missing_delegate_default_branch() {
// a) Bob's default branch is still missing
// b) Bob's issue is there
assert_matches!(
seed.handle.fetch(rid, bob.id, DEFAULT_TIMEOUT).unwrap(),
seed.handle
.fetch(rid, bob.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
{
@ -1323,7 +1421,10 @@ fn missing_delegate_default_branch() {
// Do the same for Alice
assert_matches!(
alice.handle.fetch(rid, seed.id, DEFAULT_TIMEOUT).unwrap(),
alice
.handle
.fetch(rid, seed.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
{
@ -1334,7 +1435,9 @@ fn missing_delegate_default_branch() {
// Check that Bob can still fetch from the seed
assert_matches!(
bob.handle.fetch(rid, seed.id, DEFAULT_TIMEOUT).unwrap(),
bob.handle
.fetch(rid, seed.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
}
@ -1360,11 +1463,15 @@ fn test_background_foreground_fetch() {
alice.connect(&eve);
converge([&alice, &bob, &eve]);
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
bob.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(bob.storage.contains(&rid).unwrap());
rad::fork(rid, &bob.signer, &bob.storage).unwrap();
eve.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
eve.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(eve.storage.contains(&rid).unwrap());
rad::fork(rid, &eve.signer, &eve.storage).unwrap();
@ -1373,7 +1480,10 @@ fn test_background_foreground_fetch() {
.handle
.follow(eve.id, Some(Alias::new("eve")))
.unwrap();
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap();
alice
.handle
.fetch(rid, eve.id, DEFAULT_TIMEOUT, None)
.unwrap();
let repo = alice.storage.repository(rid).unwrap();
assert!(repo.remote(&eve.id).is_ok());
let repo = alice.storage.repository(rid).unwrap();
@ -1414,7 +1524,10 @@ fn test_background_foreground_fetch() {
// interfere
log::debug!(target: "test", "Alice fetches from Eve..");
assert_matches!(
alice.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap(),
alice
.handle
.fetch(rid, eve.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
let repo = alice.storage.repository(rid).unwrap();
@ -1514,7 +1627,10 @@ fn test_channel_reader_limit() {
let updated = bob.handle.seed(acme, Scope::All).unwrap();
assert!(updated);
let result = bob.handle.fetch(acme, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(acme, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(!result.is_success());
let FetchResult::Failed { reason } = result else {
@ -1548,7 +1664,10 @@ fn test_fetch_emits_canonical_ref_update() {
bob.handle.seed(rid, Scope::All).unwrap();
alice.connect(&bob);
let result = bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(result.is_success());
let default_branch: git::fmt::Qualified = {
@ -1606,12 +1725,14 @@ fn test_non_fastforward_identity_doc() {
// Bob and Eve have the same state for the repository
bob.handle.seed(rid, Scope::Followed).unwrap();
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
bob.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
alice_laptop.handle.seed(rid, Scope::All).unwrap();
alice_laptop
.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT)
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
// Alice pushes new references to her laptop
let issue = alice_laptop.issue(
@ -1623,7 +1744,7 @@ fn test_non_fastforward_identity_doc() {
// Eve will fetch these references since her scope is "all"
eve.handle.seed(rid, Scope::All).unwrap();
eve.handle
.fetch(rid, alice_laptop.id, DEFAULT_TIMEOUT)
.fetch(rid, alice_laptop.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(has_issue(&eve, &issue));
@ -1651,7 +1772,10 @@ fn test_non_fastforward_identity_doc() {
//
// Bob does not have the issue because Alice does not have the updates from
// Alice's Laptop.
let result = bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
let result = bob
.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(matches!(result, FetchResult::Success { .. }));
assert!(!has_issue(&bob, &issue));
let repo = bob.storage.repository(rid).unwrap();
@ -1662,7 +1786,9 @@ fn test_non_fastforward_identity_doc() {
// Bob fetches from Eve, the identity document should remain the same, but
// since Bob now knows that Alice's Laptop is a delegate, the issue should
// be fetched.
bob.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap();
bob.handle
.fetch(rid, eve.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(matches!(result, FetchResult::Success { .. }));
assert!(has_issue(&bob, &issue));
let repo = bob.storage.repository(rid).unwrap();
@ -1744,7 +1870,7 @@ fn test_block_prevents_fetch() {
let result = alice
.handle
.fetch(rid, bob.id, time::Duration::from_secs(5))
.fetch(rid, bob.id, time::Duration::from_secs(5), None)
.unwrap();
assert_matches!(result, FetchResult::Failed { .. });
@ -1768,7 +1894,9 @@ fn fetch_does_not_contain_rad_sigrefs_parent() {
alice.connect(&bob);
converge([&alice, &bob]);
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
bob.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap();
assert!(bob.storage.contains(&rid).unwrap());
rad::fork(rid, &bob.signer, &bob.storage).unwrap();
@ -1782,7 +1910,9 @@ fn fetch_does_not_contain_rad_sigrefs_parent() {
log::debug!(target: "test", "Bob fetches from Alice..");
assert_matches!(
bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap(),
bob.handle
.fetch(rid, alice.id, DEFAULT_TIMEOUT, None)
.unwrap(),
FetchResult::Success { .. }
);
let repo = bob.storage.repository(rid).unwrap();

View File

@ -863,9 +863,13 @@ where
resp.err(e).ok();
}
},
Command::Fetch(rid, seed, timeout, resp) => {
// TODO(finto): pass through feature-level
let config = self.fetch_config().with_timeout(timeout);
Command::Fetch(rid, seed, timeout, signed_references_minimum_feature_level, resp) => {
let feature_level = signed_references_minimum_feature_level
.unwrap_or(self.config.fetch.feature_level_min());
let config = self
.fetch_config()
.with_timeout(timeout)
.with_minimum_feature_level(feature_level);
self.fetch(rid, seed, vec![], config, Some(resp));
}
Command::Seed(rid, scope, resp) => {

View File

@ -8,6 +8,7 @@ use radicle::node::policy::Scope;
use radicle::node::FetchResult;
use radicle::node::Seeds;
use radicle::node::{Address, Alias, Config, ConnectOptions};
use radicle::storage::refs;
use radicle::storage::refs::RefsAt;
use radicle_core::{NodeId, RepoId};
use thiserror::Error;
@ -90,7 +91,13 @@ pub enum Command {
/// sync status for given namespaces.
Seeds(RepoId, HashSet<PublicKey>, Responder<Seeds>),
/// Fetch the given repository from the network.
Fetch(RepoId, NodeId, time::Duration, Responder<FetchResult>),
Fetch(
RepoId,
NodeId,
time::Duration,
Option<refs::FeatureLevel>,
Responder<FetchResult>,
),
/// Seed the given repository.
Seed(RepoId, Scope, Responder<bool>),
/// Unseed the given repository.
@ -150,9 +157,19 @@ impl Command {
rid: RepoId,
node_id: NodeId,
duration: time::Duration,
signed_references_minimum_feature_level: Option<refs::FeatureLevel>,
) -> (Self, Receiver<Result<FetchResult>>) {
let (responder, receiver) = Responder::oneshot();
(Self::Fetch(rid, node_id, duration, responder), receiver)
(
Self::Fetch(
rid,
node_id,
duration,
signed_references_minimum_feature_level,
responder,
),
receiver,
)
}
pub fn seed(rid: RepoId, scope: Scope) -> (Self, Receiver<Result<bool>>) {
@ -191,7 +208,10 @@ impl fmt::Debug for Command {
Self::Config(_) => write!(f, "Config"),
Self::ListenAddrs(_) => write!(f, "ListenAddrs"),
Self::Seeds(id, _, _) => write!(f, "Seeds({id})"),
Self::Fetch(id, node, _, _) => write!(f, "Fetch({id}, {node})"),
Self::Fetch(id, node, _, feature_level, _) => match feature_level {
Some(feature_level) => write!(f, "Fetch({id}, {node} {feature_level})"),
None => write!(f, "Fetch({id}, {node})"),
},
Self::Seed(id, scope, _) => write!(f, "Seed({id}, {scope})"),
Self::Unseed(id, _) => write!(f, "Unseed({id})"),
Self::Follow(id, _, _) => write!(f, "Follow({id})"),

View File

@ -43,7 +43,7 @@ use crate::crypto::PublicKey;
use crate::git;
use crate::identity::RepoId;
use crate::profile;
use crate::storage::refs::RefsAt;
use crate::storage::refs::{FeatureLevel, RefsAt};
use crate::storage::RefUpdate;
pub use address::KnownAddress;
@ -975,6 +975,7 @@ pub trait Handle: Clone + Sync + Send {
id: RepoId,
from: NodeId,
timeout: time::Duration,
signed_references_minimum_feature_level: Option<FeatureLevel>,
) -> Result<FetchResult, Self::Error>;
/// Start seeding the given repo. May update the scope. Does nothing if the
/// repo is already seeded.
@ -1244,6 +1245,7 @@ impl Handle for Node {
rid: RepoId,
from: NodeId,
timeout: time::Duration,
signed_references_minimum_feature_level: Option<FeatureLevel>,
) -> Result<FetchResult, Error> {
let result = self
.call(
@ -1251,6 +1253,7 @@ impl Handle for Node {
rid,
nid: from,
timeout,
signed_references_minimum_feature_level,
},
DEFAULT_TIMEOUT.max(timeout),
)?

View File

@ -15,6 +15,7 @@ use serde_json as json;
use crate::crypto::PublicKey;
use crate::identity::RepoId;
use crate::storage::refs;
use super::events::Event;
use super::NodeId;
@ -96,6 +97,7 @@ pub enum Command {
rid: RepoId,
nid: NodeId,
timeout: time::Duration,
signed_references_minimum_feature_level: Option<refs::FeatureLevel>,
},
/// Seed the given repository.