Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: add delegate cli
Fintan Halpenny committed 3 years ago
commit aa05f676f7acdd747b8f7593b8f57bc38e53a5b9
parent d7170ff9e7b2865cfdfc4b09b80b2ed252e1781e
11 files changed +367 -6
added radicle-cli/examples/rad-delegate.md
@@ -0,0 +1,46 @@
+
Delegates are the authorized keys that can manage a project's
+
metadata, including adding a new delegate.
+

+
Let's list the current set of delegates for a project.
+

+
```
+
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
[
+
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
]
+
```
+

+
We want to add a new maintainer to the project to help out with the
+
work.
+

+
```
+
$ rad delegate add z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
Added delegate 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG'
+
ok Update successful!
+
```
+

+
Let's convince ourselves that there's another delegate.
+

+
```
+
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
[
+
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
  "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
+
]
+
```
+

+
And finally, we no longer want to be part of the project so we pass on
+
the torch and remove ourselves from the delegate set.
+

+
```
+
$ rad delegate remove z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
Removed delegate 'did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
+
ok Update successful!
+
```
+

+
```
+
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
[
+
  "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
+
]
+
```
modified radicle-cli/src/commands.rs
@@ -6,6 +6,8 @@ pub mod rad_auth;
pub mod rad_checkout;
#[path = "commands/clone.rs"]
pub mod rad_clone;
+
#[path = "commands/delegate.rs"]
+
pub mod rad_delegate;
#[path = "commands/edit.rs"]
pub mod rad_edit;
#[path = "commands/help.rs"]
added radicle-cli/src/commands/delegate.rs
@@ -0,0 +1,141 @@
+
use std::ffi::OsString;
+
use std::str::FromStr;
+

+
use anyhow::{anyhow, Context as _};
+

+
use radicle::identity::Id;
+
use radicle_crypto::PublicKey;
+

+
use crate::terminal as term;
+
use crate::terminal::args::{Args, Error, Help};
+

+
#[path = "delegate/add.rs"]
+
mod add;
+
#[path = "delegate/list.rs"]
+
mod list;
+
#[path = "delegate/remove.rs"]
+
mod remove;
+

+
pub const HELP: Help = Help {
+
    name: "delegate",
+
    description: "Manage the delegates of an identity",
+
    version: env!("CARGO_PKG_VERSION"),
+
    usage: r#"
+
Usage
+

+
    rad delegate (add|remove) <public key> [--to <id>]
+
    rad delegate list [<id>]
+

+
    The `add` and `remove` commands are limited to managing delegates
+
    where the `threshold` for the quorum is exactly `1`. Otherwise,
+
    the verification of the document will not be able to gather enough
+
    signatures to pass the quorum.
+

+
Options
+

+
    --help              Print help
+
"#,
+
};
+

+
#[derive(Debug, Default, PartialEq, Eq)]
+
pub enum OperationName {
+
    Add,
+
    Remove,
+
    #[default]
+
    List,
+
}
+

+
#[derive(Debug, Eq, PartialEq)]
+
pub enum Operation {
+
    Add { id: Option<Id>, key: PublicKey },
+
    Remove { id: Option<Id>, key: PublicKey },
+
    List { id: Option<Id> },
+
}
+

+
#[derive(Debug, Eq, PartialEq)]
+
pub struct Options {
+
    pub op: Operation,
+
}
+

+
impl Args for Options {
+
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        use lexopt::prelude::*;
+

+
        let mut parser = lexopt::Parser::from_args(args);
+
        let mut id: Option<Id> = None;
+
        let mut op: Option<OperationName> = None;
+
        let mut key: Option<PublicKey> = None;
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                Long("help") => {
+
                    return Err(Error::Help.into());
+
                }
+
                Long("to") => {
+
                    id = Some(parser.value()?.parse::<Id>()?);
+
                }
+
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
+
                    "a" | "add" => op = Some(OperationName::Add),
+
                    "r" | "remove" => op = Some(OperationName::Remove),
+
                    "l" | "list" => op = Some(OperationName::List),
+

+
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
+
                },
+
                Value(val) if op.is_some() => {
+
                    let val = val.to_string_lossy();
+

+
                    match op {
+
                        Some(OperationName::Add) | Some(OperationName::Remove) => {
+
                            if let Ok(val) = PublicKey::from_str(&val) {
+
                                key = Some(val);
+
                            } else {
+
                                return Err(anyhow!("invalid Public Key '{}'", val));
+
                            }
+
                        }
+
                        Some(OperationName::List) => {
+
                            if let Ok(val) = Id::from_str(&val) {
+
                                id = Some(val);
+
                            } else {
+
                                return Err(anyhow!("invalid Project ID '{}'", val));
+
                            }
+
                        }
+
                        None => continue,
+
                    }
+
                }
+
                _ => return Err(anyhow!(arg.unexpected())),
+
            }
+
        }
+

