diff --git a/checked_cli/Cargo.lock b/checked_cli/Cargo.lock index 7858fb1..5888a3a 100644 --- a/checked_cli/Cargo.lock +++ b/checked_cli/Cargo.lock @@ -843,6 +843,7 @@ dependencies = [ "tempfile", "tokio", "url 2.5.0", + "warp", ] [[package]] diff --git a/checked_cli/Cargo.toml b/checked_cli/Cargo.toml index 7423e00..8b34075 100644 --- a/checked_cli/Cargo.toml +++ b/checked_cli/Cargo.toml @@ -32,3 +32,4 @@ rpassword = "7.3.1" [dev-dependencies] holochain = { version = "0.3.0-beta-dev.43", default-features = false, features = ["sweetest"] } signing_keys_types = { path = "../types/signing_keys" } +warp = "0.3.6" diff --git a/checked_cli/src/cli.rs b/checked_cli/src/cli.rs index 2508150..df94232 100644 --- a/checked_cli/src/cli.rs +++ b/checked_cli/src/cli.rs @@ -154,9 +154,43 @@ pub struct FetchArgs { #[arg(long, short, default_value_t = String::from("default"))] pub name: String, + /// The directory or file to save the fetched asset to. + /// + /// When a directory is provided: + /// - The directory must exist + /// - The filename is taken from the last component in the fetch URL's path. + /// + /// When a file is provided: + /// - The directory containing the file, and any required parent directories, will be created. + /// + /// Defaults to the directory that the CLI is running in. + #[arg(long, short)] + pub output: Option, + + /// Provide a password on the command line instead of prompting for it. + /// + /// If this flag is not provided, then an interactive prompt is used to get the password. + /// + /// This is not recommended when using as a CLI flag because the password may stay in your + /// shell history. Use the interactive prompt instead if possible! + #[arg(long)] + pub password: Option, + /// The directory to find the signing key in. /// /// Defaults to `.config/checked` in your home directory. #[arg(long, short)] pub path: Option, + + /// Continue if no existing signatures are found. + /// + /// If this flag is not provided, then an interactive prompt is used to confirm. + #[arg(long, short)] + pub allow_no_signatures: Option, + + /// Sign the asset after downloading and publish the signature on Holochain. + /// + /// If this flag is not provided, then an interactive prompt is used to confirm. + #[arg(long, short)] + pub sign: Option, } diff --git a/checked_cli/src/distribute.rs b/checked_cli/src/distribute.rs index 6628887..309a5e7 100644 --- a/checked_cli/src/distribute.rs +++ b/checked_cli/src/distribute.rs @@ -8,7 +8,7 @@ use checked_types::{DistributeVfKeyRequest, VerificationKeyType}; use crate::cli::DistributeArgs; use crate::common::{get_store_dir, get_verification_key_path}; use crate::hc_client::{get_authenticated_app_agent_client, maybe_handle_holochain_error}; -use crate::password::GetPassword; +use crate::interactive::GetPassword; use crate::prelude::SignArgs; use crate::sign::sign; diff --git a/checked_cli/src/fetch.rs b/checked_cli/src/fetch.rs index 5ebc2cf..bac5def 100644 --- a/checked_cli/src/fetch.rs +++ b/checked_cli/src/fetch.rs @@ -1,6 +1,7 @@ use crate::cli::FetchArgs; use crate::common::{get_store_dir, get_verification_key_path}; use crate::hc_client; +use crate::interactive::GetPassword; use crate::prelude::SignArgs; use crate::sign::sign; use anyhow::Context; @@ -15,6 +16,8 @@ use std::path::PathBuf; use std::sync::atomic::AtomicUsize; use std::sync::Arc; use tempfile::NamedTempFile; +use url::Url; +use crate::hc_client::maybe_handle_holochain_error; struct FetchState { asset_size: AtomicUsize, @@ -22,8 +25,14 @@ struct FetchState { } pub async fn fetch(fetch_args: FetchArgs) -> anyhow::Result<()> { + let fetch_url = url::Url::parse(&fetch_args.url).context("Invalid URL")?; + println!("Fetching from {}", fetch_url); + + let output_path = get_output_path(&fetch_args, &fetch_url)?; + let mut app_client = - hc_client::get_authenticated_app_agent_client(fetch_args.port, fetch_args.path).await?; + hc_client::get_authenticated_app_agent_client(fetch_args.port, fetch_args.path.clone()) + .await?; // TODO if this fails because the credentials are no longer valid then we need a recovery mechanism that isn't `rm ~/.checked/credentials.json` let response = app_client @@ -37,27 +46,24 @@ pub async fn fetch(fetch_args: FetchArgs) -> anyhow::Result<()> { .unwrap(), ) .await - .map_err(|e| anyhow::anyhow!("Failed to get signatures for the asset: {:?}", e))?; + .map_err(|e| { + maybe_handle_holochain_error(&e, fetch_args.path.clone()); + anyhow::anyhow!("Failed to get signatures for the asset: {:?}", e) + })?; let response: Vec = response.decode()?; if response.is_empty() { println!("No signatures found for this asset. This is normal but please consider asking the author to create a signature!"); - let decision = dialoguer::Confirm::new() - .with_prompt("Download anyway?") - .interact()?; - - if !decision { + let allow = fetch_args.allow_no_signatures()?; + if !allow { return Ok(()); } } println!("Found {} signatures to check against", response.len()); - let fetch_url = url::Url::parse(&fetch_args.url).context("Invalid URL")?; - println!("Fetching from {}", fetch_url); - let mut tmp_file = tempfile::Builder::new() .prefix("checked-") .suffix(".unverified") @@ -115,31 +121,22 @@ pub async fn fetch(fetch_args: FetchArgs) -> anyhow::Result<()> { // TODO validate the signatures here and report - let file = fetch_url - .path_segments() - .ok_or_else(|| anyhow::anyhow!("Invalid URL"))? - .last() - .ok_or_else(|| anyhow::anyhow!("Invalid URL"))?; - std::fs::rename(path.clone(), file)?; - - let decision = dialoguer::Confirm::new() - .with_prompt("Sign this asset?") - .interact()?; + std::fs::rename(path.clone(), &output_path)?; - if !decision { + let should_sign = fetch_args.sign_asset()?; + if !should_sign { return Ok(()); } let signature_path = sign(SignArgs { name: fetch_args.name.clone(), - password: None, - path: None, - file: PathBuf::from(file), + password: Some(fetch_args.get_password()?), + path: fetch_args.path.clone(), + file: output_path, output: None, })?; - // TODO dir as arg - let store_dir = get_store_dir(None)?; + let store_dir = get_store_dir(fetch_args.path)?; let vk_path = get_verification_key_path(&store_dir, &fetch_args.name); app_client .call_zome( @@ -162,6 +159,35 @@ pub async fn fetch(fetch_args: FetchArgs) -> anyhow::Result<()> { Ok(()) } +fn get_output_path(fetch_args: &FetchArgs, fetch_url: &Url) -> anyhow::Result { + let guessed_file_name = fetch_url + .path_segments() + .ok_or_else(|| anyhow::anyhow!("Invalid URL"))? + .last() + .ok_or_else(|| anyhow::anyhow!("Invalid URL"))?; + + let output_path = match &fetch_args.output { + Some(output) => { + if output.is_dir() { + output.join(guessed_file_name) + } else { + let mut out = output.clone(); + out.pop(); + std::fs::create_dir_all(&out)?; + + output.clone() + } + } + None => { + let mut out = std::env::current_dir()?; + out.push(guessed_file_name); + out + } + }; + + Ok(output_path) +} + /// Download from `fetch_url` into `writer` and update `state` with the download progress. async fn run_download( fetch_url: url::Url, diff --git a/checked_cli/src/generate.rs b/checked_cli/src/generate.rs index 48d35ce..eb9c9c9 100644 --- a/checked_cli/src/generate.rs +++ b/checked_cli/src/generate.rs @@ -1,7 +1,7 @@ use crate::cli::{DistributeArgs, GenerateArgs}; use crate::common::{get_signing_key_path, get_store_dir, get_verification_key_path, open_file}; use crate::distribute::distribute; -use crate::password::GetPassword; +use crate::interactive::GetPassword; use minisign::KeyPair; use std::io::Write; use std::path::PathBuf; diff --git a/checked_cli/src/password.rs b/checked_cli/src/interactive.rs similarity index 51% rename from checked_cli/src/password.rs rename to checked_cli/src/interactive.rs index 14e31b4..c16eec9 100644 --- a/checked_cli/src/password.rs +++ b/checked_cli/src/interactive.rs @@ -1,4 +1,4 @@ -use crate::cli::{DistributeArgs, GenerateArgs, SignArgs}; +use crate::cli::{DistributeArgs, FetchArgs, GenerateArgs, SignArgs}; pub trait GetPassword { fn get_password(&self) -> anyhow::Result; @@ -28,6 +28,15 @@ impl GetPassword for DistributeArgs { } } +impl GetPassword for FetchArgs { + fn get_password(&self) -> anyhow::Result { + get_password_common( + self.password.as_ref(), + format!("Password for '{}': ", self.name), + ) + } +} + fn get_password_common( maybe_password: Option<&String>, prompt: impl ToString, @@ -37,3 +46,23 @@ fn get_password_common( None => Ok(rpassword::prompt_password(prompt)?), } } + +impl FetchArgs { + pub fn allow_no_signatures(&self) -> anyhow::Result { + match self.allow_no_signatures { + Some(allow_no_signatures) => Ok(allow_no_signatures), + None => Ok(dialoguer::Confirm::new() + .with_prompt("Download anyway?") + .interact()?), + } + } + + pub fn sign_asset(&self) -> anyhow::Result { + match self.sign { + Some(sign) => Ok(sign), + None => Ok(dialoguer::Confirm::new() + .with_prompt("Sign this asset?") + .interact()?), + } + } +} diff --git a/checked_cli/src/lib.rs b/checked_cli/src/lib.rs index aa54fe9..ffb3acd 100644 --- a/checked_cli/src/lib.rs +++ b/checked_cli/src/lib.rs @@ -5,7 +5,7 @@ mod distribute; mod fetch; pub mod generate; pub(crate) mod hc_client; -mod password; +mod interactive; pub mod sign; pub mod verify; diff --git a/checked_cli/src/sign.rs b/checked_cli/src/sign.rs index 5911b15..27a0db8 100644 --- a/checked_cli/src/sign.rs +++ b/checked_cli/src/sign.rs @@ -2,7 +2,7 @@ use crate::cli::SignArgs; use crate::common::{ get_signing_key_path, get_store_dir, get_verification_key_path, open_file, unix_timestamp, }; -use crate::password::GetPassword; +use crate::interactive::GetPassword; use minisign::{PublicKey, SecretKey}; use std::io::{BufReader, Write}; use std::path::PathBuf; diff --git a/checked_cli/tests/commands.rs b/checked_cli/tests/commands.rs index e38eeb3..4a482ef 100644 --- a/checked_cli/tests/commands.rs +++ b/checked_cli/tests/commands.rs @@ -1,9 +1,9 @@ -use std::fs::File; -use std::io::Write; use checked_cli::cli::VerifyArgs; use checked_cli::prelude::{generate, GenerateArgs, SignArgs}; use checked_cli::sign::sign; use checked_cli::verify::verify; +use std::fs::File; +use std::io::Write; // Generate a signing keypair, do not distribute #[tokio::test(flavor = "multi_thread")] @@ -40,7 +40,11 @@ async fn sign_file() -> anyhow::Result<()> { .await?; let test_file = dir.path().join("test.txt"); - File::options().write(true).create_new(true).open(&test_file)?.write_all(b"test")?; + File::options() + .write(true) + .create_new(true) + .open(&test_file)? + .write_all(b"test")?; let sig_path = sign(SignArgs { name: name.clone(), @@ -51,7 +55,10 @@ async fn sign_file() -> anyhow::Result<()> { })?; assert!(sig_path.exists()); - assert_eq!(test_file.to_str().unwrap().to_string() + ".minisig", sig_path.to_str().unwrap()); + assert_eq!( + test_file.to_str().unwrap().to_string() + ".minisig", + sig_path.to_str().unwrap() + ); Ok(()) } @@ -68,10 +75,14 @@ async fn verify_signed_file() -> anyhow::Result<()> { distribute: Some(false), path: Some(dir.as_ref().to_path_buf()), }) - .await?; + .await?; let test_file = dir.path().join("test.txt"); - File::options().write(true).create_new(true).open(&test_file)?.write_all(b"test")?; + File::options() + .write(true) + .create_new(true) + .open(&test_file)? + .write_all(b"test")?; sign(SignArgs { name: name.clone(), diff --git a/checked_cli/tests/happ_integration.rs b/checked_cli/tests/happ_integration.rs index 37afe1c..dd3e899 100644 --- a/checked_cli/tests/happ_integration.rs +++ b/checked_cli/tests/happ_integration.rs @@ -1,59 +1,80 @@ -use checked_cli::prelude::{distribute, generate, GenerateArgs}; -use holochain::sweettest::{SweetAgents, SweetConductor, SweetZome}; +use checked_cli::cli::{DistributeArgs, FetchArgs}; +use checked_cli::prelude::{distribute, fetch, generate, GenerateArgs}; +use holochain::core::AgentPubKey; +use holochain::sweettest::{SweetAgents, SweetConductor, SweetConductorHandle, SweetZome}; use holochain_conductor_api::{AdminInterfaceConfig, InterfaceDriver}; use holochain_types::app::InstallAppPayload; use holochain_types::prelude::AppBundleSource; use holochain_types::websocket::AllowedOrigins; use signing_keys_types::VfKeyResponse; use std::collections::HashMap; -use checked_cli::cli::DistributeArgs; +use std::net::SocketAddr; +use tokio::task::AbortHandle; // Generate a signing keypair, distribute it on Holochain #[tokio::test(flavor = "multi_thread")] async fn generate_signing_keypair() -> anyhow::Result<()> { let conductor = SweetConductor::from_standard_config().await; - let agent = SweetAgents::one(conductor.keystore()).await; + install_checked_app(conductor.sweet_handle(), "checked").await?; + let admin_port = add_admin_port(conductor.sweet_handle()).await?; - conductor - .clone() - .install_app_bundle(InstallAppPayload { - source: AppBundleSource::Path("../workdir/checked.happ".into()), - agent_key: agent, - installed_app_id: Some("checked".into()), - membrane_proofs: HashMap::with_capacity(0), - network_seed: None, - }) - .await?; + let dir = tempfile::tempdir()?; - conductor.clone().enable_app("checked".to_string()).await?; + generate(GenerateArgs { + name: "test_generate".to_string(), + port: Some(admin_port), + password: Some("test".to_string()), + distribute: Some(true), + path: Some(dir.as_ref().to_path_buf()), + }) + .await?; - let admin_port = conductor - .clone() - .add_admin_interfaces(vec![AdminInterfaceConfig { - driver: InterfaceDriver::Websocket { - port: 0, - allowed_origins: AllowedOrigins::Any, - }, - }]) + let zome = get_zome_handle(&conductor, "signing_keys"); + let keys: Vec = conductor + .call_fallible(&zome, "get_my_verification_key_distributions", ()) .await?; - let admin_port = admin_port.first().unwrap(); + + assert_eq!(1, keys.len()); + assert_eq!("test_generate", keys[0].verification_key_dist.name); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn distribute_existing_keypair() -> anyhow::Result<()> { + let conductor = SweetConductor::from_standard_config().await; + + install_checked_app(conductor.sweet_handle(), "checked").await?; + let admin_port = add_admin_port(conductor.sweet_handle()).await?; let dir = tempfile::tempdir()?; + let name = "test_generate".to_string(); generate(GenerateArgs { - name: "test_generate".to_string(), - port: Some(*admin_port), + name: name.clone(), + port: None, password: Some("test".to_string()), - distribute: Some(true), + distribute: Some(false), path: Some(dir.as_ref().to_path_buf()), }) .await?; - let cell_ids = conductor.running_cell_ids(); - let cell_id = cell_ids.iter().next().unwrap(); + let zome = get_zome_handle(&conductor, "signing_keys"); + + let keys: Vec = conductor + .call_fallible(&zome, "get_my_verification_key_distributions", ()) + .await?; + + assert_eq!(0, keys.len()); - let zome = SweetZome::new(cell_id.clone(), "signing_keys".into()); + distribute(DistributeArgs { + port: admin_port, + name, + password: Some("test".to_string()), + path: Some(dir.as_ref().to_path_buf()), + }) + .await?; let keys: Vec = conductor .call_fallible(&zome, "get_my_verification_key_distributions", ()) @@ -66,24 +87,67 @@ async fn generate_signing_keypair() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread")] -async fn distribute_existing_keypair() -> anyhow::Result<()> { +async fn create_first_asset_signature() -> anyhow::Result<()> { let conductor = SweetConductor::from_standard_config().await; - let agent = SweetAgents::one(conductor.keystore()).await; + install_checked_app(conductor.sweet_handle(), "checked").await?; + let admin_port = add_admin_port(conductor.sweet_handle()).await?; + + let dir = tempfile::tempdir()?; + + let name = "test_generate".to_string(); + generate(GenerateArgs { + name: name.clone(), + port: Some(admin_port), + password: Some("test".to_string()), + distribute: Some(true), + path: Some(dir.as_ref().to_path_buf()), + }) + .await?; + + let (addr, _fs_abort_handle) = start_sample_file_server().await; + let url = format!("http://{}:{}/test.txt", addr.ip(), addr.port()); + + fetch(FetchArgs { + url, + port: admin_port, + name, + output: Some(dir.as_ref().to_path_buf()), + password: Some("test".to_string()), + path: Some(dir.as_ref().to_path_buf()), + allow_no_signatures: Some(true), + sign: Some(true), + }) + .await?; + + // let zome = get_zome_handle(&conductor, "signing_keys"); + + Ok(()) +} + +async fn install_checked_app( + conductor: SweetConductorHandle, + app_id: &str, +) -> anyhow::Result { + let agent = SweetAgents::one(conductor.keystore().clone()).await; conductor .clone() .install_app_bundle(InstallAppPayload { source: AppBundleSource::Path("../workdir/checked.happ".into()), - agent_key: agent, - installed_app_id: Some("checked".into()), + agent_key: agent.clone(), + installed_app_id: Some(app_id.into()), membrane_proofs: HashMap::with_capacity(0), network_seed: None, }) .await?; - conductor.clone().enable_app("checked".to_string()).await?; + conductor.clone().enable_app(app_id.to_string()).await?; + + Ok(agent) +} +async fn add_admin_port(conductor: SweetConductorHandle) -> anyhow::Result { let admin_port = conductor .clone() .add_admin_interfaces(vec![AdminInterfaceConfig { @@ -93,38 +157,44 @@ async fn distribute_existing_keypair() -> anyhow::Result<()> { }, }]) .await?; - let admin_port = admin_port.first().unwrap(); - let dir = tempfile::tempdir()?; - - let name = "test_generate".to_string(); - generate(GenerateArgs { - name: name.clone(), - port: None, - password: Some("test".to_string()), - distribute: Some(false), - path: Some(dir.as_ref().to_path_buf()), - }) - .await?; + let admin_port = admin_port.first().unwrap(); - distribute(DistributeArgs { - port: *admin_port, - name, - password: Some("test".to_string()), - path: Some(dir.as_ref().to_path_buf()), - }).await?; + Ok(*admin_port) +} +fn get_zome_handle(conductor: &SweetConductor, zome_name: &str) -> SweetZome { let cell_ids = conductor.running_cell_ids(); let cell_id = cell_ids.iter().next().unwrap(); - let zome = SweetZome::new(cell_id.clone(), "signing_keys".into()); + let zome = SweetZome::new(cell_id.clone(), zome_name.into()); + zome +} - let keys: Vec = conductor - .call_fallible(&zome, "get_my_verification_key_distributions", ()) - .await?; +async fn start_sample_file_server() -> (SocketAddr, DropAbortHandle) { + use warp::Filter; - assert_eq!(1, keys.len()); - assert_eq!("test_generate", keys[0].verification_key_dist.name); + let (tx, rx) = tokio::sync::oneshot::channel::(); - Ok(()) + let join_handle = tokio::task::spawn(async move { + let test_txt = warp::path!("test.txt").map(|| "test"); + + let (addr, srv) = warp::serve(test_txt).bind_ephemeral(([127, 0, 0, 1], 0)); + + tx.send(addr).unwrap(); + + srv.await; + }); + + let addr = rx.await.unwrap(); + + (addr, DropAbortHandle(join_handle.abort_handle())) +} + +struct DropAbortHandle(AbortHandle); + +impl Drop for DropAbortHandle { + fn drop(&mut self) { + self.0.abort(); + } }