From b937a93892db3d80833b3cdde3bc37935151e274 Mon Sep 17 00:00:00 2001 From: Adrian Duke Date: Wed, 14 Jan 2026 18:24:51 +0000 Subject: [PATCH] radicle-cli: more helpful error for non-delegate updates When a non-delegate attempts to update the identity document, their action will be rejected. Provide a better error, along with a hint, to the non-delegate. --- CHANGELOG.md | 2 + .../examples/rad-id-unauthorized-delegate.md | 8 +++ crates/radicle-cli/src/commands/id.rs | 55 ++++++++++++++++++- crates/radicle-cli/tests/commands.rs | 50 +++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 crates/radicle-cli/examples/rad-id-unauthorized-delegate.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a9e7b5..7f92d953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `tags`, `notes`, `rad`, and `cobs`. The restriction is lifted, and the only references filtered out are `refs/tmp/heads` – used by `radicle-remote-helper` to create temporary patches. +- The `rad id` command will provide a better error message when a non-delegate + attempts to modify the identity document. ## Fixed Bugs diff --git a/crates/radicle-cli/examples/rad-id-unauthorized-delegate.md b/crates/radicle-cli/examples/rad-id-unauthorized-delegate.md new file mode 100644 index 00000000..b0dd76f4 --- /dev/null +++ b/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 is not a delegate! +``` + diff --git a/crates/radicle-cli/src/commands/id.rs b/crates/radicle-cli/src/commands/id.rs index 3b489c96..55eae25f 100644 --- a/crates/radicle-cli/src/commands/id.rs +++ b/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; @@ -193,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. @@ -420,13 +428,16 @@ fn update( doc: Doc, current: &mut IdentityMut, signer: &Device, + profile: &Profile, ) -> anyhow::Result where R: WriteRepository + cob::Store, G: crypto::signature::Signer, { 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"))?; @@ -437,6 +448,46 @@ 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::()) + .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 is not a delegate!", + labels.0, labels.1 + ), + ) + .into() + } + e @ radicle::cob::identity::ApplyError::Missing(_) + | e @ radicle::cob::identity::ApplyError::Init(_) + | e @ radicle::cob::identity::ApplyError::InvalidSignature(..) + | e @ radicle::cob::identity::ApplyError::NotAuthorized + | e @ radicle::cob::identity::ApplyError::MissingParent + | e @ radicle::cob::identity::ApplyError::DuplicateVerdict + | e @ radicle::cob::identity::ApplyError::UnexpectedState + | e @ radicle::cob::identity::ApplyError::Redacted + | e @ radicle::cob::identity::ApplyError::DocUnchanged + | e @ radicle::cob::identity::ApplyError::Git(_) + | e @ radicle::cob::identity::ApplyError::Doc(_) => { + anyhow!(e.to_string()) + } + } +} + fn print_diff( previous: Option<&RevisionId>, current: &RevisionId, diff --git a/crates/radicle-cli/tests/commands.rs b/crates/radicle-cli/tests/commands.rs index b76a7812..73f0638a 100644 --- a/crates/radicle-cli/tests/commands.rs +++ b/crates/radicle-cli/tests/commands.rs @@ -602,6 +602,56 @@ fn rad_id_multi_delegate() { .unwrap(); } +#[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() {