From 33db6637b437cd4d55b614a6a5b8292c0678a583 Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Tue, 24 Mar 2026 10:38:47 +0000 Subject: [PATCH] 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`. --- CHANGELOG.md | 6 + crates/radicle-cli/src/commands/clone/args.rs | 17 +- crates/radicle-cli/src/commands/sync.rs | 7 +- crates/radicle-cli/src/commands/sync/args.rs | 21 +- crates/radicle-cli/src/common_args.rs | 79 ++++++ crates/radicle-cli/src/lib.rs | 1 + crates/radicle-cli/src/node.rs | 12 +- crates/radicle-cli/tests/commands/cob.rs | 2 +- crates/radicle-cli/tests/commands/id.rs | 10 +- crates/radicle-cli/tests/commands/utility.rs | 4 +- crates/radicle-node/src/control.rs | 20 +- crates/radicle-node/src/runtime/handle.rs | 10 +- crates/radicle-node/src/test/handle.rs | 3 +- crates/radicle-node/src/tests.rs | 12 +- crates/radicle-node/src/tests/e2e.rs | 238 ++++++++++++++---- crates/radicle-protocol/src/service.rs | 10 +- .../radicle-protocol/src/service/command.rs | 26 +- crates/radicle/src/node.rs | 5 +- crates/radicle/src/node/command.rs | 2 + 19 files changed, 403 insertions(+), 82 deletions(-) create mode 100644 crates/radicle-cli/src/common_args.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d4f68d..37cd3959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/crates/radicle-cli/src/commands/clone/args.rs b/crates/radicle-cli/src/commands/clone/args.rs index bebc00d2..cabaf192 100644 --- a/crates/radicle-cli/src/commands/clone/args.rs +++ b/crates/radicle-cli/src/commands/clone/args.rs @@ -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, } impl From for SyncSettings { @@ -42,6 +54,9 @@ impl From 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() } } diff --git a/crates/radicle-cli/src/commands/sync.rs b/crates/radicle-cli/src/commands/sync.rs index 1236e72e..13661987 100644 --- a/crates/radicle-cli/src/commands/sync.rs +++ b/crates/radicle-cli/src/commands/sync.rs @@ -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); diff --git a/crates/radicle-cli/src/commands/sync/args.rs b/crates/radicle-cli/src/commands/sync/args.rs index df1f5509..0d0ca19d 100644 --- a/crates/radicle-cli/src/commands/sync/args.rs +++ b/crates/radicle-cli/src/commands/sync/args.rs @@ -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 { /// 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, } impl SyncArgs { @@ -220,9 +233,13 @@ impl From 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(); } diff --git a/crates/radicle-cli/src/common_args.rs b/crates/radicle-cli/src/common_args.rs new file mode 100644 index 00000000..05859de2 --- /dev/null +++ b/crates/radicle-cli/src/common_args.rs @@ -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 for refs::FeatureLevel { + fn from(SignedReferencesFeatureLevel { inner }: SignedReferencesFeatureLevel) -> Self { + inner + } +} + +impl FromStr for SignedReferencesFeatureLevel { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + 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 { + ::from_str.parse_ref(cmd, arg, value) + } + + fn possible_values( + &self, + ) -> Option + '_>> { + use clap::builder::PossibleValue; + Some(Box::new( + [ + PossibleValue::new("parent"), + PossibleValue::new("root"), + PossibleValue::new("none"), + ] + .into_iter(), + )) + } +} diff --git a/crates/radicle-cli/src/lib.rs b/crates/radicle-cli/src/lib.rs index 7b94cef4..f6bd66d0 100644 --- a/crates/radicle-cli/src/lib.rs +++ b/crates/radicle-cli/src/lib.rs @@ -8,6 +8,7 @@ pub mod pager; pub mod project; pub mod terminal; +mod common_args; mod warning; extern crate radicle_localtime as localtime; diff --git a/crates/radicle-cli/src/node.rs b/crates/radicle-cli/src/node.rs index 4db7efc3..ffef201f 100644 --- a/crates/radicle-cli/src/node.rs +++ b/crates/radicle-cli/src/node.rs @@ -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, /// 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, } 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) -> 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, } } } diff --git a/crates/radicle-cli/tests/commands/cob.rs b/crates/radicle-cli/tests/commands/cob.rs index b4fe0cba..77c93e89 100644 --- a/crates/radicle-cli/tests/commands/cob.rs +++ b/crates/radicle-cli/tests/commands/cob.rs @@ -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 { .. } ); diff --git a/crates/radicle-cli/tests/commands/id.rs b/crates/radicle-cli/tests/commands/id.rs index b9c350b8..672613c7 100644 --- a/crates/radicle-cli/tests/commands/id.rs +++ b/crates/radicle-cli/tests/commands/id.rs @@ -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") diff --git a/crates/radicle-cli/tests/commands/utility.rs b/crates/radicle-cli/tests/commands/utility.rs index 66040c3f..65935c1e 100644 --- a/crates/radicle-cli/tests/commands/utility.rs +++ b/crates/radicle-cli/tests/commands/utility.rs @@ -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(); diff --git a/crates/radicle-node/src/control.rs b/crates/radicle-node/src/control.rs index 1836f9f7..555e7b84 100644 --- a/crates/radicle-node/src/control.rs +++ b/crates/radicle-node/src/control.rs @@ -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>( id: RepoId, node: NodeId, timeout: time::Duration, + signed_references_minimum_feature_level: Option, 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)?; } diff --git a/crates/radicle-node/src/runtime/handle.rs b/crates/radicle-node/src/runtime/handle.rs index bfb7313d..0854fd79 100644 --- a/crates/radicle-node/src/runtime/handle.rs +++ b/crates/radicle-node/src/runtime/handle.rs @@ -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, ) -> Result { 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()??) } diff --git a/crates/radicle-node/src/test/handle.rs b/crates/radicle-node/src/test/handle.rs index b005f3ae..8fbb907d 100644 --- a/crates/radicle-node/src/test/handle.rs +++ b/crates/radicle-node/src/test/handle.rs @@ -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, ) -> Result { Ok(FetchResult::Success { updated: vec![], diff --git a/crates/radicle-node/src/tests.rs b/crates/radicle-node/src/tests.rs index af6f4735..070c39d1 100644 --- a/crates/radicle-node/src/tests.rs +++ b/crates/radicle-node/src/tests.rs @@ -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. diff --git a/crates/radicle-node/src/tests/e2e.rs b/crates/radicle-node/src/tests/e2e.rs index 27f81e0a..19294657 100644 --- a/crates/radicle-node/src/tests/e2e.rs +++ b/crates/radicle-node/src/tests/e2e.rs @@ -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(); diff --git a/crates/radicle-protocol/src/service.rs b/crates/radicle-protocol/src/service.rs index ee724009..cf9d177b 100644 --- a/crates/radicle-protocol/src/service.rs +++ b/crates/radicle-protocol/src/service.rs @@ -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) => { diff --git a/crates/radicle-protocol/src/service/command.rs b/crates/radicle-protocol/src/service/command.rs index 05bb17a9..85d35258 100644 --- a/crates/radicle-protocol/src/service/command.rs +++ b/crates/radicle-protocol/src/service/command.rs @@ -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, Responder), /// Fetch the given repository from the network. - Fetch(RepoId, NodeId, time::Duration, Responder), + Fetch( + RepoId, + NodeId, + time::Duration, + Option, + Responder, + ), /// Seed the given repository. Seed(RepoId, Scope, Responder), /// Unseed the given repository. @@ -150,9 +157,19 @@ impl Command { rid: RepoId, node_id: NodeId, duration: time::Duration, + signed_references_minimum_feature_level: Option, ) -> (Self, Receiver>) { 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>) { @@ -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})"), diff --git a/crates/radicle/src/node.rs b/crates/radicle/src/node.rs index 90627ac8..f885c0a8 100644 --- a/crates/radicle/src/node.rs +++ b/crates/radicle/src/node.rs @@ -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, ) -> Result; /// 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, ) -> Result { 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), )? diff --git a/crates/radicle/src/node/command.rs b/crates/radicle/src/node/command.rs index dfe9f85a..1ac5ae7f 100644 --- a/crates/radicle/src/node/command.rs +++ b/crates/radicle/src/node/command.rs @@ -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, }, /// Seed the given repository.