Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle-cli: non-delegate id update error prettyify
Merged ade opened 3 months ago

Provides a more helpful hint when a non-delegate attempts to update the id document.

10 files changed +161 -40 bd5436d7 b937a938
added crates/radicle-cli/examples/rad-id-unauthorized-delegate.md
@@ -0,0 +1,8 @@
+
Alice has created a new repository `rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji` with only herself as the sole delegate. After Bob has cloned it, let's ensure he can't add himself as a delegate too:
+

+
``` ~bob (fail)
+
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Add myself!" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm
+
✗ Error: did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk is not a delegate, and only delegates are allowed to create a revision
+
✗ Hint: bob (you) is attempting to modify the identity document but isn't a delegate!
+
```
+

modified crates/radicle-cli/src/commands/id.rs
@@ -19,6 +19,7 @@ use crate::git::unified_diff::Encode as _;
use crate::git::Rev;
use crate::terminal as term;
use crate::terminal::args::Error;
+
use crate::terminal::format::Author;
use crate::terminal::patch::Message;

pub use args::Args;
@@ -135,12 +136,11 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
                let proposal = match update::privacy_allow_list(proposal, allow, disallow) {
                    Ok(proposal) => proposal,
                    Err(e) => match e {
-
                        update::error::PrivacyAllowList::Overlapping(overlap) =>                     anyhow::bail!("`--allow` and `--disallow` must not overlap: {overlap:?}"),
-
                        update::error::PrivacyAllowList::PublicVisibility =>                         return Err(Error::WithHint {
-
                            err:
+
                        update::error::PrivacyAllowList::Overlapping(overlap) =>anyhow::bail!("`--allow` and `--disallow` must not overlap: {overlap:?}"),
+
                        update::error::PrivacyAllowList::PublicVisibility => return Err(Error::with_hint(
                            anyhow!("`--allow` and `--disallow` should only be used for private repositories"),
-
                            hint: "use `--visibility private` to make the repository private, or perhaps you meant to use `--delegate`/`--rescind`",
-
                        }.into())
+
                            "use `--visibility private` to make the repository private, or perhaps you meant to use `--delegate`/`--rescind`")
+
                        .into())
                    }
                };
                let threshold = proposal.threshold;
@@ -194,7 +194,14 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
                return Ok(());
            }
            let signer = term::signer(&profile)?;
-
            let revision = update(title, description, proposal, &mut identity, &signer)?;
