use std::fmt;
use std::path::Path;
use crate::cli_spec::{SourceError, ValueSource};
use crate::document::{DocumentFile, Format, Value};
const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
const MAX_STREAM_BYTES: usize = 1024 * 1024;
type Result<T> = std::result::Result<T, SourceError>;
#[derive(Clone, PartialEq, Eq)]
pub struct SecretString(String);
impl SecretString {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose_secret(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("***")
}
}
impl fmt::Display for SecretString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("***")
}
}
impl From<String> for SecretString {
fn from(value: String) -> Self {
Self(value)
}
}
impl ValueSource {
pub fn read(&self) -> Result<String> {
read_with(self, Policy::Plain)
}
pub fn read_secret(&self) -> Result<SecretString> {
read_with(self, Policy::Secret).map(SecretString)
}
}
fn read_with(source: &ValueSource, policy: Policy) -> Result<String> {
match source {
ValueSource::Literal(value) => Ok(value.clone()),
ValueSource::Env(name) => std::env::var(name).map_err(|error| {
let reason = match error {
std::env::VarError::NotPresent => "is unset",
std::env::VarError::NotUnicode(_) => "is not valid UTF-8",
};
SourceError::unreadable(format!("environment variable `{name}` {reason}"))
}),
ValueSource::File {
path,
dot_path,
format,
} => read_file(path, dot_path, format.as_deref(), policy),
ValueSource::Stdin => read_stream(std::io::stdin().lock(), "stdin"),
ValueSource::Fd(number) => read_fd(*number),
ValueSource::Prompt => read_prompt(),
ValueSource::Host { scheme, .. } => Err(SourceError::unreadable(format!(
"`{scheme}` is a host-defined source; this crate cannot read it"
))),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Policy {
Plain,
Secret,
}
fn read_file(
path: &Path,
dot_path: &str,
named_format: Option<&str>,
policy: Policy,
) -> Result<String> {
let format = match named_format {
Some(name) => Format::from_cli_name(name).ok_or_else(|| {
SourceError::invalid(format!("`file+{name}:` is not a format this build reads"))
})?,
None => Format::detect(path).ok_or_else(|| match Format::unavailable(path) {
Some(feature) => SourceError::unreadable(format!(
"cannot read {}: this build has no {feature} support",
path.display()
)),
None => SourceError::invalid(format!(
"cannot tell the config format of {} from its name; name it with \
file+FORMAT:{}#{dot_path}, or use a .json/.toml/.yaml/.env/.ini file",
path.display(),
path.display()
)),
})?,
};
let document = DocumentFile::open_capped(path, Some(format), MAX_FILE_BYTES).map_err(
|error| match policy {
Policy::Secret => SourceError::unreadable(format!(
"cannot read {} config {}: {}",
format.name(),
path.display(),
error.redacted_message()
)),
Policy::Plain => SourceError::unreadable(format!(
"cannot read {} config {}: {error}",
format.name(),
path.display()
)),
},
)?;
let value = document.value_at(dot_path).map_err(|error| {
if error.code() == "document_path_not_found" {
SourceError::unreadable(format!("{dot_path} was not found in {}", path.display()))
} else {
SourceError::unreadable(format!("cannot resolve {dot_path} in {}", path.display()))
}
})?;
scalar(value, path, dot_path, policy)
}
fn scalar(value: Value, path: &Path, dot_path: &str, policy: Policy) -> Result<String> {
let refused = |kind: &str| {
SourceError::unreadable(format!(
"{dot_path} in {} is {kind}, which is not a value",
path.display()
))
};
match value {
Value::String(value) => Ok(value),
other if policy == Policy::Secret => Err(SourceError::unreadable(format!(
"{dot_path} in {} is {}; a secret must be a string",
path.display(),
other.kind_name()
))),
Value::Integer(value) => Ok(value.to_string()),
Value::Unsigned(value) => Ok(value.to_string()),
Value::Float(value) => Ok(value.to_string()),
Value::Number(value) => Ok(value),
Value::Bool(value) => Ok(value.to_string()),
Value::Null => Err(refused("null")),
Value::Array(_) => Err(refused("an array")),
Value::Object(_) => Err(refused("an object")),
}
}
fn read_stream<R: std::io::Read>(reader: R, source: &str) -> Result<String> {
use std::io::Read;
let mut bytes = Vec::new();
reader
.take((MAX_STREAM_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|error| SourceError::unreadable(format!("read from {source}: {error}")))?;
if bytes.len() > MAX_STREAM_BYTES {
return Err(SourceError::unreadable(format!(
"{source} exceeds {MAX_STREAM_BYTES} bytes"
)));
}
String::from_utf8(bytes)
.map_err(|_| SourceError::unreadable(format!("{source} must carry valid UTF-8")))
}
#[cfg(unix)]
fn read_fd(number: i32) -> Result<String> {
#[cfg(feature = "libc")]
let file = {
use std::os::fd::FromRawFd;
let duplicated = unsafe { libc::dup(number) };
if duplicated < 0 {
return Err(SourceError::unreadable(format!(
"open file descriptor {number}: {}",
std::io::Error::last_os_error()
)));
}
unsafe { std::fs::File::from_raw_fd(duplicated) }
};
#[cfg(not(feature = "libc"))]
let file = std::fs::File::open(format!("/dev/fd/{number}")).map_err(|error| {
SourceError::unreadable(format!("open file descriptor {number}: {error}"))
})?;
read_stream(file, "file descriptor")
}
#[cfg(not(unix))]
fn read_fd(_number: i32) -> Result<String> {
Err(SourceError::unreadable(
"the `fd` source is unsupported on this platform",
))
}
#[cfg(unix)]
fn read_prompt() -> Result<String> {
use std::io::Write;
let mut tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.map_err(|error| {
SourceError::unreadable(format!("open the controlling terminal: {error}"))
})?;
let restore_tty = tty.try_clone().map_err(|error| {
SourceError::unreadable(format!("prepare terminal echo restoration: {error}"))
})?;
let disabled = set_terminal_echo(&tty, false)
.map_err(|error| SourceError::unreadable(format!("disable terminal echo: {error}")))?;
if !disabled {
return Err(SourceError::unreadable("disabling terminal echo failed"));
}
let _echo = EchoGuard { tty: restore_tty };
write!(tty, "Value: ")
.map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
let reader = std::io::BufReader::new(&mut tty);
let value = read_prompt_line(reader);
let _ = writeln!(tty);
value
}
#[cfg(not(unix))]
fn read_prompt() -> Result<String> {
Err(SourceError::unreadable(
"the `prompt` source is unsupported on this platform",
))
}
#[cfg(unix)]
fn set_terminal_echo(tty: &std::fs::File, enabled: bool) -> std::io::Result<bool> {
use std::process::Stdio;
let input = tty.try_clone()?;
std::process::Command::new("stty")
.arg(if enabled { "echo" } else { "-echo" })
.stdin(Stdio::from(input))
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
}
#[cfg(any(unix, test))]
fn read_prompt_line<R: std::io::BufRead>(reader: R) -> Result<String> {
use std::io::BufRead;
let mut limited = reader.take((MAX_STREAM_BYTES + 2) as u64);
let mut value = String::new();
limited
.read_line(&mut value)
.map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
let value = value.trim_end_matches(['\r', '\n']);
if value.len() > MAX_STREAM_BYTES {
return Err(SourceError::unreadable(format!(
"prompt exceeds {MAX_STREAM_BYTES} bytes"
)));
}
Ok(value.to_string())
}
#[cfg(unix)]
struct EchoGuard {
tty: std::fs::File,
}
#[cfg(unix)]
impl Drop for EchoGuard {
fn drop(&mut self) {
let _ = set_terminal_echo(&self.tty, true);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli_spec::SourceSet;
use std::path::PathBuf;
fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!(
"afdata-value-source-{name}-{}.{extension}",
std::process::id()
));
std::fs::write(&path, content).expect("write test config");
path
}
fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
#[allow(unused_mut)]
let mut cases: Vec<(&str, &str, &str, &str)> =
vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
#[cfg(feature = "toml")]
cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
#[cfg(feature = "yaml")]
cases.push(("yaml", "yaml", "a:\n b: ' v '\n", "a.b"));
#[cfg(feature = "dotenv")]
cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
cases
}
#[test]
fn a_file_source_reads_one_address_out_of_every_format() {
for (name, extension, content, dot_path) in readable_formats() {
let path = temp_config(name, extension, content);
let source = ValueSource::File {
path: path.clone(),
dot_path: dot_path.to_string(),
format: None,
};
let read = source.read();
let secret = source.read_secret();
std::fs::remove_file(&path).expect("remove test config");
assert_eq!(read.as_deref(), Ok(" v "), "{name}");
assert_eq!(
secret.expect("secret read").expose_secret(),
" v ",
"{name}"
);
}
}
#[test]
fn an_empty_string_is_still_a_value() {
let path = temp_config("empty", "json", r#"{"empty":""}"#);
let source = ValueSource::File {
path: path.clone(),
dot_path: "empty".to_string(),
format: None,
};
assert_eq!(source.read().as_deref(), Ok(""));
let secret = source.read_secret().expect("empty secret remains explicit");
assert!(secret.is_empty());
std::fs::remove_file(&path).expect("remove test config");
}
#[cfg(feature = "ini")]
#[test]
fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
let named = ValueSource::File {
path: path.clone(),
dot_path: "http-password".to_string(),
format: Some("ini".to_string()),
};
let unnamed = ValueSource::File {
path: path.clone(),
dot_path: "http-password".to_string(),
format: None,
};
let bad_name = ValueSource::File {
path: path.clone(),
dot_path: "http-password".to_string(),
format: Some("nonsense".to_string()),
};
let read = named.read_secret();
let without = unnamed.read();
let bad = bad_name.read();
std::fs::remove_file(&path).expect("remove test config");
assert_eq!(read.expect("named format").expose_secret(), "abc123");
let without = without.expect_err("no extension to detect");
assert!(without.message().contains("file+FORMAT:"), "{without}");
let bad = bad.expect_err("unknown format");
assert!(
bad.message().contains("not a format this build reads"),
"{bad}"
);
}
#[test]
fn a_non_string_scalar_is_a_value_but_never_a_secret() {
let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
let port = ValueSource::File {
path: path.clone(),
dot_path: "port".to_string(),
format: None,
};
assert_eq!(port.read().as_deref(), Ok("5432"));
let error = port.read_secret().expect_err("a secret must be a string");
assert!(error.message().contains("must be a string"), "{error}");
let on = ValueSource::File {
path: path.clone(),
dot_path: "on".to_string(),
format: None,
};
assert_eq!(on.read().as_deref(), Ok("true"));
std::fs::remove_file(&path).expect("remove test config");
}
#[test]
fn a_secret_read_never_echoes_what_it_read() {
let canary = "AFDATA_SOURCE_CANARY";
let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
let source = ValueSource::File {
path: path.clone(),
dot_path: "a".to_string(),
format: None,
};
let plain = source.read().expect_err("malformed");
let secret = source.read_secret().expect_err("malformed");
std::fs::remove_file(&path).expect("remove test config");
assert!(
!secret.message().contains(canary),
"secret read leaked: {secret}"
);
assert!(plain.message().contains("cannot read"), "{plain}");
}
#[test]
fn a_collection_is_not_a_value() {
let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
let source = ValueSource::File {
path: path.clone(),
dot_path: dot_path.to_string(),
format: None,
};
let error = source.read().expect_err(dot_path);
assert!(error.message().contains(expected), "{dot_path}: {error}");
}
std::fs::remove_file(&path).expect("remove test config");
}
#[test]
fn a_host_scheme_is_not_this_crates_to_read() {
let error = SourceSet::config()
.host_scheme("container", "container:NAME")
.parse("container:x")
.expect("parses")
.read()
.expect_err("this crate cannot read it");
assert_eq!(error.code(), "value_source_unreadable");
}
#[test]
fn an_unset_environment_source_names_what_it_tried() {
const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
let error = ValueSource::Env(ABSENT.to_string())
.read()
.expect_err("unset");
assert_eq!(error.code(), "value_source_unreadable");
assert!(error.message().contains(ABSENT), "{error}");
}
#[test]
fn a_secret_string_cannot_be_printed_by_accident() {
let secret = SecretString::new("s3cret");
assert_eq!(format!("{secret}"), "***");
assert_eq!(format!("{secret:?}"), "***");
assert!(!format!("{secret:?} {secret}").contains("s3cret"));
assert_eq!(secret.expose_secret(), "s3cret");
#[derive(Debug)]
struct Config {
#[allow(dead_code)]
token_secret: SecretString,
}
let printed = format!(
"{:?}",
Config {
token_secret: secret
}
);
assert!(!printed.contains("s3cret"), "{printed}");
}
#[test]
fn a_stream_is_read_verbatim_and_capped() {
assert_eq!(
read_stream(" v \n".as_bytes(), "test").as_deref(),
Ok(" v \n")
);
let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
assert!(error.message().contains("exceeds"), "{error}");
}
#[test]
fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
assert_eq!(
read_prompt_line(std::io::Cursor::new(exact))
.expect("cap-sized line")
.len(),
MAX_STREAM_BYTES
);
let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
assert!(error.message().contains("exceeds"), "{error}");
}
#[cfg(unix)]
#[test]
fn an_fd_source_never_closes_the_callers_descriptor() {
use std::io::{Read, Seek};
use std::os::fd::AsRawFd;
let path = temp_config("fd", "txt", "descriptor value");
let mut file = std::fs::File::open(&path).expect("open test descriptor");
let source = ValueSource::Fd(file.as_raw_fd());
assert_eq!(source.read().as_deref(), Ok("descriptor value"));
file.rewind()
.expect("the caller still owns an open descriptor");
let mut reread = String::new();
file.read_to_string(&mut reread)
.expect("read through caller-owned descriptor");
assert_eq!(reread, "descriptor value");
std::fs::remove_file(&path).expect("remove test config");
}
}