+
        let op = match op.unwrap_or_default() {
+
            OperationName::List => Operation::List { id },
+
            OperationName::Add => Operation::Add {
+
                id,
+
                key: key.ok_or_else(|| anyhow!("a delegate key must be provided"))?,
+
            },
+
            OperationName::Remove => Operation::Remove {
+
                id,
+
                key: key.ok_or_else(|| anyhow!("a delegate key must be provided"))?,
+
            },
+
        };
+

+
        Ok((Options { op }, vec![]))
+
    }
+
}
+

+
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
    let profile = ctx.profile()?;
+
    let storage = &profile.storage;
+

+
    match options.op {
+
        Operation::Add { id, key } => add::run(&profile, storage, get_id(id)?, key)?,
+
        Operation::Remove { id, key } => remove::run(&profile, storage, get_id(id)?, &key)?,
+
        Operation::List { id } => list::run(&profile, storage, get_id(id)?)?,
+
    }
+

+
    Ok(())
+
}
+

+
fn get_id(id: Option<Id>) -> anyhow::Result<Id> {
+
    id.or_else(|| radicle::rad::cwd().ok().map(|(_, id)| id))
+
        .context("Couldn't get ID from either command line or cwd")
+
}
added radicle-cli/src/commands/delegate/add.rs
@@ -0,0 +1,51 @@
+
use anyhow::Context as _;
+
use radicle::{
+
    prelude::{Did, Id},
+
    storage::{WriteRepository as _, WriteStorage},
+
    Profile,
+
};
+
use radicle_crypto::PublicKey;
+

+
use crate::terminal as term;
+

+
pub fn run<S>(profile: &Profile, storage: &S, id: Id, key: PublicKey) -> anyhow::Result<()>
+
where
+
    S: WriteStorage,
+
{
+
    let signer = term::signer(profile)?;
+
    let me = signer.public_key();
+

+
    let mut project = storage
+
        .get(&profile.public_key, id)?
+
        .context("No project with such ID exists")?;
+

+
    let repo = storage.repository(id)?;
+

+
    if !project.is_delegate(me) {
+
        return Err(anyhow::anyhow!(
+
            "'{}' is not a delegate of the project, only a delegate may add this key",
+
            me
+
        ));
+
    }
+

+
    if project.threshold > 1 {
+
        return Err(anyhow::anyhow!("project threshold > 1"));
+
    }
+

+
    if project.delegate(&key) {
+
        project.sign(&signer).and_then(|(_, sig)| {
+
            project.update(
+
                signer.public_key(),
+
                "Updated payload",
+
                &[(signer.public_key(), sig)],
+
                repo.raw(),
+
            )
+
        })?;
+
        term::info!("Added delegate '{}'", Did::from(key));
+
        term::success!("Update successful!");
+
        Ok(())
+
    } else {
+
        term::info!("the delegate for '{}' already exists", key);
+
        Ok(())
+
    }
+
}
added radicle-cli/src/commands/delegate/list.rs
@@ -0,0 +1,16 @@
+
use anyhow::Context as _;
+
use radicle::{prelude::Id, storage::ReadStorage, Profile};
+

+
use crate::terminal as term;
+

+
pub fn run<S>(profile: &Profile, storage: &S, id: Id) -> anyhow::Result<()>
+
where
+
    S: ReadStorage,
+
{
+
    let project = storage
+
        .get(&profile.public_key, id)?
+
        .context("No project with such ID exists")?;
+

+
    term::info!("{}", serde_json::to_string_pretty(&project.delegates)?);
+
    Ok(())
+
}
added radicle-cli/src/commands/delegate/remove.rs
@@ -0,0 +1,54 @@
+
use anyhow::Context as _;
+
use radicle::{
+
    prelude::Id,
+
    storage::{WriteRepository as _, WriteStorage},
+
    Profile,
+
};
+
use radicle_crypto::PublicKey;
+

+
use crate::terminal as term;
+

+
pub fn run<S>(profile: &Profile, storage: &S, id: Id, key: &PublicKey) -> anyhow::Result<()>
+
where
+
    S: WriteStorage,
