use std::io::Read;
use shep_core::config::DaemonConfig;
use shep_core::paths::ShepPaths;
use shep_core::secrets::{
self, ALL_ENVIRONMENTS, ProviderCache, Resolution, SecretError, SecretRef, SecretView,
};
use crate::cli::{Format, SecretArgs, SecretCommand};
use crate::exit::ExitCode;
use crate::output::{
SecretKeyRow, SecretKeyRows, SecretSlotRow, SecretValueRow, Streams, emit, write_outcome,
};
const HOW_TO_ALLOW_READ: &str = "printing a stored secret back is off; add `[secrets]` with \
`allow_read = true` to $SHEP_HOME/shep.toml";
pub(crate) fn exit_code_for(err: &SecretError) -> ExitCode {
match err {
SecretError::InvalidKey(_)
| SecretError::InvalidEnvironment(_)
| SecretError::ValueTooLong { .. } => ExitCode::Usage,
SecretError::FutureVersion(_) | SecretError::Decode(_) => ExitCode::InvalidConfig,
_ => ExitCode::Failure,
}
}
fn fail(streams: &mut Streams<'_>, err: &SecretError) -> ExitCode {
let code = exit_code_for(err);
streams.fail(code, &err.to_string())
}
pub fn secret(streams: &mut Streams<'_>, paths: &ShepPaths, args: &SecretArgs) -> ExitCode {
match &args.command {
SecretCommand::Set {
key,
value,
env,
stdin,
} => {
let value = if *stdin {
match resolve_stdin_value(&mut std::io::stdin().lock()) {
Ok(value) => value,
Err((code, message)) => return streams.fail(code, &message),
}
} else {
value
.clone()
.expect("clap requires a value unless --stdin is set")
};
set(streams, paths, key, env.as_deref(), &value)
}
SecretCommand::Get { key, env } => {
let config = daemon_config(paths);
get(
streams,
paths,
key,
env.as_deref(),
config.secrets.allow_read,
&config.daemon.environment,
)
}
SecretCommand::Unset { key, env } => unset(streams, paths, key, env.as_deref()),
SecretCommand::List => list(streams, paths),
}
}
pub(crate) fn daemon_config(paths: &ShepPaths) -> DaemonConfig {
let text = std::fs::read_to_string(&paths.daemon_config).ok();
DaemonConfig::load(text.as_deref(), &|_| None).unwrap_or_default()
}
fn resolve_stdin_value(reader: &mut dyn Read) -> Result<String, (ExitCode, String)> {
let mut bytes = Vec::new();
reader
.take(STDIN_READ_CAP as u64)
.read_to_end(&mut bytes)
.map_err(|err| {
(
ExitCode::Failure,
format!("could not read the value from stdin: {err}"),
)
})?;
if bytes.last() == Some(&b'\n') {
bytes.pop();
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
}
if bytes.len() > secrets::MAX_VALUE_BYTES {
return Err((
ExitCode::Usage,
format!(
"the value read from stdin is over the {}-byte limit",
secrets::MAX_VALUE_BYTES
),
));
}
String::from_utf8(bytes).map_err(|_utf8_error| {
(
ExitCode::Failure,
"the value read from stdin is not valid UTF-8".to_string(),
)
})
}
const STDIN_READ_CAP: usize = secrets::MAX_VALUE_BYTES + 3;
fn set(
streams: &mut Streams<'_>,
paths: &ShepPaths,
key: &str,
environment: Option<&str>,
value: &str,
) -> ExitCode {
let environment = environment.unwrap_or(ALL_ENVIRONMENTS);
match secrets::set(&paths.secrets, key, environment, value) {
Ok(()) => emit_slot(streams, key, environment),
Err(err) => fail(streams, &err),
}
}
fn get(
streams: &mut Streams<'_>,
paths: &ShepPaths,
key: &str,
environment: Option<&str>,
allow_read: bool,
host_environment: &str,
) -> ExitCode {
if !allow_read {
return streams.fail(ExitCode::InvalidConfig, HOW_TO_ALLOW_READ);
}
let found = match environment {
Some(environment) => secrets::get(&paths.secrets, key, environment),
None => resolve(paths, key, host_environment),
};
match found {
Ok(Some(value)) => match streams.fmt {
Format::Table => write_outcome(writeln!(streams.out, "{value}")),
Format::Json => {
let row = SecretValueRow {
key: key.to_string(),
value,
};
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"secret",
row,
streams.style,
))
}
},
Ok(None) => {
let message = match environment {
Some(environment) => format!("`{key}` has no value for `{environment}`"),
None => {
format!("`{key}` has no value for `{host_environment}` or `{ALL_ENVIRONMENTS}`")
}
};
streams.fail(ExitCode::NotFound, &message)
}
Err(err) => fail(streams, &err),
}
}
fn resolve(
paths: &ShepPaths,
key: &str,
host_environment: &str,
) -> Result<Option<String>, SecretError> {
if !secrets::is_name(key) {
return Err(SecretError::InvalidKey(key.to_string()));
}
let view = SecretView::new(
host_environment.to_string(),
secrets::all(&paths.secrets)?,
ProviderCache::default(),
);
Ok(
match view.resolve(&SecretRef {
namespace: None,
key,
}) {
Resolution::Found(value) => Some(value.to_string()),
Resolution::MissingKey | Resolution::MissingNamespace => None,
},
)
}
fn unset(
streams: &mut Streams<'_>,
paths: &ShepPaths,
key: &str,
environment: Option<&str>,
) -> ExitCode {
let environment = environment.unwrap_or(ALL_ENVIRONMENTS);
match secrets::unset(&paths.secrets, key, environment) {
Ok(true) => emit_slot(streams, key, environment),
Ok(false) => {
let message = format!("`{key}` has no value for `{environment}`");
streams.fail(ExitCode::NotFound, &message)
}
Err(err) => fail(streams, &err),
}
}
fn list(streams: &mut Streams<'_>, paths: &ShepPaths) -> ExitCode {
match secrets::all(&paths.secrets) {
Ok(entries) => {
let rows = SecretKeyRows(
entries
.into_iter()
.map(|(key, by_environment)| SecretKeyRow {
key,
environments: by_environment.into_keys().collect(),
})
.collect(),
);
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"secret",
rows,
streams.style,
))
}
Err(err) => fail(streams, &err),
}
}
fn emit_slot(streams: &mut Streams<'_>, key: &str, environment: &str) -> ExitCode {
let row = SecretSlotRow {
key: key.to_string(),
environment: environment.to_string(),
};
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"secret",
row,
streams.style,
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use shep_core::paths::ShepPaths;
use shep_core::secrets::ALL_ENVIRONMENTS;
use crate::cli::{Cli, Format};
use crate::exit::ExitCode;
use crate::output::Streams;
fn streams_with<'a>(out: &'a mut Vec<u8>, err: &'a mut Vec<u8>, fmt: Format) -> Streams<'a> {
Streams {
out,
err,
style: crate::style::Presentation::BARE,
fmt,
}
}
fn streams<'a>(out: &'a mut Vec<u8>, err: &'a mut Vec<u8>) -> Streams<'a> {
streams_with(out, err, Format::Json)
}
fn paths_in(dir: &Path) -> ShepPaths {
let home = dir.display().to_string();
ShepPaths::resolve(&move |key| (key == "SHEP_HOME").then(|| home.clone()), dir)
}
fn run_set(
paths: &ShepPaths,
key: &str,
environment: Option<&str>,
value: &str,
) -> Result<(), String> {
let mut out = Vec::new();
let mut err = Vec::new();
let code = set(
&mut streams(&mut out, &mut err),
paths,
key,
environment,
value,
);
match code {
ExitCode::Success => Ok(()),
_ => Err(String::from_utf8(err).unwrap()),
}
}
fn render_list(paths: &ShepPaths) -> Result<String, String> {
let mut out = Vec::new();
let mut err = Vec::new();
let code = list(&mut streams(&mut out, &mut err), paths);
match code {
ExitCode::Success => Ok(String::from_utf8(out).unwrap()),
_ => Err(String::from_utf8(err).unwrap()),
}
}
fn run_get(
paths: &ShepPaths,
key: &str,
environment: Option<&str>,
allow_read: bool,
fmt: Format,
) -> (ExitCode, String, String) {
let mut out = Vec::new();
let mut err = Vec::new();
let code = get(
&mut streams_with(&mut out, &mut err, fmt),
paths,
key,
environment,
allow_read,
"production",
);
(
code,
String::from_utf8(out).unwrap(),
String::from_utf8(err).unwrap(),
)
}
fn run_get_capturing(
paths: &ShepPaths,
key: &str,
environment: Option<&str>,
allow_read: bool,
) -> (ExitCode, String) {
let (code, _, err) = run_get(paths, key, environment, allow_read, Format::Table);
(code, err)
}
fn run_get_capturing_out(
paths: &ShepPaths,
key: &str,
environment: Option<&str>,
allow_read: bool,
) -> (ExitCode, String) {
let (code, out, _) = run_get(paths, key, environment, allow_read, Format::Table);
(code, out)
}
#[test]
fn set_then_list_names_the_key_and_its_environments_but_no_value() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
run_set(&paths, "DB_PASSWORD", None, "hunter2").unwrap();
run_set(&paths, "DB_PASSWORD", Some("staging"), "staging-pw").unwrap();
let listed = render_list(&paths).unwrap();
assert!(listed.contains("DB_PASSWORD"));
assert!(listed.contains("all"));
assert!(listed.contains("staging"));
assert!(!listed.contains("hunter2"), "a list never prints a value");
assert!(!listed.contains("staging-pw"));
}
#[test]
fn set_with_no_env_writes_the_all_slot() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
run_set(&paths, "K", None, "v").unwrap();
assert_eq!(
shep_core::secrets::get(&paths.secrets, "K", ALL_ENVIRONMENTS)
.unwrap()
.as_deref(),
Some("v")
);
}
#[test]
fn get_is_refused_unless_shep_toml_turns_it_on() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
run_set(&paths, "K", None, "v").unwrap();
let (code, err) = run_get_capturing(&paths, "K", None, false);
assert_eq!(code, ExitCode::InvalidConfig);
assert!(err.contains("allow_read"), "{err}");
assert!(err.contains("[secrets]"), "{err}");
assert!(
!err.contains('v') || !err.contains("value is"),
"no value in the refusal"
);
}
#[test]
fn get_prints_the_value_once_it_is_turned_on() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
run_set(&paths, "K", None, "v").unwrap();
let (code, out) = run_get_capturing_out(&paths, "K", None, true);
assert_eq!(code, ExitCode::Success);
assert_eq!(out.trim(), "v");
}
#[test]
fn get_under_json_wraps_the_value_in_the_standard_envelope() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
run_set(&paths, "K", None, "hunter2").unwrap();
let (code, out, _) = run_get(&paths, "K", None, true, Format::Json);
assert_eq!(code, ExitCode::Success);
let envelope: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(envelope["command"], "secret");
assert_eq!(envelope["data"]["key"], "K");
assert_eq!(envelope["data"]["value"], "hunter2");
}
#[test]
fn get_on_a_missing_key_exits_not_found_and_prints_nothing() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
let (code, out) = run_get_capturing_out(&paths, "ABSENT", None, true);
assert_eq!(
code,
ExitCode::NotFound,
"so `shep secret get k || default` works"
);
assert!(out.is_empty());
}
#[test]
fn a_bad_key_exits_usage_and_a_future_store_exits_invalid_config() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
assert_eq!(
exit_code_for(&SecretError::InvalidKey("x y".into())),
ExitCode::Usage
);
assert_eq!(
exit_code_for(&SecretError::FutureVersion(9)),
ExitCode::InvalidConfig
);
let _ = paths;
}
#[test]
fn a_bad_key_is_refused_through_set_and_creates_no_store() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
let mut out = Vec::new();
let mut err = Vec::new();
let code = set(
&mut streams(&mut out, &mut err),
&paths,
"not valid",
None,
"v",
);
assert_eq!(code, ExitCode::Usage);
assert!(
!paths.secrets.exists(),
"the store must not be created on a refused key"
);
}
#[test]
fn get_with_an_env_reads_that_slot_exactly() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
run_set(&paths, "K", None, "shared").unwrap();
let (code, out) = run_get_capturing_out(&paths, "K", Some("staging"), true);
assert_eq!(code, ExitCode::NotFound);
assert!(out.is_empty(), "{out}");
run_set(&paths, "K", Some("staging"), "staged").unwrap();
let (code, out) = run_get_capturing_out(&paths, "K", Some("staging"), true);
assert_eq!(code, ExitCode::Success);
assert_eq!(out.trim(), "staged");
}
#[test]
fn set_and_unset_report_the_slot_and_never_the_value() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
let mut out = Vec::new();
let mut err = Vec::new();
let code = set(
&mut streams(&mut out, &mut err),
&paths,
"K",
Some("staging"),
"hunter2",
);
assert_eq!(code, ExitCode::Success);
let written = String::from_utf8(std::mem::take(&mut out)).unwrap();
assert!(written.contains("K"), "{written}");
assert!(written.contains("staging"), "{written}");
assert!(!written.contains("hunter2"), "{written}");
let code = unset(
&mut streams(&mut out, &mut err),
&paths,
"K",
Some("staging"),
);
assert_eq!(code, ExitCode::Success);
let removed = String::from_utf8(out).unwrap();
assert!(removed.contains("staging"), "{removed}");
assert!(!removed.contains("hunter2"), "{removed}");
}
#[test]
fn unset_on_an_empty_slot_exits_not_found() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
let mut out = Vec::new();
let mut err = Vec::new();
let code = unset(&mut streams(&mut out, &mut err), &paths, "ABSENT", None);
assert_eq!(code, ExitCode::NotFound);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn the_store_works_with_no_shepherd_running() {
let home = tempfile::tempdir().unwrap();
let paths = paths_in(home.path());
run_set(&paths, "K", None, "v").unwrap();
assert!(paths.secrets.exists());
}
#[test]
fn stdin_value_reads_bytes_with_no_trailing_newline() {
let mut input = std::io::Cursor::new(b"hunter2".to_vec());
assert_eq!(resolve_stdin_value(&mut input).unwrap(), "hunter2");
}
#[test]
fn stdin_value_strips_exactly_one_trailing_newline_and_a_preceding_cr() {
let mut lf = std::io::Cursor::new(b"hunter2\n".to_vec());
assert_eq!(resolve_stdin_value(&mut lf).unwrap(), "hunter2");
let mut crlf = std::io::Cursor::new(b"hunter2\r\n".to_vec());
assert_eq!(resolve_stdin_value(&mut crlf).unwrap(), "hunter2");
}
#[test]
fn stdin_value_trims_nothing_else() {
let mut input = std::io::Cursor::new(b" hunter2 \r more\n".to_vec());
assert_eq!(resolve_stdin_value(&mut input).unwrap(), " hunter2 \r more");
}
struct Oversized {
left: usize,
pulled: usize,
}
impl Read for Oversized {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let taken = buf.len().min(self.left);
buf[..taken].fill(b'x');
self.left -= taken;
self.pulled += taken;
Ok(taken)
}
}
#[test]
fn stdin_value_refuses_an_oversized_reader_without_buffering_it() {
let mut input = Oversized {
left: secrets::MAX_VALUE_BYTES * 4,
pulled: 0,
};
let (code, message) = resolve_stdin_value(&mut input).unwrap_err();
assert_eq!(code, ExitCode::Usage);
assert!(
message.contains(&secrets::MAX_VALUE_BYTES.to_string()),
"the refusal names the limit: {message}"
);
assert!(
input.pulled <= secrets::MAX_VALUE_BYTES + 8,
"only a little past the cap may be read, not the whole stream: {}",
input.pulled
);
}
#[test]
fn stdin_value_accepts_a_value_at_the_cap_with_a_newline_after_it() {
let mut at_cap =
std::io::Cursor::new([vec![b'x'; secrets::MAX_VALUE_BYTES], b"\r\n".to_vec()].concat());
assert_eq!(
resolve_stdin_value(&mut at_cap).unwrap().len(),
secrets::MAX_VALUE_BYTES
);
}
#[test]
fn set_stdin_alone_parses_with_no_positional_value() {
use clap::Parser;
let cli = Cli::try_parse_from(["shep", "secret", "set", "K", "--stdin"]).unwrap();
let crate::cli::Commands::Secret(args) = cli.command else {
panic!("expected Commands::Secret");
};
let SecretCommand::Set { value, stdin, .. } = args.command else {
panic!("expected SecretCommand::Set");
};
assert_eq!(value, None);
assert!(stdin);
}
#[test]
fn set_refuses_a_positional_value_and_stdin_together() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "secret", "set", "K", "v", "--stdin"]).is_err());
}
#[test]
fn set_requires_a_positional_value_or_stdin() {
use clap::Parser;
assert!(Cli::try_parse_from(["shep", "secret", "set", "K"]).is_err());
assert!(Cli::try_parse_from(["shep", "secret", "set", "K", "v"]).is_ok());
assert!(Cli::try_parse_from(["shep", "secret", "set", "K", "--stdin"]).is_ok());
}
}