1use std::fmt;
28use std::path::Path;
29
30use crate::cli_spec::{SourceError, ValueSource};
31use crate::document::{DocumentFile, Format, Value};
32
33pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
38const MAX_STREAM_BYTES: usize = 1024 * 1024;
40
41type Result<T> = std::result::Result<T, SourceError>;
42
43#[derive(Clone, PartialEq, Eq)]
52pub struct SecretString(String);
53
54impl SecretString {
55 pub fn new(value: impl Into<String>) -> Self {
56 Self(value.into())
57 }
58
59 #[must_use]
62 pub fn expose_secret(&self) -> &str {
63 &self.0
64 }
65
66 #[must_use]
67 pub fn is_empty(&self) -> bool {
68 self.0.is_empty()
69 }
70}
71
72impl fmt::Debug for SecretString {
73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 formatter.write_str("***")
75 }
76}
77
78impl fmt::Display for SecretString {
79 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
80 formatter.write_str("***")
81 }
82}
83
84impl From<String> for SecretString {
85 fn from(value: String) -> Self {
86 Self(value)
87 }
88}
89
90impl ValueSource {
91 pub fn read(&self) -> Result<String> {
98 read_with(self, Policy::Plain)
99 }
100
101 pub fn read_secret(&self) -> Result<SecretString> {
107 read_with(self, Policy::Secret).map(SecretString)
108 }
109}
110
111fn read_with(source: &ValueSource, policy: Policy) -> Result<String> {
112 match source {
113 ValueSource::Literal(value) => Ok(value.clone()),
114 ValueSource::Env(name) => std::env::var(name).map_err(|error| {
115 let reason = match error {
116 std::env::VarError::NotPresent => "is unset",
117 std::env::VarError::NotUnicode(_) => "is not valid UTF-8",
118 };
119 SourceError::unreadable(format!("environment variable `{name}` {reason}"))
120 }),
121 ValueSource::File {
122 path,
123 dot_path,
124 format,
125 } => read_file(path, dot_path, format.as_deref(), policy),
126 ValueSource::Stdin => read_stream(std::io::stdin().lock(), "stdin"),
127 ValueSource::Fd(number) => read_fd(*number),
128 ValueSource::Prompt => read_prompt(),
129 ValueSource::Host { scheme, .. } => Err(SourceError::unreadable(format!(
130 "`{scheme}` is a host-defined source; this crate cannot read it"
131 ))),
132 }
133}
134
135#[derive(Clone, Copy, PartialEq, Eq)]
136enum Policy {
137 Plain,
138 Secret,
139}
140
141fn read_file(
142 path: &Path,
143 dot_path: &str,
144 named_format: Option<&str>,
145 policy: Policy,
146) -> Result<String> {
147 let format = match named_format {
151 Some(name) => Format::from_cli_name(name).ok_or_else(|| {
152 SourceError::invalid(format!("`file+{name}:` is not a format this build reads"))
153 })?,
154 None => Format::detect(path).ok_or_else(|| match Format::unavailable(path) {
155 Some(feature) => SourceError::unreadable(format!(
156 "cannot read {}: this build has no {feature} support",
157 path.display()
158 )),
159 None => SourceError::invalid(format!(
160 "cannot tell the config format of {} from its name; name it with \
161 file+FORMAT:{}#{dot_path}, or use a .json/.toml/.yaml/.env/.ini file",
162 path.display(),
163 path.display()
164 )),
165 })?,
166 };
167 let document = DocumentFile::open_capped(path, Some(format), MAX_FILE_BYTES).map_err(
170 |error| match policy {
171 Policy::Secret => SourceError::unreadable(format!(
174 "cannot read {} config {}: {}",
175 format.name(),
176 path.display(),
177 error.redacted_message()
178 )),
179 Policy::Plain => SourceError::unreadable(format!(
180 "cannot read {} config {}: {error}",
181 format.name(),
182 path.display()
183 )),
184 },
185 )?;
186 let value = document.value_at(dot_path).map_err(|error| {
187 if error.code() == "document_path_not_found" {
188 SourceError::unreadable(format!("{dot_path} was not found in {}", path.display()))
189 } else {
190 SourceError::unreadable(format!("cannot resolve {dot_path} in {}", path.display()))
191 }
192 })?;
193 scalar(value, path, dot_path, policy)
194}
195
196fn scalar(value: Value, path: &Path, dot_path: &str, policy: Policy) -> Result<String> {
197 let refused = |kind: &str| {
198 SourceError::unreadable(format!(
199 "{dot_path} in {} is {kind}, which is not a value",
200 path.display()
201 ))
202 };
203 match value {
204 Value::String(value) => Ok(value),
205 other if policy == Policy::Secret => Err(SourceError::unreadable(format!(
208 "{dot_path} in {} is {}; a secret must be a string",
209 path.display(),
210 other.kind_name()
211 ))),
212 Value::Integer(value) => Ok(value.to_string()),
213 Value::Unsigned(value) => Ok(value.to_string()),
214 Value::Float(value) => Ok(value.to_string()),
215 Value::Number(value) => Ok(value),
216 Value::Bool(value) => Ok(value.to_string()),
217 Value::Null => Err(refused("null")),
218 Value::Array(_) => Err(refused("an array")),
219 Value::Object(_) => Err(refused("an object")),
220 }
221}
222
223fn read_stream<R: std::io::Read>(reader: R, source: &str) -> Result<String> {
227 use std::io::Read;
228 let mut bytes = Vec::new();
229 reader
230 .take((MAX_STREAM_BYTES + 1) as u64)
231 .read_to_end(&mut bytes)
232 .map_err(|error| SourceError::unreadable(format!("read from {source}: {error}")))?;
233 if bytes.len() > MAX_STREAM_BYTES {
234 return Err(SourceError::unreadable(format!(
235 "{source} exceeds {MAX_STREAM_BYTES} bytes"
236 )));
237 }
238 String::from_utf8(bytes)
239 .map_err(|_| SourceError::unreadable(format!("{source} must carry valid UTF-8")))
240}
241
242#[cfg(unix)]
243fn read_fd(number: i32) -> Result<String> {
244 #[cfg(feature = "libc")]
245 let file = {
246 use std::os::fd::FromRawFd;
247
248 let duplicated = unsafe { libc::dup(number) };
255 if duplicated < 0 {
256 return Err(SourceError::unreadable(format!(
257 "open file descriptor {number}: {}",
258 std::io::Error::last_os_error()
259 )));
260 }
261 unsafe { std::fs::File::from_raw_fd(duplicated) }
264 };
265 #[cfg(not(feature = "libc"))]
266 let file = std::fs::File::open(format!("/dev/fd/{number}")).map_err(|error| {
267 SourceError::unreadable(format!("open file descriptor {number}: {error}"))
268 })?;
269 read_stream(file, "file descriptor")
270}
271
272#[cfg(not(unix))]
273fn read_fd(_number: i32) -> Result<String> {
274 Err(SourceError::unreadable(
275 "the `fd` source is unsupported on this platform",
276 ))
277}
278
279#[cfg(all(unix, feature = "libc"))]
280fn read_prompt() -> Result<String> {
281 use std::io::Write;
282
283 let mut tty = std::fs::OpenOptions::new()
284 .read(true)
285 .write(true)
286 .open("/dev/tty")
287 .map_err(|error| {
288 SourceError::unreadable(format!("open the controlling terminal: {error}"))
289 })?;
290 let restore_tty = tty.try_clone().map_err(|error| {
291 SourceError::unreadable(format!("prepare terminal echo restoration: {error}"))
292 })?;
293 let original = disable_terminal_echo(&tty)
294 .map_err(|error| SourceError::unreadable(format!("disable terminal echo: {error}")))?;
295 let _echo = EchoGuard {
296 tty: restore_tty,
297 original,
298 };
299 write!(tty, "Value: ")
300 .map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
301 let reader = std::io::BufReader::new(&mut tty);
302 let value = read_prompt_line(reader);
303 let _ = writeln!(tty);
305 value
306}
307
308#[cfg(all(unix, not(feature = "libc")))]
312fn read_prompt() -> Result<String> {
313 Err(SourceError::unreadable(
314 "the `prompt` source needs Cargo feature `libc` to turn terminal echo off",
315 ))
316}
317
318#[cfg(windows)]
319fn read_prompt() -> Result<String> {
320 use std::io::Write as _;
321
322 let input = std::fs::OpenOptions::new()
328 .read(true)
329 .write(true)
330 .open("CONIN$")
331 .map_err(|error| SourceError::unreadable(format!("open the console input: {error}")))?;
332 let mut output = std::fs::OpenOptions::new()
333 .write(true)
334 .open("CONOUT$")
335 .map_err(|error| SourceError::unreadable(format!("open the console output: {error}")))?;
336 let restore_console = input.try_clone().map_err(|error| {
337 SourceError::unreadable(format!("prepare console echo restoration: {error}"))
338 })?;
339 let restore_output = output.try_clone().map_err(|error| {
340 SourceError::unreadable(format!("prepare console echo restoration: {error}"))
341 })?;
342 let original = disable_console_echo(&input)
343 .map_err(|error| SourceError::unreadable(format!("disable console echo: {error}")))?;
344 let _echo = EchoGuard {
345 console: restore_console,
346 output: restore_output,
347 original,
348 };
349 write!(output, "Value: ")
350 .map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
351 let value = read_console_line(&input);
352 let _ = writeln!(output);
356 value
357}
358
359#[cfg(all(not(unix), not(windows)))]
360fn read_prompt() -> Result<String> {
361 Err(SourceError::unreadable(
362 "the `prompt` source is unsupported on this platform",
363 ))
364}
365
366#[cfg(windows)]
373mod windows_console {
374 use std::ffi::c_void;
375
376 pub(super) const ENABLE_ECHO_INPUT: u32 = 0x0004;
379
380 #[link(name = "kernel32")]
381 unsafe extern "system" {
382 pub(super) fn GetConsoleMode(console: *mut c_void, mode: *mut u32) -> i32;
383 pub(super) fn SetConsoleMode(console: *mut c_void, mode: u32) -> i32;
384 pub(super) fn ReadConsoleW(
385 console: *mut c_void,
386 buffer: *mut u16,
387 units_to_read: u32,
388 units_read: *mut u32,
389 input_control: *mut c_void,
390 ) -> i32;
391 }
392}
393
394#[cfg(windows)]
402fn disable_console_echo(console: &std::fs::File) -> std::io::Result<u32> {
403 let original = console_mode(console)?;
404 set_console_mode(console, original & !windows_console::ENABLE_ECHO_INPUT)?;
405 if console_mode(console)? & windows_console::ENABLE_ECHO_INPUT != 0 {
406 let _ = set_console_mode(console, original);
409 return Err(std::io::Error::other("console echo is still enabled"));
410 }
411 Ok(original)
412}
413
414#[cfg(windows)]
415fn console_mode(console: &std::fs::File) -> std::io::Result<u32> {
416 use std::os::windows::io::AsRawHandle as _;
417
418 let mut mode = 0u32;
419 let status = unsafe { windows_console::GetConsoleMode(console.as_raw_handle(), &mut mode) };
422 if status == 0 {
423 return Err(std::io::Error::last_os_error());
424 }
425 Ok(mode)
426}
427
428#[cfg(windows)]
429fn set_console_mode(console: &std::fs::File, mode: u32) -> std::io::Result<()> {
430 use std::os::windows::io::AsRawHandle as _;
431
432 let status = unsafe { windows_console::SetConsoleMode(console.as_raw_handle(), mode) };
435 if status == 0 {
436 return Err(std::io::Error::last_os_error());
437 }
438 Ok(())
439}
440
441#[cfg(windows)]
451fn read_console_line(console: &std::fs::File) -> Result<String> {
452 use std::os::windows::io::AsRawHandle as _;
453
454 let mut buffer = vec![0u16; MAX_STREAM_BYTES + 4];
459 let units_to_read = u32::try_from(buffer.len())
460 .map_err(|_| SourceError::unreadable("the console read buffer does not fit a request"))?;
461 let mut units_read = 0u32;
462 let status = unsafe {
466 windows_console::ReadConsoleW(
467 console.as_raw_handle(),
468 buffer.as_mut_ptr(),
469 units_to_read,
470 &mut units_read,
471 std::ptr::null_mut(),
472 )
473 };
474 if status == 0 {
475 return Err(SourceError::unreadable(format!(
476 "read from the console: {}",
477 std::io::Error::last_os_error()
478 )));
479 }
480 let units = buffer
481 .get(..units_read as usize)
482 .ok_or_else(|| SourceError::unreadable("the console reported reading past its buffer"))?;
483 let text = String::from_utf16(units)
484 .map_err(|_| SourceError::unreadable("the console answered malformed UTF-16"))?;
485 read_prompt_line(std::io::Cursor::new(text.as_bytes()))
487}
488
489#[cfg(all(unix, feature = "libc"))]
498fn disable_terminal_echo(tty: &std::fs::File) -> std::io::Result<libc::termios> {
499 let original = terminal_attributes(tty)?;
500 let mut quiet = original;
501 quiet.c_lflag &= !libc::ECHO;
502 set_terminal_attributes(tty, &quiet)?;
503 if terminal_attributes(tty)?.c_lflag & libc::ECHO != 0 {
504 let _ = set_terminal_attributes(tty, &original);
507 return Err(std::io::Error::other("terminal echo is still enabled"));
508 }
509 Ok(original)
510}
511
512#[cfg(all(unix, feature = "libc"))]
513fn terminal_attributes(tty: &std::fs::File) -> std::io::Result<libc::termios> {
514 use std::os::fd::AsRawFd as _;
515
516 let mut attributes = std::mem::MaybeUninit::<libc::termios>::uninit();
517 let status = unsafe { libc::tcgetattr(tty.as_raw_fd(), attributes.as_mut_ptr()) };
520 if status != 0 {
521 return Err(std::io::Error::last_os_error());
522 }
523 Ok(unsafe { attributes.assume_init() })
525}
526
527#[cfg(all(unix, feature = "libc"))]
528fn set_terminal_attributes(tty: &std::fs::File, attributes: &libc::termios) -> std::io::Result<()> {
529 use std::os::fd::AsRawFd as _;
530
531 let status = unsafe { libc::tcsetattr(tty.as_raw_fd(), libc::TCSAFLUSH, attributes) };
536 if status != 0 {
537 return Err(std::io::Error::last_os_error());
538 }
539 Ok(())
540}
541
542#[cfg(any(all(unix, feature = "libc"), windows, test))]
543fn read_prompt_line<R: std::io::BufRead>(reader: R) -> Result<String> {
544 use std::io::BufRead;
545
546 let mut limited = reader.take((MAX_STREAM_BYTES + 2) as u64);
549 let mut value = String::new();
550 let read = limited
551 .read_line(&mut value)
552 .map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
553 if read == 0 {
554 return Err(SourceError::unreadable(
555 "the terminal closed before a value was entered",
556 ));
557 }
558 let value = match value.strip_suffix('\n') {
559 Some(line) => line.strip_suffix('\r').unwrap_or(line),
560 None => &value,
561 };
562 if value.len() > MAX_STREAM_BYTES {
563 return Err(SourceError::unreadable(format!(
564 "prompt exceeds {MAX_STREAM_BYTES} bytes"
565 )));
566 }
567 Ok(value.to_string())
568}
569
570#[cfg(all(unix, feature = "libc"))]
572struct EchoGuard {
573 tty: std::fs::File,
574 original: libc::termios,
575}
576
577#[cfg(all(unix, feature = "libc"))]
578impl Drop for EchoGuard {
579 fn drop(&mut self) {
580 use std::io::Write as _;
581
582 if set_terminal_attributes(&self.tty, &self.original).is_ok() {
583 return;
584 }
585 let _ = writeln!(
590 &mut self.tty,
591 "\nwarning: could not restore terminal echo; run `stty echo` to fix this terminal"
592 );
593 }
594}
595
596#[cfg(windows)]
602struct EchoGuard {
603 console: std::fs::File,
604 output: std::fs::File,
605 original: u32,
606}
607
608#[cfg(windows)]
609impl Drop for EchoGuard {
610 fn drop(&mut self) {
611 use std::io::Write as _;
612
613 if set_console_mode(&self.console, self.original).is_ok() {
614 return;
615 }
616 let _ = writeln!(
621 &mut self.output,
622 "\nwarning: could not restore console echo; close this console window to get it back"
623 );
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[cfg(all(unix, feature = "libc"))]
632 #[test]
633 fn terminal_echo_control_goes_to_the_terminal_not_to_a_program() {
634 use std::io::Write as _;
635
636 let path = std::env::temp_dir().join(format!(
641 "afdata_not_a_tty_{}_{}",
642 std::process::id(),
643 std::time::SystemTime::now()
644 .duration_since(std::time::UNIX_EPOCH)
645 .map(|d| d.as_nanos())
646 .unwrap_or(0)
647 ));
648 let mut file = match std::fs::File::create(&path) {
649 Ok(file) => file,
650 Err(_) => return,
651 };
652 let _ = file.write_all(b"not a terminal");
653
654 let error = disable_terminal_echo(&file)
655 .err()
656 .map(|error| error.raw_os_error());
657
658 let _ = std::fs::remove_file(&path);
659 assert_eq!(
660 error,
661 Some(Some(libc::ENOTTY)),
662 "echo control must fail as a terminal call, not as a missing program"
663 );
664 }
665
666 #[cfg(all(unix, feature = "libc"))]
667 #[test]
668 fn a_failed_restore_is_reported_rather_than_swallowed() {
669 let path = std::env::temp_dir().join(format!(
674 "afdata_echo_guard_{}_{}",
675 std::process::id(),
676 std::time::SystemTime::now()
677 .duration_since(std::time::UNIX_EPOCH)
678 .map(|d| d.as_nanos())
679 .unwrap_or(0)
680 ));
681 let Ok(file) = std::fs::File::options()
682 .create(true)
683 .truncate(true)
684 .read(true)
685 .write(true)
686 .open(&path)
687 else {
688 return;
689 };
690 let original = unsafe { std::mem::zeroed::<libc::termios>() };
694 drop(EchoGuard {
695 tty: file,
696 original,
697 });
698
699 let announced = std::fs::read_to_string(&path).unwrap_or_default();
700 let _ = std::fs::remove_file(&path);
701 assert!(
702 announced.contains("could not restore terminal echo"),
703 "a terminal left without echo must say so: {announced:?}"
704 );
705 }
706
707 #[cfg(windows)]
710 mod windows_echo {
711 use super::super::{EchoGuard, disable_console_echo};
712
713 const ERROR_INVALID_HANDLE: i32 = 6;
718
719 fn scratch_file(label: &str) -> Option<(std::path::PathBuf, std::fs::File)> {
720 let path = std::env::temp_dir().join(format!(
721 "afdata_{label}_{}_{}",
722 std::process::id(),
723 std::time::SystemTime::now()
724 .duration_since(std::time::UNIX_EPOCH)
725 .map(|d| d.as_nanos())
726 .unwrap_or(0)
727 ));
728 let file = std::fs::File::options()
729 .create(true)
730 .truncate(true)
731 .read(true)
732 .write(true)
733 .open(&path)
734 .ok()?;
735 Some((path, file))
736 }
737
738 #[test]
739 fn echo_control_goes_to_the_console_not_to_a_program() {
740 let Some((path, file)) = scratch_file("not_a_console") else {
741 return;
742 };
743
744 let error = disable_console_echo(&file)
745 .err()
746 .and_then(|error| error.raw_os_error());
747
748 drop(file);
749 let _ = std::fs::remove_file(&path);
750 assert_eq!(
751 error,
752 Some(ERROR_INVALID_HANDLE),
753 "echo control must fail as a console call, not as a missing program"
754 );
755 }
756
757 #[test]
758 fn a_failed_restore_is_reported_rather_than_swallowed() {
759 let Some((path, file)) = scratch_file("console_echo_guard") else {
760 return;
761 };
762 let Ok(output) = file.try_clone() else {
763 let _ = std::fs::remove_file(&path);
764 return;
765 };
766
767 drop(EchoGuard {
770 console: file,
771 output,
772 original: 0,
773 });
774
775 let announced = std::fs::read_to_string(&path).unwrap_or_default();
776 let _ = std::fs::remove_file(&path);
777 assert!(
778 announced.contains("could not restore console echo"),
779 "a console left without echo must say so: {announced:?}"
780 );
781 }
782 }
783
784 use crate::cli_spec::SourceSet;
785 use std::path::PathBuf;
786
787 fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
788 let path = std::env::temp_dir().join(format!(
789 "afdata-value-source-{name}-{}.{extension}",
790 std::process::id()
791 ));
792 std::fs::write(&path, content).expect("write test config");
793 path
794 }
795
796 fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
802 #[allow(unused_mut)]
805 let mut cases: Vec<(&str, &str, &str, &str)> =
806 vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
807 #[cfg(feature = "toml")]
808 cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
809 #[cfg(feature = "yaml")]
810 cases.push(("yaml", "yaml", "a:\n b: ' v '\n", "a.b"));
811 #[cfg(feature = "dotenv")]
812 cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
813 cases
814 }
815
816 #[test]
817 fn a_file_source_reads_one_address_out_of_every_format() {
818 for (name, extension, content, dot_path) in readable_formats() {
819 let path = temp_config(name, extension, content);
820 let source = ValueSource::File {
821 path: path.clone(),
822 dot_path: dot_path.to_string(),
823 format: None,
824 };
825 let read = source.read();
826 let secret = source.read_secret();
827 std::fs::remove_file(&path).expect("remove test config");
828 assert_eq!(read.as_deref(), Ok(" v "), "{name}");
830 assert_eq!(
831 secret.expect("secret read").expose_secret(),
832 " v ",
833 "{name}"
834 );
835 }
836 }
837
838 #[test]
839 fn an_empty_string_is_still_a_value() {
840 let path = temp_config("empty", "json", r#"{"empty":""}"#);
841 let source = ValueSource::File {
842 path: path.clone(),
843 dot_path: "empty".to_string(),
844 format: None,
845 };
846 assert_eq!(source.read().as_deref(), Ok(""));
847 let secret = source.read_secret().expect("empty secret remains explicit");
848 assert!(secret.is_empty());
849 std::fs::remove_file(&path).expect("remove test config");
850 }
851
852 #[cfg(feature = "ini")]
859 #[test]
860 fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
861 let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
862 let named = ValueSource::File {
863 path: path.clone(),
864 dot_path: "http-password".to_string(),
865 format: Some("ini".to_string()),
866 };
867 let unnamed = ValueSource::File {
868 path: path.clone(),
869 dot_path: "http-password".to_string(),
870 format: None,
871 };
872 let bad_name = ValueSource::File {
873 path: path.clone(),
874 dot_path: "http-password".to_string(),
875 format: Some("nonsense".to_string()),
876 };
877 let read = named.read_secret();
878 let without = unnamed.read();
879 let bad = bad_name.read();
880 std::fs::remove_file(&path).expect("remove test config");
881
882 assert_eq!(read.expect("named format").expose_secret(), "abc123");
883 let without = without.expect_err("no extension to detect");
886 assert!(without.message().contains("file+FORMAT:"), "{without}");
887 let bad = bad.expect_err("unknown format");
888 assert!(
889 bad.message().contains("not a format this build reads"),
890 "{bad}"
891 );
892 }
893
894 #[test]
897 fn a_non_string_scalar_is_a_value_but_never_a_secret() {
898 let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
899 let port = ValueSource::File {
900 path: path.clone(),
901 dot_path: "port".to_string(),
902 format: None,
903 };
904 assert_eq!(port.read().as_deref(), Ok("5432"));
905 let error = port.read_secret().expect_err("a secret must be a string");
906 assert!(error.message().contains("must be a string"), "{error}");
907
908 let on = ValueSource::File {
909 path: path.clone(),
910 dot_path: "on".to_string(),
911 format: None,
912 };
913 assert_eq!(on.read().as_deref(), Ok("true"));
914 std::fs::remove_file(&path).expect("remove test config");
915 }
916
917 #[test]
920 fn a_secret_read_never_echoes_what_it_read() {
921 let canary = "AFDATA_SOURCE_CANARY";
922 let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
925 let source = ValueSource::File {
926 path: path.clone(),
927 dot_path: "a".to_string(),
928 format: None,
929 };
930 let plain = source.read().expect_err("malformed");
931 let secret = source.read_secret().expect_err("malformed");
932 std::fs::remove_file(&path).expect("remove test config");
933 assert!(
934 !secret.message().contains(canary),
935 "secret read leaked: {secret}"
936 );
937 assert!(plain.message().contains("cannot read"), "{plain}");
939 }
940
941 #[test]
942 fn a_collection_is_not_a_value() {
943 let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
944 for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
945 let source = ValueSource::File {
946 path: path.clone(),
947 dot_path: dot_path.to_string(),
948 format: None,
949 };
950 let error = source.read().expect_err(dot_path);
951 assert!(error.message().contains(expected), "{dot_path}: {error}");
952 }
953 std::fs::remove_file(&path).expect("remove test config");
954 }
955
956 #[test]
958 fn a_host_scheme_is_not_this_crates_to_read() {
959 let error = SourceSet::config()
960 .host_scheme("container", "container:NAME")
961 .parse("container:x")
962 .expect("parses")
963 .read()
964 .expect_err("this crate cannot read it");
965 assert_eq!(error.code(), "value_source_unreadable");
966 }
967
968 #[test]
969 fn an_unset_environment_source_names_what_it_tried() {
970 const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
971 let error = ValueSource::Env(ABSENT.to_string())
972 .read()
973 .expect_err("unset");
974 assert_eq!(error.code(), "value_source_unreadable");
975 assert!(error.message().contains(ABSENT), "{error}");
976 }
977
978 #[test]
981 fn a_secret_string_cannot_be_printed_by_accident() {
982 let secret = SecretString::new("s3cret");
983 assert_eq!(format!("{secret}"), "***");
984 assert_eq!(format!("{secret:?}"), "***");
985 assert!(!format!("{secret:?} {secret}").contains("s3cret"));
986 assert_eq!(secret.expose_secret(), "s3cret");
987 #[derive(Debug)]
989 struct Config {
990 #[allow(dead_code)]
991 token_secret: SecretString,
992 }
993 let printed = format!(
994 "{:?}",
995 Config {
996 token_secret: secret
997 }
998 );
999 assert!(!printed.contains("s3cret"), "{printed}");
1000 }
1001
1002 #[test]
1003 fn a_stream_is_read_verbatim_and_capped() {
1004 assert_eq!(
1005 read_stream(" v \n".as_bytes(), "test").as_deref(),
1006 Ok(" v \n")
1007 );
1008 let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
1009 let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
1010 assert!(error.message().contains("exceeds"), "{error}");
1011 }
1012
1013 #[test]
1014 fn prompt_eof_is_not_an_empty_answer_and_only_one_line_ending_is_removed() {
1015 assert!(read_prompt_line(std::io::Cursor::new(b"")).is_err());
1016 for (input, expected) in [
1017 ("\n", ""),
1018 ("\r\n", ""),
1019 ("value\r\r\n", "value\r"),
1020 ("value\r", "value\r"),
1021 ] {
1022 assert_eq!(
1023 read_prompt_line(std::io::Cursor::new(input)).expect("answer"),
1024 expected
1025 );
1026 }
1027 }
1028
1029 #[test]
1030 fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
1031 let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
1032 assert_eq!(
1033 read_prompt_line(std::io::Cursor::new(exact))
1034 .expect("cap-sized line")
1035 .len(),
1036 MAX_STREAM_BYTES
1037 );
1038 let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
1039 let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
1040 assert!(error.message().contains("exceeds"), "{error}");
1041 }
1042
1043 #[cfg(unix)]
1044 #[test]
1045 fn an_fd_source_never_closes_the_callers_descriptor() {
1046 use std::io::{Read, Seek};
1047 use std::os::fd::AsRawFd;
1048
1049 let path = temp_config("fd", "txt", "descriptor value");
1050 let mut file = std::fs::File::open(&path).expect("open test descriptor");
1051 let source = ValueSource::Fd(file.as_raw_fd());
1052 assert_eq!(source.read().as_deref(), Ok("descriptor value"));
1053
1054 file.rewind()
1055 .expect("the caller still owns an open descriptor");
1056 let mut reread = String::new();
1057 file.read_to_string(&mut reread)
1058 .expect("read through caller-owned descriptor");
1059 assert_eq!(reread, "descriptor value");
1060 std::fs::remove_file(&path).expect("remove test config");
1061 }
1062}