+
{
+
    let signer = term::signer(profile)?;
+
    let me = signer.public_key();
+

+
    let mut project = storage
+
        .get(&profile.public_key, id)?
+
        .context("No project with such ID exists")?;
+

+
    let repo = storage.repository(id)?;
+

+
    if !project.is_delegate(me) {
+
        return Err(anyhow::anyhow!(
+
            "'{}' is not a delegate of the project, only a delegate may remove this key",
+
            me
+
        ));
+
    }
+

+
    if project.threshold > 1 {
+
        return Err(anyhow::anyhow!("project threshold > 1"));
+
    }
+

+
    match project.rescind(key)? {
+
        Some(delegate) => {
+
            project.sign(&signer).and_then(|(_, sig)| {
+
                project.update(
+
                    signer.public_key(),
+
                    "Updated payload",
+
                    &[(signer.public_key(), sig)],
+
                    repo.raw(),
+
                )
+
            })?;
+
            term::info!("Removed delegate '{}'", delegate);
+
            term::success!("Update successful!");
+
            Ok(())
+
        }
+
        None => {
+
            term::info!("the delegate for '{}' did not exist", key);
+
            Ok(())
+
        }
+
    }
+
}
modified radicle-cli/src/main.rs
@@ -134,6 +134,14 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
+
        "delegate" => {
+
            term::run_command_args::<rad_delegate::Options, _>(
+
                rad_delegate::HELP,
+
                "Delegate",
+
                rad_delegate::run,
+
                args.to_vec(),
+
            );
+
        }
        "edit" => {
            term::run_command_args::<rad_edit::Options, _>(
                rad_edit::HELP,
modified radicle-cli/tests/commands.rs
@@ -72,3 +72,18 @@ fn rad_init() {

    test("examples/rad-init.md", Some(&profile)).unwrap();
}
+

+
#[test]
+
fn rad_delegate() {
+
    let home = tempfile::tempdir().unwrap();
+
    let working = tempfile::tempdir().unwrap();
+
    let profile = profile(home.path());
+

+
    // Setup a test repository.
+
    fixtures::repository(working.path());
+
    // Navigate to repository.
+
    env::set_current_dir(working.path()).unwrap();
+

+
    test("examples/rad-init.md", Some(&profile)).unwrap();
+
    test("examples/rad-delegate.md", Some(&profile)).unwrap();
+
}
modified radicle/src/identity.rs
@@ -213,7 +213,7 @@ mod test {
            .unwrap();

        // Add Bob as a delegate, and sign it.
-
        doc.delegate(*bob.public_key());
+
        doc.delegate(bob.public_key());
        doc.threshold = 2;
        doc.sign(&alice)
            .and_then(|(_, sig)| {
@@ -227,7 +227,7 @@ mod test {
            .unwrap();

        // Add Eve as a delegate, and sign it.
-
        doc.delegate(*eve.public_key());
+
        doc.delegate(eve.public_key());
        doc.sign(&alice)
            .and_then(|(_, alice_sig)| {
                doc.sign(&bob).and_then(|(_, bob_sig)| {
modified radicle/src/identity/did.rs
@@ -14,7 +14,7 @@ pub enum DidError {
    PublicKey(#[from] crypto::PublicKeyError),
}

-
#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone)]
+
#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy)]
#[serde(into = "String", try_from = "String")]
pub struct Did(crypto::PublicKey);

@@ -34,9 +34,15 @@ impl Did {
    }
}

+
impl From<&crypto::PublicKey> for Did {
+
    fn from(key: &crypto::PublicKey) -> Self {
+
        Self(*key)
+
    }
+
}
+

impl From<crypto::PublicKey> for Did {
    fn from(key: crypto::PublicKey) -> Self {
-
        Self(key)
+
        (&key).into()
    }
}

modified radicle/src/identity/doc.rs
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::fmt::Write as _;
use std::marker::PhantomData;
-
use std::ops::Deref;
+
use std::ops::{Deref, Not};
use std::path::Path;

use nonempty::NonEmpty;
@@ -150,6 +150,10 @@ impl<V> Doc<V> {
        repo.blob_at(commit, Path::new(&*PATH))
            .map_err(DocError::from)
    }
+

+
    pub fn is_delegate(&self, key: &crypto::PublicKey) -> bool {
+
        self.delegates.contains(&key.into())
+
    }
}

impl Doc<Verified> {
@@ -165,7 +169,7 @@ impl Doc<Verified> {
    }

    /// Attempt to add a new delegate to the document. Returns `true` if it wasn't there before.
-
    pub fn delegate(&mut self, key: crypto::PublicKey) -> bool {
+
    pub fn delegate(&mut self, key: &crypto::PublicKey) -> bool {
        let delegate = Did::from(key);
        if self.delegates.iter().all(|id| id != &delegate) {
            self.delegates.push(delegate);
@@ -174,6 +178,24 @@ impl Doc<Verified> {
        false
    }

+
    pub fn rescind(&mut self, key: &crypto::PublicKey) -> Result<Option<Did>, DocError> {
+
        let delegate = Did::from(key);
+
        let (matches, delegates) = self.delegates.iter().partition(|d| **d == delegate);
+
        match NonEmpty::from_vec(delegates) {
+
            Some(delegates) => {
+
                self.delegates = delegates;
+
                if self.threshold > self.delegates.len() {
+
                    return Err(DocError::Threshold(
+
                        self.threshold,
+
                        "the thresholds exceeds the new delegate count after removal",
+
                    ));
+
                }
+
                Ok(matches.is_empty().not().then_some(delegate))
+
            }
+
            None => Err(DocError::Delegates("cannot remove the last delegate")),
+
        }
+
    }
+

    /// Get the project payload, if it exists and is valid, out of this document.
    pub fn project(&self) -> Result<Project, PayloadError> {
        let value = self