Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: Teach `rad sync` and `rad clone` to accept feature levels
Fintan Halpenny committed 1 month ago
commit 33db6637b437cd4d55b614a6a5b8292c0678a583
parent ef4ddf0
19 files changed +403 -82
modified 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.
modified 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<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()
        }
    }
modified 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);
modified 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 {
    /// <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();
            }
added 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<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(),
+
        ))
+
    }
+
}
modified 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;
modified 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<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,
        }
    }
}
modified 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 { .. }
    );
modified 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")
modified 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();
modified 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<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)?;
        }
modified 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<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()??)
    }

modified 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<refs::FeatureLevel>,
    ) -> Result<FetchResult, Self::Error> {
        Ok(FetchResult::Success {
            updated: vec![],
modified 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.
modified 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();
modified 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) => {
modified 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<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})"),
modified 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<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),
            )?
modified 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<refs::FeatureLevel>,
    },

    /// Seed the given repository.