Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/desired-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ Three things make this feel good:

### `envx variables`

- Prints a nice unicode box-drawing table by default.
- Has `--kv` (KEY=VALUE lines), `--json`, `--filter <regex>`, `--all`.
- Prints KEY=VALUE lines by default, so output can be redirected to a `.env` file.
- Has `--table` (unicode box-drawing table), `--json`, `--filter <regex>`, `--all`. `--kv` is a hidden no-op kept for existing scripts.
- This one is already polished.

### `envx shell`
Expand Down
1 change: 1 addition & 0 deletions src/commands/friends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> {
file: None,
stdin: false,
env: false,
text: false,
expires: None,
json: false,
retry: None,
Expand Down
28 changes: 9 additions & 19 deletions src/commands/get/config.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
use super::*;
use crate::utils::config::Config;
use crate::utils::table::Table;
use anyhow::Context;
use anyhow::Result;
use std::collections::BTreeMap;

/// Get the configuration either as a table or as a JSON output
/// Get the configuration as KEY=VALUE lines or as JSON
#[derive(Parser)]
pub struct Args {
/// Show only the primary key
Expand All @@ -31,14 +29,10 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> {
return Ok(());
}

let mut map = BTreeMap::new();
if let Some(key) = key {
map.insert(
key.fingerprint.chars().skip(30).collect(),
key.primary_user_id.clone(),
);
println!("fingerprint={}", key.fingerprint);
println!("primary_user_id={}", key.primary_user_id);
}
Table::new("Fingerprint | Key ID".into(), map).print()?;
return Ok(());
}

Expand All @@ -57,16 +51,12 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> {
return Ok(());
};

Table::new(
"Configuration".into(),
display
.as_object()
.context("Invalid config shape")?
.iter()
.map(|(k, v)| (k.clone(), v.to_string()))
.collect(),
)
.print()?;
for (k, v) in display.as_object().context("Invalid config shape")? {
match v {
serde_json::Value::String(v) => println!("{k}={v}"),
v => println!("{k}={v}"),
}
}

Ok(())
}
8 changes: 5 additions & 3 deletions src/commands/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> {
} else {
match envelope.payload {
Payload::Text(text) => text.into_bytes(),
Payload::Variables(variables) => {
serde_json::to_vec_pretty(&variables)?
}
Payload::Variables(variables) => variables
.iter()
.map(|(name, value)| format!("{name}={value}\n"))
.collect::<String>()
.into_bytes(),
}
};
if let Some(path) = args.output {
Expand Down
198 changes: 179 additions & 19 deletions src/commands/send.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::*;
use crate::utils::{
config::Config,
messaging::{self, Client, Message},
messaging::{self, Client, Friend, Message},
messaging_crypto::{self as crypto, Envelope, Payload},
};
use reqwest::Method;
Expand All @@ -13,23 +13,33 @@ use std::{
};

/// Send a signed, encrypted secret to a friend; values never go in arguments
///
/// Run without arguments in a terminal to pick a friend and secret
/// interactively. Piped input is read automatically; KEY=VALUE lines are
/// sent as variables unless --text is given.
#[derive(Parser, Debug)]
pub struct Args {
#[arg(required_unless_present = "retry")]
/// Friend UUID or local alias (prompted for when omitted in a terminal)
pub friend: Option<String>,
/// Read the secret from a file
#[arg(long, conflicts_with = "stdin")]
pub file: Option<PathBuf>,
/// Read the secret from stdin even when it is a terminal
#[arg(long)]
pub stdin: bool,
/// Parse input as KEY=VALUE lines instead of plain text
#[arg(long)]
#[arg(long, conflicts_with = "text")]
pub env: bool,
/// Send input as plain text even if it looks like KEY=VALUE lines
#[arg(long)]
pub text: bool,
/// Expire the message after a duration such as 30m, 24h, or 7d
#[arg(long)]
pub expires: Option<String>,
#[arg(long)]
pub json: bool,
/// Retry a previously prepared send without creating a duplicate
#[arg(long, conflicts_with_all = ["friend", "file", "stdin", "env", "expires"])]
#[arg(long, conflicts_with_all = ["friend", "file", "stdin", "env", "text", "expires"])]
pub retry: Option<String>,
}
pub async fn command(args: Args, config: &mut Config) -> Result<()> {
Expand Down Expand Up @@ -69,23 +79,48 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> {
crate::utils::user_display::UserDisplay::from_state(&client.state)?;
(id, body, names.name(&friend.user.id, &friend.user.username))
} else {
let target = args
.friend
.context("Specify a friend's UUID or local alias")?;
let friend = client.resolve(&target).await?;
// Wizard only when nothing was specified and a human can answer.
let wizard = args.friend.is_none()
&& args.file.is_none()
&& !args.stdin
&& crate::utils::prompt::is_interactive();
let friend = match args.friend {
Some(target) => client.resolve(&target).await?,
None if wizard => select_friend(&client).await?,
None => anyhow::bail!(
"Specify a friend's UUID or local alias, or run `envx send` in a terminal to choose one"
),
};
let pin = client.trusted(&friend.user)?;
let text = input(args.file, args.stdin)?;
let (file, expires) = if wizard {
let file = prompt_source()?;
let expires = match args.expires {
Some(expires) => Some(expires),
None => prompt_expiry()?,
};
(file, expires)
} else {
(args.file, args.expires)
};
// Validate expiry before asking for the secret.
let expires_at =
expires.as_deref().map(messaging::expires).transpose()?;
let (text, typed) = input(file, args.stdin)?;
let payload = if args.env {
Payload::Variables(parse_variables(&text)?)
} else if args.text || typed {
Payload::Text(text)
} else if let Some(variables) = detect_variables(&text) {
eprintln!(
"Detected {} KEY=VALUE variable{}; pass --text to send as plain text.",
variables.len(),
if variables.len() == 1 { "" } else { "s" }
);
Payload::Variables(variables)
} else {
Payload::Text(text)
};
let id = uuid::Uuid::new_v4().to_string();
let expires_at = args
.expires
.as_deref()
.map(messaging::expires)
.transpose()?;
let envelope = Envelope {
version: 1,
server: client.origin.clone(),
Expand Down Expand Up @@ -134,7 +169,90 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> {
}
Ok(())
}
fn input(file: Option<PathBuf>, stdin: bool) -> Result<String> {
/// Presentation wrapper so the picker shows names while returning the friend.
struct Choice<T>(String, T);
impl<T> std::fmt::Display for Choice<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}

async fn select_friend(client: &Client) -> Result<Friend> {
let names =
crate::utils::user_display::UserDisplay::from_state(&client.state)?;
let mut choices = Vec::new();
for friend in client.friends().await? {
client.learn_from_receipt(&friend)?;
// Only offer friends whose current key matches the local pin.
if client.trusted(&friend.user).is_ok() {
choices.push(Choice(
names.row(&friend.user.id, &friend.user.username, false),
friend,
));
}
}
if choices.is_empty() {
anyhow::bail!(
"No trusted friends to send to. Share an `envx friend-link` code, or verify a friend with `envx friends --accept-key`."
);
}
Ok(inquire::Select::new("Send to:", choices)
.with_render_config(crate::utils::prompt::get_render_config())
.prompt()
.context("Failed to choose a friend")?
.1)
}

/// None means type the secret at a hidden prompt.
fn prompt_source() -> Result<Option<PathBuf>> {
let choices = vec![
Choice("Type a secret (hidden)".into(), false),
Choice(
"Send a file (KEY=VALUE files become variables)".into(),
true,
),
];
let from_file = inquire::Select::new("What to send:", choices)
.with_render_config(crate::utils::prompt::get_render_config())
.prompt()
.context("Failed to choose what to send")?
.1;
if !from_file {
return Ok(None);
}
let path = inquire::Text::new("File path:")
.with_render_config(crate::utils::prompt::get_render_config())
.with_validator(|path: &str| {
Ok(if std::path::Path::new(path.trim()).is_file() {
inquire::validator::Validation::Valid
} else {
inquire::validator::Validation::Invalid(
"No file at that path".into(),
)
})
})
.prompt()
.context("Failed to read file path")?;
Ok(Some(PathBuf::from(path.trim())))
}

fn prompt_expiry() -> Result<Option<String>> {
let choices = vec![
Choice("Never".into(), None),
Choice("1 hour".into(), Some("1h")),
Choice("24 hours".into(), Some("24h")),
Choice("7 days".into(), Some("7d")),
];
Ok(inquire::Select::new("Expires:", choices)
.with_render_config(crate::utils::prompt::get_render_config())
.prompt()
.context("Failed to choose an expiry")?
.1
.map(Into::into))
}

/// Returns the secret and whether it was typed at the hidden prompt.
fn input(file: Option<PathBuf>, stdin: bool) -> Result<(String, bool)> {
let mut bytes = Vec::new();
if let Some(path) = file {
std::fs::File::open(path)?
Expand All @@ -146,22 +264,44 @@ fn input(file: Option<PathBuf>, stdin: bool) -> Result<String> {
.take(65537)
.read_to_end(&mut bytes)?;
} else {
return inquire::Password::new("Secret:")
let secret = inquire::Password::new("Secret:")
.without_confirmation()
.with_render_config(crate::utils::prompt::get_render_config())
.with_help_message(
"Hidden input. Use --stdin or --file for multiline secrets.",
"Hidden input. Pipe input or use --file for multiline secrets.",
)
.prompt()
.context("Failed to read secret");
.context("Failed to read secret")?;
return Ok((secret, true));
}
if bytes.len() > 65536 {
anyhow::bail!("Secret input exceeds 64 KiB");
}
if bytes.is_empty() {
anyhow::bail!("Secret input is empty");
}
String::from_utf8(bytes).context("Secret input must be UTF-8 text")
let text =
String::from_utf8(bytes).context("Secret input must be UTF-8 text")?;
Ok((text, false))
}

/// Conservative: every line must look like a conventional env assignment,
/// so a bare token such as base64 with `=` padding stays plain text.
fn detect_variables(text: &str) -> Option<BTreeMap<String, String>> {
let conventional = text.lines().all(|line| {
let line = line.trim_start();
if line.trim().is_empty() || line.starts_with('#') {
return true;
}
line.split_once('=').is_some_and(|(name, value)| {
name.bytes().any(|b| b.is_ascii_uppercase())
&& name.bytes().all(|b| {
b == b'_' || b.is_ascii_uppercase() || b.is_ascii_digit()
})
&& !value.starts_with('=')
})
});
conventional.then(|| parse_variables(text).ok()).flatten()
}
pub fn parse_variables(text: &str) -> Result<BTreeMap<String, String>> {
let mut variables = BTreeMap::new();
Expand Down Expand Up @@ -211,4 +351,24 @@ mod tests {
assert!(!error.contains("private-value"));
}
}

#[test]
fn detects_only_conventional_env_input_as_variables() {
let values =
detect_variables("# comment\nTOKEN=a=b\n\nAPI_KEY_2=\n").unwrap();
assert_eq!(values["TOKEN"], "a=b");
assert_eq!(values["API_KEY_2"], "");
for text in [
"hunter2",
"QUJDRA==",
"password=secret",
"TOKEN=a\nplain line",
"export TOKEN=a",
"A=1\nA=2",
"_=1",
"",
] {
assert!(detect_variables(text).is_none(), "{text:?}");
}
}
}
12 changes: 8 additions & 4 deletions src/commands/variables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ pub struct Args {
#[arg(long)]
json: bool,

/// Output as a list of key=value pairs
/// Output as a table instead of KEY=VALUE lines
#[arg(long)]
table: bool,

/// KEY=VALUE lines; the default, kept for existing scripts
#[arg(long, hide = true, conflicts_with = "table")]
kv: bool,

/// Output all variables (this project only)
Expand Down Expand Up @@ -83,10 +87,10 @@ impl Mode {
fn from_args(args: &Args) -> Self {
if args.json {
Self::Json
} else if args.kv {
Self::KV
} else {
} else if args.table {
Self::Table
} else {
Self::KV
}
}
}
Loading
Loading