1use std::fmt;
28use std::path::Path;
29
30use crate::cli_spec::{SourceError, ValueSource};
31use crate::document::{DocumentFile, Format, Value};
32
33const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
35const MAX_STREAM_BYTES: usize = 1024 * 1024;
37
38type Result<T> = std::result::Result<T, SourceError>;
39
40#[derive(Clone, PartialEq, Eq)]
49pub struct SecretString(String);
50
51impl SecretString {
52 pub fn new(value: impl Into<String>) -> Self {
53 Self(value.into())
54 }
55
56 #[must_use]
59 pub fn expose_secret(&self) -> &str {
60 &self.0
61 }
62
63 #[must_use]
64 pub fn is_empty(&self) -> bool {
65 self.0.is_empty()
66 }
67}
68
69impl fmt::Debug for SecretString {
70 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71 formatter.write_str("***")
72 }
73}
74
75impl fmt::Display for SecretString {
76 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77 formatter.write_str("***")
78 }
79}
80
81impl From<String> for SecretString {
82 fn from(value: String) -> Self {
83 Self(value)
84 }
85}
86
87impl ValueSource {
88 pub fn read(&self) -> Result<String> {
95 read_with(self, Policy::Plain)
96 }
97
98 pub fn read_secret(&self) -> Result<SecretString> {
104 read_with(self, Policy::Secret).map(SecretString)
105 }
106}
107
108fn read_with(source: &ValueSource, policy: Policy) -> Result<String> {
109 match source {
110 ValueSource::Literal(value) => Ok(value.clone()),
111 ValueSource::Env(name) => std::env::var(name).map_err(|error| {
112 let reason = match error {
113 std::env::VarError::NotPresent => "is unset",
114 std::env::VarError::NotUnicode(_) => "is not valid UTF-8",
115 };
116 SourceError::unreadable(format!("environment variable `{name}` {reason}"))
117 }),
118 ValueSource::File {
119 path,
120 dot_path,
121 format,
122 } => read_file(path, dot_path, format.as_deref(), policy),
123 ValueSource::Stdin => read_stream(std::io::stdin().lock(), "stdin"),
124 ValueSource::Fd(number) => read_fd(*number),
125 ValueSource::Prompt => read_prompt(),
126 ValueSource::Host { scheme, .. } => Err(SourceError::unreadable(format!(
127 "`{scheme}` is a host-defined source; this crate cannot read it"
128 ))),
129 }
130}
131
132#[derive(Clone, Copy, PartialEq, Eq)]
133enum Policy {
134 Plain,
135 Secret,
136}
137
138fn read_file(
139 path: &Path,
140 dot_path: &str,
141 named_format: Option<&str>,
142 policy: Policy,
143) -> Result<String> {
144 let format = match named_format {
148 Some(name) => Format::from_cli_name(name).ok_or_else(|| {
149 SourceError::invalid(format!("`file+{name}:` is not a format this build reads"))
150 })?,
151 None => Format::detect(path).ok_or_else(|| match Format::unavailable(path) {
152 Some(feature) => SourceError::unreadable(format!(
153 "cannot read {}: this build has no {feature} support",
154 path.display()
155 )),
156 None => SourceError::invalid(format!(
157 "cannot tell the config format of {} from its name; name it with \
158 file+FORMAT:{}#{dot_path}, or use a .json/.toml/.yaml/.env/.ini file",
159 path.display(),
160 path.display()
161 )),
162 })?,
163 };
164 let document = DocumentFile::open_capped(path, Some(format), MAX_FILE_BYTES).map_err(
167 |error| match policy {
168 Policy::Secret => SourceError::unreadable(format!(
171 "cannot read {} config {}: {}",
172 format.name(),
173 path.display(),
174 error.redacted_message()
175 )),
176 Policy::Plain => SourceError::unreadable(format!(
177 "cannot read {} config {}: {error}",
178 format.name(),
179 path.display()
180 )),
181 },
182 )?;
183 let value = document.value_at(dot_path).map_err(|error| {
184 if error.code() == "document_path_not_found" {
185 SourceError::unreadable(format!("{dot_path} was not found in {}", path.display()))
186 } else {
187 SourceError::unreadable(format!("cannot resolve {dot_path} in {}", path.display()))
188 }
189 })?;
190 scalar(value, path, dot_path, policy)
191}
192
193fn scalar(value: Value, path: &Path, dot_path: &str, policy: Policy) -> Result<String> {
194 let refused = |kind: &str| {
195 SourceError::unreadable(format!(
196 "{dot_path} in {} is {kind}, which is not a value",
197 path.display()
198 ))
199 };
200 match value {
201 Value::String(value) => Ok(value),
202 other if policy == Policy::Secret => Err(SourceError::unreadable(format!(
205 "{dot_path} in {} is {}; a secret must be a string",
206 path.display(),
207 other.kind_name()
208 ))),
209 Value::Integer(value) => Ok(value.to_string()),
210 Value::Unsigned(value) => Ok(value.to_string()),
211 Value::Float(value) => Ok(value.to_string()),
212 Value::Number(value) => Ok(value),
213 Value::Bool(value) => Ok(value.to_string()),
214 Value::Null => Err(refused("null")),
215 Value::Array(_) => Err(refused("an array")),
216 Value::Object(_) => Err(refused("an object")),
217 }
218}
219
220fn read_stream<R: std::io::Read>(reader: R, source: &str) -> Result<String> {
224 use std::io::Read;
225 let mut bytes = Vec::new();
226 reader
227 .take((MAX_STREAM_BYTES + 1) as u64)
228 .read_to_end(&mut bytes)
229 .map_err(|error| SourceError::unreadable(format!("read from {source}: {error}")))?;
230 if bytes.len() > MAX_STREAM_BYTES {
231 return Err(SourceError::unreadable(format!(
232 "{source} exceeds {MAX_STREAM_BYTES} bytes"
233 )));
234 }
235 String::from_utf8(bytes)
236 .map_err(|_| SourceError::unreadable(format!("{source} must carry valid UTF-8")))
237}
238
239#[cfg(unix)]
240fn read_fd(number: i32) -> Result<String> {
241 #[cfg(feature = "libc")]
242 let file = {
243 use std::os::fd::FromRawFd;
244
245 let duplicated = unsafe { libc::dup(number) };
252 if duplicated < 0 {
253 return Err(SourceError::unreadable(format!(
254 "open file descriptor {number}: {}",
255 std::io::Error::last_os_error()
256 )));
257 }
258 unsafe { std::fs::File::from_raw_fd(duplicated) }
261 };
262 #[cfg(not(feature = "libc"))]
263 let file = std::fs::File::open(format!("/dev/fd/{number}")).map_err(|error| {
264 SourceError::unreadable(format!("open file descriptor {number}: {error}"))
265 })?;
266 read_stream(file, "file descriptor")
267}
268
269#[cfg(not(unix))]
270fn read_fd(_number: i32) -> Result<String> {
271 Err(SourceError::unreadable(
272 "the `fd` source is unsupported on this platform",
273 ))
274}
275
276#[cfg(unix)]
277fn read_prompt() -> Result<String> {
278 use std::io::Write;
279
280 let mut tty = std::fs::OpenOptions::new()
281 .read(true)
282 .write(true)
283 .open("/dev/tty")
284 .map_err(|error| {
285 SourceError::unreadable(format!("open the controlling terminal: {error}"))
286 })?;
287 let restore_tty = tty.try_clone().map_err(|error| {
288 SourceError::unreadable(format!("prepare terminal echo restoration: {error}"))
289 })?;
290 let disabled = set_terminal_echo(&tty, false)
291 .map_err(|error| SourceError::unreadable(format!("disable terminal echo: {error}")))?;
292 if !disabled {
293 return Err(SourceError::unreadable("disabling terminal echo failed"));
294 }
295 let _echo = EchoGuard { tty: restore_tty };
296 write!(tty, "Value: ")
297 .map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
298 let reader = std::io::BufReader::new(&mut tty);
299 let value = read_prompt_line(reader);
300 let _ = writeln!(tty);
302 value
303}
304
305#[cfg(not(unix))]
306fn read_prompt() -> Result<String> {
307 Err(SourceError::unreadable(
308 "the `prompt` source is unsupported on this platform",
309 ))
310}
311
312#[cfg(unix)]
314fn set_terminal_echo(tty: &std::fs::File, enabled: bool) -> std::io::Result<bool> {
315 use std::process::Stdio;
316
317 let input = tty.try_clone()?;
318 std::process::Command::new("stty")
319 .arg(if enabled { "echo" } else { "-echo" })
320 .stdin(Stdio::from(input))
323 .stdout(Stdio::null())
326 .stderr(Stdio::null())
327 .status()
328 .map(|status| status.success())
329}
330
331#[cfg(any(unix, test))]
332fn read_prompt_line<R: std::io::BufRead>(reader: R) -> Result<String> {
333 use std::io::BufRead;
334
335 let mut limited = reader.take((MAX_STREAM_BYTES + 2) as u64);
338 let mut value = String::new();
339 limited
340 .read_line(&mut value)
341 .map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
342 let value = value.trim_end_matches(['\r', '\n']);
343 if value.len() > MAX_STREAM_BYTES {
344 return Err(SourceError::unreadable(format!(
345 "prompt exceeds {MAX_STREAM_BYTES} bytes"
346 )));
347 }
348 Ok(value.to_string())
349}
350
351#[cfg(unix)]
352struct EchoGuard {
353 tty: std::fs::File,
354}
355
356#[cfg(unix)]
357impl Drop for EchoGuard {
358 fn drop(&mut self) {
359 let _ = set_terminal_echo(&self.tty, true);
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::cli_spec::SourceSet;
367 use std::path::PathBuf;
368
369 fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
370 let path = std::env::temp_dir().join(format!(
371 "afdata-value-source-{name}-{}.{extension}",
372 std::process::id()
373 ));
374 std::fs::write(&path, content).expect("write test config");
375 path
376 }
377
378 fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
384 #[allow(unused_mut)]
387 let mut cases: Vec<(&str, &str, &str, &str)> =
388 vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
389 #[cfg(feature = "toml")]
390 cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
391 #[cfg(feature = "yaml")]
392 cases.push(("yaml", "yaml", "a:\n b: ' v '\n", "a.b"));
393 #[cfg(feature = "dotenv")]
394 cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
395 cases
396 }
397
398 #[test]
399 fn a_file_source_reads_one_address_out_of_every_format() {
400 for (name, extension, content, dot_path) in readable_formats() {
401 let path = temp_config(name, extension, content);
402 let source = ValueSource::File {
403 path: path.clone(),
404 dot_path: dot_path.to_string(),
405 format: None,
406 };
407 let read = source.read();
408 let secret = source.read_secret();
409 std::fs::remove_file(&path).expect("remove test config");
410 assert_eq!(read.as_deref(), Ok(" v "), "{name}");
412 assert_eq!(
413 secret.expect("secret read").expose_secret(),
414 " v ",
415 "{name}"
416 );
417 }
418 }
419
420 #[test]
421 fn an_empty_string_is_still_a_value() {
422 let path = temp_config("empty", "json", r#"{"empty":""}"#);
423 let source = ValueSource::File {
424 path: path.clone(),
425 dot_path: "empty".to_string(),
426 format: None,
427 };
428 assert_eq!(source.read().as_deref(), Ok(""));
429 let secret = source.read_secret().expect("empty secret remains explicit");
430 assert!(secret.is_empty());
431 std::fs::remove_file(&path).expect("remove test config");
432 }
433
434 #[cfg(feature = "ini")]
441 #[test]
442 fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
443 let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
444 let named = ValueSource::File {
445 path: path.clone(),
446 dot_path: "http-password".to_string(),
447 format: Some("ini".to_string()),
448 };
449 let unnamed = ValueSource::File {
450 path: path.clone(),
451 dot_path: "http-password".to_string(),
452 format: None,
453 };
454 let bad_name = ValueSource::File {
455 path: path.clone(),
456 dot_path: "http-password".to_string(),
457 format: Some("nonsense".to_string()),
458 };
459 let read = named.read_secret();
460 let without = unnamed.read();
461 let bad = bad_name.read();
462 std::fs::remove_file(&path).expect("remove test config");
463
464 assert_eq!(read.expect("named format").expose_secret(), "abc123");
465 let without = without.expect_err("no extension to detect");
468 assert!(without.message().contains("file+FORMAT:"), "{without}");
469 let bad = bad.expect_err("unknown format");
470 assert!(
471 bad.message().contains("not a format this build reads"),
472 "{bad}"
473 );
474 }
475
476 #[test]
479 fn a_non_string_scalar_is_a_value_but_never_a_secret() {
480 let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
481 let port = ValueSource::File {
482 path: path.clone(),
483 dot_path: "port".to_string(),
484 format: None,
485 };
486 assert_eq!(port.read().as_deref(), Ok("5432"));
487 let error = port.read_secret().expect_err("a secret must be a string");
488 assert!(error.message().contains("must be a string"), "{error}");
489
490 let on = ValueSource::File {
491 path: path.clone(),
492 dot_path: "on".to_string(),
493 format: None,
494 };
495 assert_eq!(on.read().as_deref(), Ok("true"));
496 std::fs::remove_file(&path).expect("remove test config");
497 }
498
499 #[test]
502 fn a_secret_read_never_echoes_what_it_read() {
503 let canary = "AFDATA_SOURCE_CANARY";
504 let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
507 let source = ValueSource::File {
508 path: path.clone(),
509 dot_path: "a".to_string(),
510 format: None,
511 };
512 let plain = source.read().expect_err("malformed");
513 let secret = source.read_secret().expect_err("malformed");
514 std::fs::remove_file(&path).expect("remove test config");
515 assert!(
516 !secret.message().contains(canary),
517 "secret read leaked: {secret}"
518 );
519 assert!(plain.message().contains("cannot read"), "{plain}");
521 }
522
523 #[test]
524 fn a_collection_is_not_a_value() {
525 let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
526 for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
527 let source = ValueSource::File {
528 path: path.clone(),
529 dot_path: dot_path.to_string(),
530 format: None,
531 };
532 let error = source.read().expect_err(dot_path);
533 assert!(error.message().contains(expected), "{dot_path}: {error}");
534 }
535 std::fs::remove_file(&path).expect("remove test config");
536 }
537
538 #[test]
540 fn a_host_scheme_is_not_this_crates_to_read() {
541 let error = SourceSet::config()
542 .host_scheme("container", "container:NAME")
543 .parse("container:x")
544 .expect("parses")
545 .read()
546 .expect_err("this crate cannot read it");
547 assert_eq!(error.code(), "value_source_unreadable");
548 }
549
550 #[test]
551 fn an_unset_environment_source_names_what_it_tried() {
552 const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
553 let error = ValueSource::Env(ABSENT.to_string())
554 .read()
555 .expect_err("unset");
556 assert_eq!(error.code(), "value_source_unreadable");
557 assert!(error.message().contains(ABSENT), "{error}");
558 }
559
560 #[test]
563 fn a_secret_string_cannot_be_printed_by_accident() {
564 let secret = SecretString::new("s3cret");
565 assert_eq!(format!("{secret}"), "***");
566 assert_eq!(format!("{secret:?}"), "***");
567 assert!(!format!("{secret:?} {secret}").contains("s3cret"));
568 assert_eq!(secret.expose_secret(), "s3cret");
569 #[derive(Debug)]
571 struct Config {
572 #[allow(dead_code)]
573 token_secret: SecretString,
574 }
575 let printed = format!(
576 "{:?}",
577 Config {
578 token_secret: secret
579 }
580 );
581 assert!(!printed.contains("s3cret"), "{printed}");
582 }
583
584 #[test]
585 fn a_stream_is_read_verbatim_and_capped() {
586 assert_eq!(
587 read_stream(" v \n".as_bytes(), "test").as_deref(),
588 Ok(" v \n")
589 );
590 let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
591 let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
592 assert!(error.message().contains("exceeds"), "{error}");
593 }
594
595 #[test]
596 fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
597 let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
598 assert_eq!(
599 read_prompt_line(std::io::Cursor::new(exact))
600 .expect("cap-sized line")
601 .len(),
602 MAX_STREAM_BYTES
603 );
604 let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
605 let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
606 assert!(error.message().contains("exceeds"), "{error}");
607 }
608
609 #[cfg(unix)]
610 #[test]
611 fn an_fd_source_never_closes_the_callers_descriptor() {
612 use std::io::{Read, Seek};
613 use std::os::fd::AsRawFd;
614
615 let path = temp_config("fd", "txt", "descriptor value");
616 let mut file = std::fs::File::open(&path).expect("open test descriptor");
617 let source = ValueSource::Fd(file.as_raw_fd());
618 assert_eq!(source.read().as_deref(), Ok("descriptor value"));
619
620 file.rewind()
621 .expect("the caller still owns an open descriptor");
622 let mut reread = String::new();
623 file.read_to_string(&mut reread)
624 .expect("read through caller-owned descriptor");
625 assert_eq!(reread, "descriptor value");
626 std::fs::remove_file(&path).expect("remove test config");
627 }
628}