+
            let revision = update(
+
                title,
+
                description,
+
                proposal,
+
                &mut identity,
+
                &signer,
+
                &profile,
+
            )?;

            if revision.is_accepted() && revision.parent == Some(current.id) {
                // Update the canonical head to point to the latest accepted revision.
@@ -421,13 +428,16 @@ fn update<R, G>(
    doc: Doc,
    current: &mut IdentityMut<R>,
    signer: &Device<G>,
+
    profile: &Profile,
) -> anyhow::Result<Revision>
where
    R: WriteRepository + cob::Store<Namespace = NodeId>,
    G: crypto::signature::Signer<crypto::Signature>,
{
    if let Some((title, description)) = edit_title_description(title, description)? {
-
        let id = current.update(title, description, &doc, signer)?;
+
        let id = current
+
            .update(title, description, &doc, signer)
+
            .map_err(|e| on_identity_err(e, profile))?;
        let revision = current
            .revision(&id)
            .ok_or(anyhow!("update failed: revision {id} is missing"))?;
@@ -438,6 +448,34 @@ where
    }
}

+
fn on_identity_err(e: identity::Error, profile: &Profile) -> anyhow::Error {
+
    let e = anyhow::Error::from(e);
+

+
    e.chain()
+
        .find_map(|c| c.downcast_ref::<identity::ApplyError>())
+
        .map(|e| on_apply_err(e, profile))
+
        .unwrap_or(e)
+
}
+

+
fn on_apply_err(e: &identity::ApplyError, profile: &Profile) -> anyhow::Error {
+
    match e {
+
        e @ identity::ApplyError::NonDelegateUnauthorized { author, .. } => {
+
            let nid = NodeId::from(*author);
+
            let labels = Author::new(&nid, profile, false).labels();
+

+
            Error::with_hint(
+
                anyhow!(e.to_string()),
+
                format!(
+
                    "{} {} is attempting to modify the identity document but isn't a delegate!",
+
                    labels.0, labels.1
+
                ),
+
            )
+
            .into()
+
        }
+
        e => anyhow!(e.to_string()),
+
    }
+
}
+

fn print_diff(
    previous: Option<&RevisionId>,
    current: &RevisionId,
modified crates/radicle-cli/src/commands/issue.rs
@@ -122,9 +122,8 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
            let id = id.resolve(&repo.backend)?;
            let issue = issues
                .get(&id)
-
                .map_err(|e| Error::WithHint {
-
                    err: e.into(),
-
                    hint: "reset the cache with `rad issue cache` and try again",
+
                .map_err(|e| {
+
                    Error::with_hint(e, "reset the cache with `rad issue cache` and try again")
                })?
                .context("No issue with the given ID exists")?;
            term::issue::show(&issue, &id, format, args.verbose, &profile)?;
modified crates/radicle-cli/src/commands/patch/show.rs
@@ -33,10 +33,9 @@ pub fn run(
    workdir: Option<&git::raw::Repository>,
) -> anyhow::Result<()> {
    let patches = term::cob::patches(profile, stored)?;
-
    let Some(patch) = patches.get(patch_id).map_err(|e| Error::WithHint {
-
        err: e.into(),
-
        hint: "reset the cache with `rad patch cache` and try again",
-
    })?
+
    let Some(patch) = patches
+
        .get(patch_id)
+
        .map_err(|e| Error::with_hint(e, "reset the cache with `rad patch cache` and try again"))?
    else {
        anyhow::bail!("Patch `{patch_id}` not found");
    };
modified crates/radicle-cli/src/commands/publish.rs
@@ -25,22 +25,20 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let doc = identity.doc();

    if doc.is_public() {
-
        return Err(term::Error::WithHint {
-
            err: anyhow!("repository is already public"),
-
            hint: "to announce the repository to the network, run `rad sync --inventory`",
-
        }
+
        return Err(term::Error::with_hint(
+
            anyhow!("repository is already public"),
+
            "to announce the repository to the network, run `rad sync --inventory`",
+
        )
        .into());
    }
    if !doc.is_delegate(&profile.id().into()) {
        return Err(anyhow!("only the repository delegate can publish it"));
    }
    if doc.delegates().len() > 1 {
-
        return Err(term::Error::WithHint {
-
            err: anyhow!(
-
                "only repositories with a single delegate can be published with this command"
-
            ),
-
            hint: "see `rad id --help` to publish repositories with more than one delegate",
-
        }
+
        return Err(term::Error::with_hint(
+
            anyhow!("only repositories with a single delegate can be published with this command"),
+
            "see `rad id --help` to publish repositories with more than one delegate",
+
        )
        .into());
    }
    let signer = profile.signer()?;
modified crates/radicle-cli/src/terminal.rs
@@ -45,10 +45,10 @@ impl Context for DefaultContext {
    fn profile(&self) -> Result<Profile, anyhow::Error> {
        match Profile::load() {
            Ok(profile) => Ok(profile),
-
            Err(radicle::profile::Error::NotFound(path)) => Err(args::Error::WithHint {
-
                err: anyhow::anyhow!("Radicle profile not found in '{}'.", path.display()),
-
                hint: "To setup your radicle profile, run `rad auth`.",
-
            }
+
            Err(radicle::profile::Error::NotFound(path)) => Err(args::Error::with_hint(
+
                anyhow::anyhow!("Radicle profile not found in '{}'.", path.display()),
+
                "To setup your radicle profile, run `rad auth`.",
+
            )
            .into()),
            Err(radicle::profile::Error::LoadConfig(e)) => Err(e.into()),
            Err(e) => Err(anyhow::anyhow!("Could not load radicle profile: {e}")),
modified crates/radicle-cli/src/terminal/args.rs
@@ -8,10 +8,20 @@ use radicle::prelude::{Did, NodeId, RepoId};
pub(crate) enum Error {
    /// An error with a hint.
    #[error("{err}")]
-
    WithHint {
-
        err: anyhow::Error,
-
        hint: &'static str,
-
    },
+
    WithHint { err: anyhow::Error, hint: String },
+
}
+

+
impl Error {
+
    pub fn with_hint<E, H>(err: E, hint: H) -> Self
+
    where
+
        E: Into<anyhow::Error>,
+
        H: ToString,
+
    {
+
        Self::WithHint {
+
            err: err.into(),
+
            hint: hint.to_string(),
+
        }
+
    }
}

/// Targets used in the `block` and `unblock` commands
modified crates/radicle-cli/src/terminal/cob.rs
@@ -104,10 +104,7 @@ where
fn with_hint(e: profile::Error) -> anyhow::Error {
    match e {
        profile::Error::CobsCache(cob::cache::Error::OutOfDate) => {
-
            anyhow::Error::from(terminal::args::Error::WithHint {
-
                err: e.into(),
-
                hint: MIGRATION_HINT,
-
            })
+
            terminal::args::Error::with_hint(e, MIGRATION_HINT).into()
        }
        e => anyhow::Error::from(e),
    }
modified crates/radicle-cli/tests/commands.rs
@@ -603,6 +603,56 @@ fn rad_id_multi_delegate() {
}

#[test]
+
fn rad_id_unauthorized_delegate() {
+
    let mut environment = Environment::new();
+
    let alice = environment.node("alice");
+
    let bob = environment.node("bob");
+
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
+

+
    environment.repository(&alice);
+

+
    test(
+
        "examples/rad-init.md",
+
        environment.work(&alice),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+

+
    let mut alice = alice.spawn();
+
    let mut bob = bob.spawn();
+

+
    // Alice sets up the seed
+
    alice.handle.seed(acme, Scope::Followed).unwrap();
+

+
    bob.connect(&alice).converge([&alice]);
+
    bob.rad(
+
        "clone",
+
        &[acme.to_string().as_str()],
+
        environment.work(&bob),
+
    )
+
    .unwrap();
+

+
    formula(
+
        &environment.tempdir(),
+
        "examples/rad-id-unauthorized-delegate.md",
+
    )
+
    .unwrap()
+
    .home(
+
        "alice",
+
        environment.work(&alice),
+
        [("RAD_HOME", alice.home.path().display())],
+
    )
+
    .home(
+
        "bob",
+
        environment.work(&bob),
+
        [("RAD_HOME", bob.home.path().display())],
+
    )
+
    .run()
+
    .unwrap();
+
}
+

+
#[test]
#[ignore = "slow"]
fn rad_id_collaboration() {
    let mut environment = Environment::new();
@@ -610,7 +660,9 @@ fn rad_id_collaboration() {
    let bob = environment.node("bob");
    let eve = environment.node("eve");
    let seed = environment.seed("seed");
-
    let distrustful = environment.seed("distrustful");
+
    // On Mac OS X and BSD systems `SUN_LEN` is 104 bytes, "distrustful" was causing paths to be
+
    // 106, and as such this test will error. Renamed to shorter "bad".
+
    let distrustful = environment.seed("bad");
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();

    environment.repository(&alice);
modified crates/radicle/src/cob/identity.rs
@@ -129,6 +129,24 @@ pub enum ApplyError {
    Git(#[from] git::raw::Error),
    #[error("identity document error: {0}")]
    Doc(#[from] DocError),
+
    #[error("{author} is not a delegate, and only delegates are allowed to {action}")]
+
    NonDelegateUnauthorized { author: Did, action: String },
+
}
+

+
impl ApplyError {
+
    fn non_delegate_unauthorized(author: Did, action: &Action) -> Self {
+
        let action = match action {
+
            Action::Revision { .. } => "create a revision",
+
            Action::RevisionEdit { .. } => "edit a revision",
+
            Action::RevisionAccept { .. } => "accept a revision",
+
            Action::RevisionReject { .. } => "reject a revision",
+
            Action::RevisionRedact { .. } => "redact a revision",
+
        };
+
        Self::NonDelegateUnauthorized {
+
            author,
+
            action: action.to_string(),
+
        }
+
    }
}

/// Error updating or creating proposals.
@@ -444,8 +462,9 @@ impl Identity {
    ) -> Result<(), ApplyError> {
        let current = self.current().clone();

-
        if !current.is_delegate(&author.into()) {
-
            return Err(ApplyError::UnexpectedState);
+
        let did = author.into();
+
        if !current.is_delegate(&did) {
+
            return Err(ApplyError::non_delegate_unauthorized(did, &action));
        }
        match action {
            Action::RevisionAccept {
@@ -1396,6 +1415,7 @@ mod test {
                .unwrap()
        });
        // Eve's revision is active.
+
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2, e1]);
        assert!(eve_identity.revision(&e1).unwrap().is_active());

        //  b1      (Accept "Remove Eve") 2/2
@@ -1411,7 +1431,7 @@ mod test {
        eve_identity.reload().unwrap();
        // Now that Eve reloaded, since Bob's vote to remove Eve went through first (b1 < e1),
        // her revision is no longer valid.
-
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1]);
+
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1]);
        assert_eq!(eve_identity.revision(&e1), None);
        assert!(!eve_identity.is_delegate(&eve.signer.public_key().into()));
    }