diff --git a/Cargo.lock b/Cargo.lock index 0fa5796e..12ddf07a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,9 +205,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.77" +version = "0.1.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", @@ -318,9 +318,9 @@ dependencies = [ "bitflags 1.3.2", "bytes", "futures-util", - "http", - "http-body", - "hyper", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", "itoa", "matchit", "memchr", @@ -347,8 +347,8 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 0.2.11", + "http-body 0.4.6", "mime", "tower-layer", "tower-service", @@ -393,6 +393,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -537,7 +543,7 @@ version = "4.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.48", @@ -573,7 +579,7 @@ dependencies = [ "exitcode", "mockall", "pcr-sign", - "reqwest", + "reqwest 0.11.24", "serde", "serde_json", "thiserror", @@ -780,6 +786,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "der" version = "0.6.1" @@ -1018,6 +1034,7 @@ dependencies = [ name = "ev-cli" version = "0.1.0" dependencies = [ + "async-trait", "attestation-doc-validation", "atty", "chrono", @@ -1028,10 +1045,15 @@ dependencies = [ "ev-enclave", "exitcode", "human-panic", + "lazy_static", "log", + "regex", "semver", + "sentry", "serde", "serde_json", + "strum", + "strum_macros", "tempfile", "thiserror", "tokio", @@ -1070,7 +1092,7 @@ dependencies = [ "pcr-sign", "rcgen", "regex", - "reqwest", + "reqwest 0.11.24", "semver", "serde", "serde_cbor", @@ -1122,6 +1144,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "float-cmp" version = "0.9.0" @@ -1342,7 +1376,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.11", "indexmap 2.2.3", "slab", "tokio", @@ -1372,6 +1406,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -1417,6 +1457,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +dependencies = [ + "cfg-if", + "libc", + "windows", +] + [[package]] name = "http" version = "0.2.11" @@ -1428,6 +1479,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -1435,7 +1497,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.11", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" +dependencies = [ + "bytes", + "futures-core", + "http 1.1.0", + "http-body 1.0.0", "pin-project-lite", ] @@ -1490,8 +1575,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.11", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -1503,6 +1588,25 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1510,12 +1614,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.28", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.3.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "hyper 1.3.1", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.60" @@ -2327,9 +2467,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.3" +version = "1.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" dependencies = [ "aho-corasick", "memchr", @@ -2366,10 +2506,10 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", - "hyper", - "hyper-tls", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", + "hyper-tls 0.5.0", "ipnet", "js-sys", "log", @@ -2378,7 +2518,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", @@ -2393,7 +2533,47 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "winreg", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.3.1", + "hyper-tls 0.6.0", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 2.1.2", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.52.0", ] [[package]] @@ -2452,6 +2632,15 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2495,6 +2684,28 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64 0.22.1", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" + +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + [[package]] name = "ryu" version = "1.0.16" @@ -2583,6 +2794,114 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" +[[package]] +name = "sentry" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00421ed8fa0c995f07cde48ba6c89e80f2b312f74ff637326f392fbfd23abe02" +dependencies = [ + "httpdate", + "native-tls", + "reqwest 0.12.4", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a79194074f34b0cbe5dd33896e5928bbc6ab63a889bd9df2264af5acb186921e" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba8870c5dba2bfd9db25c75574a11429f6b95957b0a78ac02e2970dd7a5249a" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a75011ea1c0d5c46e9e57df03ce81f5c7f0a9e199086334a1f9c0a541e0826" +dependencies = [ + "once_cell", + "rand", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec2a486336559414ab66548da610da5e9626863c3c4ffca07d88f7dc71c8de8" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eaa3ecfa3c8750c78dcfd4637cfa2598b95b52897ed184b4dc77fcf7d95060d" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f715932bf369a61b7256687c6f0554141b7ce097287e30e3f7ed6e9de82498fe" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4519c900ce734f7a0eb7aba0869dfb225a7af8820634a7dd51449e3b093cfb7c" +dependencies = [ + "debugid", + "hex", + "rand", + "serde", + "serde_json", + "thiserror", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.199" @@ -2866,6 +3185,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.48", +] + [[package]] name = "subtle" version = "2.5.0" @@ -3194,8 +3535,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 0.2.11", + "http-body 0.4.6", "http-range-header", "pin-project-lite", "tower", @@ -3233,6 +3574,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "tracing-core", ] [[package]] @@ -3247,6 +3598,15 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -3302,6 +3662,19 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd" +dependencies = [ + "base64 0.22.1", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.0" @@ -3311,6 +3684,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -3326,8 +3700,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" dependencies = [ "getrandom", + "serde", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -3491,6 +3872,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets 0.52.0", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -3642,6 +4033,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "x509-parser" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index 3a4a264c..a48c9d9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] members = ["crates/*"] -resolver = "2" \ No newline at end of file +resolver = "2" diff --git a/crates/common/src/api/client.rs b/crates/common/src/api/client.rs index a4368553..ff70081f 100644 --- a/crates/common/src/api/client.rs +++ b/crates/common/src/api/client.rs @@ -114,6 +114,9 @@ pub trait ApiClient { AuthMode::NoAuth => request_builder, AuthMode::ApiKey(api_key) => request_builder.header("api-key", api_key), AuthMode::BearerAuth(token) => request_builder.bearer_auth(token), + AuthMode::BasicAuth((api_key, app_uuid)) => { + request_builder.basic_auth(app_uuid, Some(api_key)) + } } } } diff --git a/crates/common/src/api/mod.rs b/crates/common/src/api/mod.rs index a20d426b..b7ac0f66 100644 --- a/crates/common/src/api/mod.rs +++ b/crates/common/src/api/mod.rs @@ -1,11 +1,15 @@ pub mod assets; pub mod client; +pub mod papi; pub use reqwest::Client; +pub type BasicAuth = (String, String); + #[derive(Clone)] pub enum AuthMode { NoAuth, ApiKey(String), BearerAuth(String), + BasicAuth(BasicAuth), } diff --git a/crates/common/src/api/papi.rs b/crates/common/src/api/papi.rs new file mode 100644 index 00000000..8ed4d88f --- /dev/null +++ b/crates/common/src/api/papi.rs @@ -0,0 +1,79 @@ +use self::client::{ApiResult, HandleResponse}; +use crate::relay::{CreateRelay, Relay}; + +use super::*; +use super::{ + client::{ApiClient, ApiClientError, GenericApiClient}, + AuthMode, BasicAuth, +}; + +/// Client for Evervault API +pub struct EvApiClient { + inner: GenericApiClient, +} + +impl ApiClient for EvApiClient { + fn client(&self) -> &reqwest::Client { + self.inner.client() + } + + fn base_url(&self) -> String { + let domain = std::env::var("EV_DOMAIN").unwrap_or_else(|_| String::from("evervault.com")); + format!("https://api.{}", domain) + } + + fn auth(&self) -> &AuthMode { + self.inner.auth() + } + + fn update_auth(&mut self, _: AuthMode) -> Result<(), ApiClientError> { + Err(ApiClientError::AuthModeNotSupported) + } +} + +impl EvApiClient { + pub fn new(auth: BasicAuth) -> Self { + Self { + inner: GenericApiClient::from(AuthMode::BasicAuth(auth)), + } + } +} + +#[async_trait::async_trait] +#[cfg_attr(test, mockall::automock)] +pub trait EvApi { + async fn update_relay(&self, relay: &Relay) -> ApiResult; + async fn create_relay(&self, relay: &Relay) -> ApiResult; +} + +#[async_trait::async_trait] +impl EvApi for EvApiClient { + async fn update_relay(&self, relay: &Relay) -> ApiResult { + let update_relay_url = format!( + "{}/relays/{}", + self.base_url(), + relay.id.clone().expect("Relay ID is required") + ); + + self.patch(&update_relay_url) + .json(&CreateRelay { + encrypt_empty_strings: relay.encrypt_empty_strings, + authentication: relay.authentication.clone(), + routes: relay.routes.clone(), + }) + .send() + .await + .handle_json_response() + .await + } + + async fn create_relay(&self, relay: &Relay) -> ApiResult { + let create_relay_url = format!("{}/relays", self.base_url()); + self.post(&create_relay_url) + .json(&relay) + .send() + .await + .handle_json_response() + .await + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 99830c4e..3361e48d 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,6 +1,6 @@ pub mod api; pub mod enclave; - +pub mod relay; pub trait CliError { fn exitcode(&self) -> exitcode::ExitCode; } diff --git a/crates/common/src/relay/mod.rs b/crates/common/src/relay/mod.rs new file mode 100644 index 00000000..b52217fb --- /dev/null +++ b/crates/common/src/relay/mod.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Debug, Clone, Serialize)] +pub struct RelaySelections { + r#type: String, + #[serde(skip_serializing_if = "Option::is_none")] + role: Option, + selector: String, +} + +#[derive(Deserialize, Debug, Clone, Serialize)] +pub struct RelayAction { + action: String, + selections: Vec, +} + +#[derive(Deserialize, Debug, Serialize)] +pub struct RelayResponse { + method: String, + path: String, + relay: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct RelayRoutes { + method: Option, + path: String, + request: Option>, + response: Option>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Relays { + pub data: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Relay { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + pub destination_domain: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub evervault_domain: Option, + pub encrypt_empty_strings: bool, + pub authentication: Option, + pub routes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub app: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateRelay { + pub encrypt_empty_strings: bool, + pub authentication: Option, + pub routes: Vec, +} diff --git a/crates/ev-cli/Cargo.toml b/crates/ev-cli/Cargo.toml index 0fa7f267..7290a128 100644 --- a/crates/ev-cli/Cargo.toml +++ b/crates/ev-cli/Cargo.toml @@ -1,26 +1,32 @@ [package] +edition = "2021" name = "ev-cli" version = "0.1.0" -edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +async-trait = "0.1.80" +attestation-doc-validation = "0.7.4" atty = "0.2.14" -clap = { version = "4.5.4", features = ["derive"] } -serde = { version= "1.0.199", features = ["derive"] } -serde_json = "1.0.116" -thiserror = "1.0.59" -tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros", "fs"] } -human-panic = "1.0.3" -log = "0.4.17" +chrono = "0.4.19" +clap = {version = "4.5.4", features = ["derive"]} +common = {path = "../common"} +dialoguer = "0.10.2" env_logger = "0.9.0" +ev-enclave = {path = "../ev-enclave"} exitcode = "1.1.2" -dialoguer = "0.10.2" -attestation-doc-validation = "0.7.4" -ev-enclave = { path = "../ev-enclave" } -common = { path = "../common" } -chrono = "0.4.19" -toml = "0.5.9" +human-panic = "1.0.3" +lazy_static = "1.4.0" +log = "0.4.17" +regex = "1.10.4" semver = "1.0.20" +sentry = "0.32.3" +serde = {version = "1.0.199", features = ["derive"]} +serde_json = "1.0.116" +strum = { version = "0.26.2", features = [ "derive", "strum_macros" ]} +strum_macros = "0.26.2" tempfile = "3.3.0" +thiserror = "1.0.59" +tokio = {version = "1.37.0", features = ["rt", "rt-multi-thread", "macros", "fs"]} +toml = "0.5.9" diff --git a/crates/ev-cli/src/auth.rs b/crates/ev-cli/src/auth.rs index ef24cfee..2c7fdc86 100644 --- a/crates/ev-cli/src/auth.rs +++ b/crates/ev-cli/src/auth.rs @@ -1,32 +1,29 @@ -#[macro_export] -macro_rules! get_auth { - () => { - match (std::env::var("EV_API_KEY"), std::env::var("EV_APP_UUID")) { - (Ok(api_key), Ok(app_uuid)) => (api_key, app_uuid), - (Err(_), Err(_)) => { - log::error!( - "No App UUID or API key found. Make sure you have correctly set the \ +pub fn get_auth() -> (String, String) { + match (std::env::var("EV_API_KEY"), std::env::var("EV_APP_UUID")) { + (Ok(api_key), Ok(app_uuid)) => (api_key, app_uuid), + (Err(_), Err(_)) => { + log::error!( + "No App UUID or API key found. Make sure you have correctly set the \ EV_APP_UUID and EV_API_KEY environment variables. See \ https://docs.evervault.com/sdks/cli for more information." - ); - return crate::errors::NOUSER; - } - (Err(_), _) => { - log::error!( - "No API Key found. Make sure you have correctly set the EV_API_KEY \ + ); + std::process::exit(crate::errors::NOUSER); + } + (Err(_), _) => { + log::error!( + "No API Key found. Make sure you have correctly set the EV_API_KEY \ environment variable. See https://docs.evervault.com/sdks/cli for more \ information." - ); - return crate::errors::NOUSER; - } - (_, Err(_)) => { - log::error!( - "No App UUID found. Make sure you have correctly set the EV_APP_UUID \ + ); + std::process::exit(crate::errors::NOUSER); + } + (_, Err(_)) => { + log::error!( + "No App UUID found. Make sure you have correctly set the EV_APP_UUID \ environment variable. See https://docs.evervault.com/sdks/cli for more \ information." - ); - return crate::errors::NOUSER; - } + ); + std::process::exit(crate::errors::NOUSER); } - }; + } } diff --git a/crates/ev-cli/src/commands/enclave/mod.rs b/crates/ev-cli/src/commands/enclave/mod.rs index 4d73c5e2..ea4de7af 100644 --- a/crates/ev-cli/src/commands/enclave/mod.rs +++ b/crates/ev-cli/src/commands/enclave/mod.rs @@ -22,7 +22,6 @@ pub struct EnclaveArgs { } #[derive(Parser, Debug)] -#[command(name = "enclave")] pub enum EnclaveCommand { #[cfg(not(target_os = "windows"))] Attest(attest::AttestArgs), @@ -46,8 +45,8 @@ pub enum AuthenticatedEnclaveCommand { Scale(scale::ScaleArgs), } -pub async fn run(enclave_args: EnclaveArgs) -> i32 { - match enclave_args.action { +pub async fn run(enclave_args: EnclaveArgs) { + let exitcode = match enclave_args.action { EnclaveCommand::Build(build_args) => build::run(build_args).await, EnclaveCommand::Describe(describe_args) => describe::run(describe_args).await, EnclaveCommand::Migrate(migrate_args) => migrate::run(migrate_args).await, @@ -55,7 +54,7 @@ pub async fn run(enclave_args: EnclaveArgs) -> i32 { EnclaveCommand::Attest(attest_args) => attest::run(attest_args).await, EnclaveCommand::Update(update_args) => update::run(update_args).await, EnclaveCommand::Authenticated(authenticated_command) => { - let (api_key, _) = crate::get_auth!(); + let (api_key, _) = crate::get_auth(); match authenticated_command { AuthenticatedEnclaveCommand::Cert(cert_args) => cert::run(cert_args, api_key).await, @@ -76,5 +75,7 @@ pub async fn run(enclave_args: EnclaveArgs) -> i32 { } } } - } + }; + + std::process::exit(exitcode); } diff --git a/crates/ev-cli/src/commands/interact.rs b/crates/ev-cli/src/commands/interact.rs new file mode 100644 index 00000000..9052f9da --- /dev/null +++ b/crates/ev-cli/src/commands/interact.rs @@ -0,0 +1,101 @@ +use dialoguer::Input; + +use crate::theme::CliTheme; + +pub mod validators { + use lazy_static; + use regex::Regex; + use std::fmt::{Debug, Display}; + + #[derive(Debug)] + pub enum ValidationError { + InvalidHostname, + InvalidCustomDomain, + InvalidDestinationDomain, + } + impl Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + #[allow(unreachable_patterns)] + match self { + Self::InvalidHostname => write!(f, "Value must be a valid HTTPS domain"), + Self::InvalidCustomDomain => write!( + f, + "Invalid custom domain. You should not use a \ + top-level domain name, or include a protocol or path" + ), + Self::InvalidDestinationDomain => write!( + f, + "Destination Domain must be a valid hostname and not contain a protocol or path." + ), + _ => write!(f, "{:?}", self), + } + } + } + + pub type GenericValidator = dyn Fn(&String) -> Result<(), ValidationError>; + pub fn is_valid_hostname(input: &String) -> Result<(), ValidationError> { + lazy_static::lazy_static!( + static ref VALID_HOST_REGEX: Regex = Regex::new( + r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(https://)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$" + ).unwrap(); + ); + match VALID_HOST_REGEX.is_match(input) { + true => Ok(()), + _ => Err(ValidationError::InvalidHostname), + } + } + + pub fn is_valid_custom_domain(input: &String) -> Result<(), ValidationError> { + lazy_static::lazy_static!( + static ref VALID_CUSTOM_DOMAIN_REGEX: Regex = Regex::new( + // top-level domains not supported + r"(?i)^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.){2,}([A-Za-z]|[A-Za-z][A-Za-z0-9-]*[A-Za-z0-9])$" + ).unwrap(); + ); + match VALID_CUSTOM_DOMAIN_REGEX.is_match(input) { + true => Ok(()), + _ => Err(ValidationError::InvalidCustomDomain), + } + } + + pub fn is_valid_destination_domain(input: &String) -> Result<(), ValidationError> { + lazy_static::lazy_static!( + static ref VALID_DESTINATION_DOMAIN_REGEX: Regex = Regex::new( + r"/^(?:(?:\*?(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|\*\*?)\.){3}(?:\*?(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|\*\*?)$|^(?:(?:\*?(?:[a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])|\*\*?)\.)+(?:\*?(?:[a-z]|[a-z][a-z0-9\-]*[a-z0-9])|\*\*?)$|^\*\*?$/i" + ).unwrap(); + ); + + match VALID_DESTINATION_DOMAIN_REGEX.is_match(input) { + true => Ok(()), + _ => Err(ValidationError::InvalidDestinationDomain), + } + } +} + +pub fn input(prompt: Option, allow_empty: bool) -> Option { + let theme = CliTheme::default(); + let mut input: Input = Input::with_theme(&theme); + if let Some(prompt) = prompt { + input.with_prompt(prompt); + } + + input.allow_empty(allow_empty).interact().ok() +} + +pub fn validated_input( + prompt: Option, + allow_empty: bool, + validator: Box, +) -> Option { + let theme = CliTheme::default(); + let mut input: Input = Input::with_theme(&theme); + if let Some(prompt) = prompt { + input.with_prompt(prompt); + } + + input + .allow_empty(allow_empty) + .validate_with(validator) + .interact() + .ok() +} diff --git a/crates/ev-cli/src/commands/mod.rs b/crates/ev-cli/src/commands/mod.rs index 24888a85..8ff1b6b4 100644 --- a/crates/ev-cli/src/commands/mod.rs +++ b/crates/ev-cli/src/commands/mod.rs @@ -1,25 +1,26 @@ -use clap::Parser; - use crate::BaseArgs; +use clap::Parser; -use self::enclave::EnclaveArgs; +use self::{enclave::EnclaveArgs, relay::RelayArgs}; mod enclave; +mod interact; +mod relay; #[derive(Parser, Debug)] pub enum Command { Enclave(EnclaveArgs), + Relay(RelayArgs), } -pub async fn run_command(base_args: BaseArgs) -> i32 { +pub async fn run_command(base_args: BaseArgs) { if let Err(e) = crate::version::check_version().await { log::error!("{}", e); - return exitcode::SOFTWARE; + std::process::exit(exitcode::SOFTWARE); }; - let exit_code = match base_args.command { + match base_args.command { Command::Enclave(enclave_args) => enclave::run(enclave_args).await, - }; - - exit_code + Command::Relay(relay_args) => relay::run(relay_args).await, + } } diff --git a/crates/ev-cli/src/commands/relay/create.rs b/crates/ev-cli/src/commands/relay/create.rs new file mode 100644 index 00000000..d06b3dfc --- /dev/null +++ b/crates/ev-cli/src/commands/relay/create.rs @@ -0,0 +1,124 @@ +use crate::CmdOutput; +use clap::Parser; +use common::{ + api::{ + client::ApiError, + papi::{self, EvApi}, + BasicAuth, + }, + relay::Relay, +}; +use std::path::PathBuf; +use thiserror::Error; + +use crate::commands::interact::{self, validated_input}; +/// Creates an Evervault Relay and generates its configuration file +#[derive(Parser, Debug)] +#[command(name = "create")] +pub struct CreateArgs { + /// Path to write relay.json to. Defaults to relay.json + #[arg(short = 'o', long = "out", default_value = "relay.json")] + pub out: String, + #[arg(short = 'f', long = "force", default_value = "false")] + pub force: bool, +} + +#[derive(Error, Debug)] +pub enum CreateError { + #[error( + "A Relay configuration file already exists at the path: {0}, use the --force parameter to overwrite the existing file" + )] + FileAlreadyExists(String), + #[error("An error occured while writing the Relay configuration file: {0}]")] + WriteError(#[from] std::io::Error), + #[error( + "A domain must be chosen to create a Relay. Use the --domain flag to provide one ahead of time." + )] + NoDomain, + #[error("An error occurred while creating the relay: {0}")] + ApiError(#[from] ApiError), + #[error("An error occured while parsing the relay configuration: {0}")] + ParseError(#[from] serde_json::Error), +} + +impl CmdOutput for CreateError { + fn code(&self) -> String { + match self { + CreateError::FileAlreadyExists(_) => "relay-file-already-exists", + CreateError::WriteError(_) => "relay-write-error", + CreateError::NoDomain => "relay-no-domain", + CreateError::ApiError(_) => "relay-api-error", + CreateError::ParseError(_) => "relay-parse-error", + } + .to_string() + } + + fn exitcode(&self) -> crate::errors::ExitCode { + match self { + CreateError::WriteError(_) => crate::errors::CANTCREAT, + _ => crate::errors::GENERAL, + } + } +} + +#[derive(strum_macros::Display, Debug)] +pub enum CreateMessage { + #[strum(to_string = "Relay configuration saved to file {0}")] + FileWritten(String), +} + +impl CmdOutput for CreateMessage { + fn code(&self) -> String { + match self { + CreateMessage::FileWritten(_) => "relay-file-written".to_string(), + } + } + + fn exitcode(&self) -> crate::errors::ExitCode { + crate::errors::OK + } +} + +#[derive(strum_macros::Display)] +pub enum CreatePrompt { + #[strum( + to_string = "Where should we forward requests to? This can be any domain that accepts HTTPS requests." + )] + WhichDomain, +} + +pub async fn run(args: CreateArgs, auth: BasicAuth) -> Result { + let path = PathBuf::from(&args.out); + + if let Ok(exists) = path.try_exists() { + if exists & !args.force { + return Err(CreateError::FileAlreadyExists(args.out)); + } + } + + let domain = validated_input( + Some(CreatePrompt::WhichDomain.to_string()), + false, + Box::new(interact::validators::is_valid_destination_domain), + ) + .ok_or(CreateError::NoDomain)?; + + let relay_req_body = Relay { + id: None, + destination_domain: domain, + routes: vec![], + evervault_domain: None, + encrypt_empty_strings: true, + authentication: None, + app: None, + }; + + let api_client = papi::EvApiClient::new(auth); + + let relay = api_client.create_relay(&relay_req_body).await?; + let config_to_write = serde_json::to_string_pretty(&relay)?; + + std::fs::write(&path, config_to_write)?; + + Ok(CreateMessage::FileWritten(args.out)) +} diff --git a/crates/ev-cli/src/commands/relay/deploy.rs b/crates/ev-cli/src/commands/relay/deploy.rs new file mode 100644 index 00000000..c69d1927 --- /dev/null +++ b/crates/ev-cli/src/commands/relay/deploy.rs @@ -0,0 +1,77 @@ +use crate::relay::RelayConfig; +use crate::CmdOutput; +use clap::Parser; +use common::api::{papi::EvApi, BasicAuth}; +use strum::{Display, EnumString}; +use thiserror::Error; +/// Deploy your Evervault Relay +#[derive(Parser, Debug)] +#[command(name = "deploy", about)] +pub struct DeployArgs { + /// The file containing the relay config you want to use. Defaults to relay.json + #[arg(short = 'f', long = "file", default_value = "relay.json")] + file: String, +} + +#[derive(Debug, Error)] +pub enum DeployError { + #[error(transparent)] + RelayConfigError(#[from] crate::relay::RelayConfigError), + #[error("An unexpected API error occured when deploying your relay {0}")] + ApiError(#[from] common::api::client::ApiError), +} + +impl CmdOutput for DeployError { + fn code(&self) -> String { + match self { + DeployError::RelayConfigError(_) => "relay-config-error", + DeployError::ApiError(_) => "relay-deploy-error", + } + .to_string() + } + + fn exitcode(&self) -> crate::errors::ExitCode { + match self { + DeployError::RelayConfigError(_) => crate::errors::CONFIG, + DeployError::ApiError(_) => crate::errors::GENERAL, + } + } +} + +#[derive(Display, EnumString)] +pub enum DeployMessage { + #[strum(to_string = "Relay successfully deployed with destination {domain}")] + Success { domain: String }, + #[strum(to_string = "Relay successfully created with destination {domain}")] + NewRelayCreated { domain: String }, +} + +impl CmdOutput for DeployMessage { + fn code(&self) -> String { + match self { + DeployMessage::Success { .. } => "relay-deployed".to_string(), + DeployMessage::NewRelayCreated { .. } => "relay-created".to_string(), + } + } + + fn exitcode(&self) -> crate::errors::ExitCode { + crate::errors::OK + } +} + +pub async fn run(args: DeployArgs, auth: BasicAuth) -> Result { + let relay_config = RelayConfig::try_from(&args.file.clone().into())?; + let api_client = common::api::papi::EvApiClient::new(auth); + let update = api_client.update_relay(&relay_config.relay).await; + + if update.is_err() { + let relay = api_client.create_relay(&relay_config.relay).await?; + return Ok(DeployMessage::NewRelayCreated { + domain: relay.destination_domain, + }); + } else { + return Ok(DeployMessage::Success { + domain: update.expect("infallible").destination_domain, + }); + } +} diff --git a/crates/ev-cli/src/commands/relay/mod.rs b/crates/ev-cli/src/commands/relay/mod.rs new file mode 100644 index 00000000..5efa86f3 --- /dev/null +++ b/crates/ev-cli/src/commands/relay/mod.rs @@ -0,0 +1,28 @@ +use clap::Parser; + +pub mod create; +pub mod deploy; +use crate::run_cmd; + +#[derive(Parser, Debug)] +#[command(name = "relay")] +pub struct RelayArgs { + #[command(subcommand)] + pub action: RelayCommand, +} + +#[derive(Parser, Debug)] +#[command(name = "relay")] +pub enum RelayCommand { + Create(create::CreateArgs), + Deploy(deploy::DeployArgs), +} + +pub async fn run(args: RelayArgs) { + let auth = crate::get_auth(); + + match args.action { + RelayCommand::Create(create_args) => run_cmd(create::run(create_args, auth).await), + RelayCommand::Deploy(deploy_args) => run_cmd(deploy::run(deploy_args, auth).await), + } +} diff --git a/crates/ev-cli/src/errors.rs b/crates/ev-cli/src/errors.rs index e578807b..9bf044d3 100644 --- a/crates/ev-cli/src/errors.rs +++ b/crates/ev-cli/src/errors.rs @@ -6,6 +6,9 @@ pub type ExitCode = i32; /// Successful exit pub const OK: ExitCode = 0; +/// Catchall for general errors +pub const GENERAL: ExitCode = 1; + /// The command was used incorrectly, e.g., with the /// wrong number of arguments, a bad flag, a bad syntax /// in a parameter, etc. diff --git a/crates/ev-cli/src/main.rs b/crates/ev-cli/src/main.rs index c4627418..610a0285 100644 --- a/crates/ev-cli/src/main.rs +++ b/crates/ev-cli/src/main.rs @@ -10,8 +10,47 @@ use std::io::Write; mod auth; mod commands; mod errors; +mod relay; +mod theme; +mod tty; mod version; +pub use auth::get_auth; + +pub trait CmdOutput: std::fmt::Display { + fn code(&self) -> String; + + fn exitcode(&self) -> crate::errors::ExitCode; +} + +pub fn run_cmd(r: Result) -> ! { + match r { + Ok(output) => crate::print_and_exit(output), + // TODO(Mark): do something on error + Err(e) => crate::print_and_exit(e), + } +} + +pub fn print_and_exit(output: T) -> ! +where + T: CmdOutput, +{ + let base_args = BaseArgs::parse(); + + let msg = if base_args.json { + serde_json::json!({ + "message": output.to_string(), + "code": output.code(), + }) + .to_string() + } else { + output.to_string() + }; + + println!("{}", msg); + std::process::exit(output.exitcode()); +} + #[derive(Debug, Parser)] #[clap(name = "Evervault Enclave CLI", version)] pub struct BaseArgs { @@ -39,8 +78,8 @@ async fn main() { let base_args: BaseArgs = BaseArgs::parse(); setup_logger(base_args.verbose); - let exit_code = commands::run_command(base_args).await; - std::process::exit(exit_code); + setup_sentry(); + commands::run_command(base_args).await; } fn setup_logger(verbose_logging: bool) { @@ -81,3 +120,15 @@ fn setup_logger(verbose_logging: bool) { } builder.format(log_formatter).init(); } + +fn setup_sentry() { + if cfg!(not(debug_assertions)) { + let _ = sentry::init(( + "https://7930c2e61c1642bca8518bdadf37b78b@o359326.ingest.sentry.io/5799012", + sentry::ClientOptions { + release: sentry::release_name!(), + ..Default::default() + }, + )); + } +} diff --git a/crates/ev-cli/src/relay/mod.rs b/crates/ev-cli/src/relay/mod.rs new file mode 100644 index 00000000..6c5fc786 --- /dev/null +++ b/crates/ev-cli/src/relay/mod.rs @@ -0,0 +1,37 @@ +use common::relay::Relay; +use std::{fs, io}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RelayConfigError { + #[error( + "Relay configuration could not be found at {0}, specify a relay config file \ + with the --file flag. Or create a relay with ev relay create." + )] + ConfigNotFound(String), + #[error("Error reading relay config file: {0}")] + IoError(#[from] io::Error), + #[error("Error parsing relay config file: {0}")] + ParseError(#[from] serde_json::Error), +} + +pub struct RelayConfig { + pub relay: Relay, +} + +impl TryFrom<&std::path::PathBuf> for RelayConfig { + type Error = RelayConfigError; + + fn try_from(path: &std::path::PathBuf) -> Result { + if !path.try_exists()? { + return Err(RelayConfigError::ConfigNotFound( + path.to_string_lossy().to_string(), + )); + } + + let relay_config_str = fs::read_to_string(path)?; + Ok(RelayConfig { + relay: serde_json::from_str::(&relay_config_str)?, + }) + } +} diff --git a/crates/ev-cli/src/theme.rs b/crates/ev-cli/src/theme.rs new file mode 100644 index 00000000..1617c501 --- /dev/null +++ b/crates/ev-cli/src/theme.rs @@ -0,0 +1,394 @@ +use dialoguer::console::{style, Style, StyledObject}; +use dialoguer::theme::Theme; +use std::fmt; + +/// no color used if: +/// - NO_COLOR is set (regardless of value) +/// - EV_NO_COLOR is set (regardless of value) +/// - TERM is set to `dumb` +fn has_color_env_vars() -> bool { + std::env::var("NO_COLOR").is_ok() + || std::env::var("EV_NO_COLOR").is_ok() + || std::env::var("TERM") + .ok() + .map(|term_mode| term_mode == "dumb".to_string()) + .unwrap_or(false) +} + +pub struct CliTheme { + /// The style for default values + pub defaults_style: Style, + /// The style for prompt + pub prompt_style: Style, + /// Prompt prefix value and style + pub prompt_prefix: StyledObject, + /// Prompt suffix value and style + pub prompt_suffix: StyledObject, + /// Prompt on success prefix value and style + pub success_prefix: StyledObject, + /// Prompt on success suffix value and style + pub success_suffix: StyledObject, + /// Error prefix value and style + pub error_prefix: StyledObject, + /// The style for error message + pub error_style: Style, + /// The style for hints + pub hint_style: Style, + /// The style for values on prompt success + pub values_style: Style, + /// The style for active items + pub active_item_style: Style, + /// The style for inactive items + pub inactive_item_style: Style, + /// Active item in select prefix value and style + pub active_item_prefix: StyledObject, + /// Inctive item in select prefix value and style + pub inactive_item_prefix: StyledObject, + /// Checked item in multi select prefix value and style + pub checked_item_prefix: StyledObject, + /// Unchecked item in multi select prefix value and style + pub unchecked_item_prefix: StyledObject, + /// Picked item in sort prefix value and style + pub picked_item_prefix: StyledObject, + /// Unpicked item in sort prefix value and style + pub unpicked_item_prefix: StyledObject, + /// Show the selections from certain prompts inline + pub inline_selections: bool, +} + +impl Default for CliTheme { + fn default() -> CliTheme { + let report = crate::tty::AttyReport::default(); + if !report.stdout || has_color_env_vars() { + CliTheme { + defaults_style: Style::new().for_stderr(), + prompt_style: Style::new().for_stderr(), + prompt_prefix: style("?".to_string()).for_stderr(), + prompt_suffix: style("›".to_string()).for_stderr(), + success_prefix: style("✔".to_string()).for_stderr(), + success_suffix: style("·".to_string()).for_stderr(), + error_prefix: style("✘".to_string()).for_stderr().red(), + error_style: Style::new().for_stderr().red(), + hint_style: Style::new().for_stderr(), + values_style: Style::new().for_stderr(), + active_item_style: Style::new().for_stderr(), + inactive_item_style: Style::new().for_stderr(), + active_item_prefix: style("❯".to_string()).for_stderr(), + inactive_item_prefix: style(" ".to_string()).for_stderr(), + checked_item_prefix: style("✔".to_string()).for_stderr(), + unchecked_item_prefix: style("✔".to_string()).for_stderr(), + picked_item_prefix: style("❯".to_string()).for_stderr(), + unpicked_item_prefix: style(" ".to_string()).for_stderr(), + inline_selections: true, + } + } else { + CliTheme { + defaults_style: Style::new().for_stderr().cyan(), + prompt_style: Style::new().for_stderr().green().bold(), + prompt_prefix: style("?".to_string()).for_stderr().green().dim(), + prompt_suffix: style("›".to_string()).for_stderr().black().bright(), + success_prefix: style("✔".to_string()).for_stderr().green().dim(), + success_suffix: style("·".to_string()).for_stderr().black().bright(), + error_prefix: style("✘".to_string()).for_stderr().red(), + error_style: Style::new().for_stderr().red(), + hint_style: Style::new().for_stderr().black().bright(), + values_style: Style::new().for_stderr().cyan().dim(), + active_item_style: Style::new().for_stderr().bold(), + inactive_item_style: Style::new().for_stderr(), + active_item_prefix: style("❯".to_string()).for_stderr().bold(), + inactive_item_prefix: style(" ".to_string()).for_stderr(), + checked_item_prefix: style("✔".to_string()).for_stderr().green(), + unchecked_item_prefix: style("✔".to_string()).for_stderr().black(), + picked_item_prefix: style("❯".to_string()).for_stderr().green(), + unpicked_item_prefix: style(" ".to_string()).for_stderr(), + inline_selections: true, + } + } + } +} + +impl Theme for CliTheme { + /// Formats a prompt. + fn format_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { + if !prompt.is_empty() { + write!( + f, + "{} {} ", + &self.prompt_prefix, + self.prompt_style.apply_to(prompt) + )?; + } + + write!(f, "{}", &self.prompt_suffix) + } + + /// Formats an error + fn format_error(&self, f: &mut dyn fmt::Write, err: &str) -> fmt::Result { + write!( + f, + "{} {}", + &self.error_prefix, + self.error_style.apply_to(err) + ) + } + + /// Formats an input prompt. + fn format_input_prompt( + &self, + f: &mut dyn fmt::Write, + prompt: &str, + default_val: Option<&str>, + ) -> fmt::Result { + if !prompt.is_empty() { + write!( + f, + "{} {} ", + &self.prompt_prefix, + self.prompt_style.apply_to(prompt) + )?; + } + + match default_val { + Some(default_val) => write!( + f, + "{} {} ", + self.hint_style.apply_to(&format!("({})", default_val)), + &self.prompt_suffix + ), + None => write!(f, "{} ", &self.prompt_suffix), + } + } + + /// Formats a confirm prompt. + fn format_confirm_prompt( + &self, + f: &mut dyn fmt::Write, + prompt: &str, + default: Option, + ) -> fmt::Result { + if !prompt.is_empty() { + write!( + f, + "{} {} ", + &self.prompt_prefix, + self.prompt_style.apply_to(prompt) + )?; + } + + match default { + None => write!( + f, + "{} {}", + self.hint_style.apply_to("(y/n)"), + &self.prompt_suffix + ), + Some(true) => write!( + f, + "{} {}", + self.hint_style.apply_to("(Y/n)"), + &self.prompt_suffix + ), + Some(false) => write!( + f, + "{} {}", + self.hint_style.apply_to("(y/N)"), + &self.prompt_suffix + ), + } + } + + /// Formats a confirm prompt after selection. + fn format_confirm_prompt_selection( + &self, + f: &mut dyn fmt::Write, + prompt: &str, + selection: Option, + ) -> fmt::Result { + if !prompt.is_empty() { + write!( + f, + "{} {} ", + &self.success_prefix, + self.prompt_style.apply_to(prompt) + )?; + } + let selection = selection.map(|b| if b { "yes" } else { "no" }); + + match selection { + Some(selection) => { + write!( + f, + "{} {}", + &self.success_suffix, + self.values_style.apply_to(selection) + ) + } + None => { + write!(f, "{}", &self.success_suffix) + } + } + } + + /// Formats an input prompt after selection. + fn format_input_prompt_selection( + &self, + f: &mut dyn fmt::Write, + prompt: &str, + sel: &str, + ) -> fmt::Result { + if !prompt.is_empty() { + write!( + f, + "{} {} ", + &self.success_prefix, + self.prompt_style.apply_to(prompt) + )?; + } + + write!( + f, + "{} {}", + &self.success_suffix, + self.values_style.apply_to(sel) + ) + } + + /// Formats a password prompt after selection. + fn format_password_prompt_selection( + &self, + f: &mut dyn fmt::Write, + prompt: &str, + ) -> fmt::Result { + self.format_input_prompt_selection(f, prompt, "********") + } + + /// Formats a multi select prompt after selection. + fn format_multi_select_prompt_selection( + &self, + f: &mut dyn fmt::Write, + prompt: &str, + selections: &[&str], + ) -> fmt::Result { + if !prompt.is_empty() { + write!( + f, + "{} {} ", + &self.success_prefix, + self.prompt_style.apply_to(prompt) + )?; + } + + write!(f, "{} ", &self.success_suffix)?; + + if self.inline_selections { + for (idx, sel) in selections.iter().enumerate() { + write!( + f, + "{}{}", + if idx == 0 { "" } else { ", " }, + self.values_style.apply_to(sel) + )?; + } + } + + Ok(()) + } + + /// Formats a select prompt item. + fn format_select_prompt_item( + &self, + f: &mut dyn fmt::Write, + text: &str, + active: bool, + ) -> fmt::Result { + let details = match active { + true => ( + &self.active_item_prefix, + self.active_item_style.apply_to(text), + ), + false => ( + &self.inactive_item_prefix, + self.inactive_item_style.apply_to(text), + ), + }; + + write!(f, "{} {}", details.0, details.1) + } + + /// Formats a multi select prompt item. + fn format_multi_select_prompt_item( + &self, + f: &mut dyn fmt::Write, + text: &str, + checked: bool, + active: bool, + ) -> fmt::Result { + if active { + if checked { + return write!( + f, + "{} {}", + self.checked_item_prefix, + self.active_item_style.apply_to(text) + ); + } else { + return write!( + f, + "{} {}", + self.unchecked_item_prefix, + self.active_item_style.apply_to(text) + ); + } + } + + if checked { + return write!( + f, + "{} {}", + self.checked_item_prefix, + self.inactive_item_style.apply_to(text) + ); + } else { + return write!( + f, + "{} {}", + self.unchecked_item_prefix, + self.inactive_item_style.apply_to(text) + ); + } + } + + /// Formats a sort prompt item. + fn format_sort_prompt_item( + &self, + f: &mut dyn fmt::Write, + text: &str, + picked: bool, + active: bool, + ) -> fmt::Result { + if !active { + return write!( + f, + "{} {}", + self.unpicked_item_prefix, + self.inactive_item_style.apply_to(text) + ); + } + + if picked { + return write!( + f, + "{} {}", + self.picked_item_prefix, + self.active_item_style.apply_to(text) + ); + } + + write!( + f, + "{} {}", + self.unpicked_item_prefix, + self.active_item_style.apply_to(text) + ) + } +} diff --git a/crates/ev-cli/src/tty.rs b/crates/ev-cli/src/tty.rs new file mode 100644 index 00000000..6c75aad4 --- /dev/null +++ b/crates/ev-cli/src/tty.rs @@ -0,0 +1,32 @@ +use atty::Stream; + +#[derive(Debug)] +pub struct AttyReport { + pub stdout: bool, + pub stderr: bool, + pub stdin: bool, +} + +impl AttyReport { + fn is_stdout_atty() -> bool { + atty::is(Stream::Stdout) + } + + fn is_stderr_atty() -> bool { + atty::is(Stream::Stderr) + } + + fn is_stdin_atty() -> bool { + atty::is(Stream::Stdin) + } +} + +impl Default for AttyReport { + fn default() -> Self { + Self { + stdout: Self::is_stdout_atty(), + stderr: Self::is_stderr_atty(), + stdin: Self::is_stdin_atty(), + } + } +}