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 limited
551 .read_line(&mut value)
552 .map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
553 let value = value.trim_end_matches(['\r', '\n']);
554 if value.len() > MAX_STREAM_BYTES {
555 return Err(SourceError::unreadable(format!(
556 "prompt exceeds {MAX_STREAM_BYTES} bytes"
557 )));
558 }
559 Ok(value.to_string())
560}
561
562#[cfg(all(unix, feature = "libc"))]
564struct EchoGuard {
565 tty: std::fs::File,
566 original: libc::termios,
567}
568
569#[cfg(all(unix, feature = "libc"))]
570impl Drop for EchoGuard {
571 fn drop(&mut self) {
572 use std::io::Write as _;
573
574 if set_terminal_attributes(&self.tty, &self.original).is_ok() {
575 return;
576 }
577 let _ = writeln!(
582 &mut self.tty,
583 "\nwarning: could not restore terminal echo; run `stty echo` to fix this terminal"
584 );
585 }
586}
587
588#[cfg(windows)]
594struct EchoGuard {
595 console: std::fs::File,
596 output: std::fs::File,
597 original: u32,
598}
599
600#[cfg(windows)]
601impl Drop for EchoGuard {
602 fn drop(&mut self) {
603 use std::io::Write as _;
604
605 if set_console_mode(&self.console, self.original).is_ok() {
606 return;
607 }
608 let _ = writeln!(
613 &mut self.output,
614 "\nwarning: could not restore console echo; close this console window to get it back"
615 );
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[cfg(all(unix, feature = "libc"))]
624 #[test]
625 fn terminal_echo_control_goes_to_the_terminal_not_to_a_program() {
626 use std::io::Write as _;
627
628 let path = std::env::temp_dir().join(format!(
633 "afdata_not_a_tty_{}_{}",
634 std::process::id(),
635 std::time::SystemTime::now()
636 .duration_since(std::time::UNIX_EPOCH)
637 .map(|d| d.as_nanos())
638 .unwrap_or(0)
639 ));
640 let mut file = match std::fs::File::create(&path) {
641 Ok(file) => file,
642 Err(_) => return,
643 };
644 let _ = file.write_all(b"not a terminal");
645
646 let error = disable_terminal_echo(&file)
647 .err()
648 .map(|error| error.raw_os_error());
649
650 let _ = std::fs::remove_file(&path);
651 assert_eq!(
652 error,
653 Some(Some(libc::ENOTTY)),
654 "echo control must fail as a terminal call, not as a missing program"
655 );
656 }
657
658 #[cfg(all(unix, feature = "libc"))]
659 #[test]
660 fn a_failed_restore_is_reported_rather_than_swallowed() {
661 let path = std::env::temp_dir().join(format!(
666 "afdata_echo_guard_{}_{}",
667 std::process::id(),
668 std::time::SystemTime::now()
669 .duration_since(std::time::UNIX_EPOCH)
670 .map(|d| d.as_nanos())
671 .unwrap_or(0)
672 ));
673 let Ok(file) = std::fs::File::options()
674 .create(true)
675 .truncate(true)
676 .read(true)
677 .write(true)
678 .open(&path)
679 else {
680 return;
681 };
682 let original = unsafe { std::mem::zeroed::<libc::termios>() };
686 drop(EchoGuard {
687 tty: file,
688 original,
689 });
690
691 let announced = std::fs::read_to_string(&path).unwrap_or_default();
692 let _ = std::fs::remove_file(&path);
693 assert!(
694 announced.contains("could not restore terminal echo"),
695 "a terminal left without echo must say so: {announced:?}"
696 );
697 }
698
699 #[cfg(windows)]
702 mod windows_echo {
703 use super::super::{EchoGuard, disable_console_echo};
704
705 const ERROR_INVALID_HANDLE: i32 = 6;
710
711 fn scratch_file(label: &str) -> Option<(std::path::PathBuf, std::fs::File)> {
712 let path = std::env::temp_dir().join(format!(
713 "afdata_{label}_{}_{}",
714 std::process::id(),
715 std::time::SystemTime::now()
716 .duration_since(std::time::UNIX_EPOCH)
717 .map(|d| d.as_nanos())
718 .unwrap_or(0)
719 ));
720 let file = std::fs::File::options()
721 .create(true)
722 .truncate(true)
723 .read(true)
724 .write(true)
725 .open(&path)
726 .ok()?;
727 Some((path, file))
728 }
729
730 #[test]
731 fn echo_control_goes_to_the_console_not_to_a_program() {
732 let Some((path, file)) = scratch_file("not_a_console") else {
733 return;
734 };
735
736 let error = disable_console_echo(&file)
737 .err()
738 .and_then(|error| error.raw_os_error());
739
740 drop(file);
741 let _ = std::fs::remove_file(&path);
742 assert_eq!(
743 error,
744 Some(ERROR_INVALID_HANDLE),
745 "echo control must fail as a console call, not as a missing program"
746 );
747 }
748
749 #[test]
750 fn a_failed_restore_is_reported_rather_than_swallowed() {
751 let Some((path, file)) = scratch_file("console_echo_guard") else {
752 return;
753 };
754 let Ok(output) = file.try_clone() else {
755 let _ = std::fs::remove_file(&path);
756 return;
757 };
758
759 drop(EchoGuard {
762 console: file,
763 output,
764 original: 0,
765 });
766
767 let announced = std::fs::read_to_string(&path).unwrap_or_default();
768 let _ = std::fs::remove_file(&path);
769 assert!(
770 announced.contains("could not restore console echo"),
771 "a console left without echo must say so: {announced:?}"
772 );
773 }
774 }
775
776 use crate::cli_spec::SourceSet;
777 use std::path::PathBuf;
778
779 fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
780 let path = std::env::temp_dir().join(format!(
781 "afdata-value-source-{name}-{}.{extension}",
782 std::process::id()
783 ));
784 std::fs::write(&path, content).expect("write test config");
785 path
786 }
787
788 fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
794 #[allow(unused_mut)]
797 let mut cases: Vec<(&str, &str, &str, &str)> =
798 vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
799 #[cfg(feature = "toml")]
800 cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
801 #[cfg(feature = "yaml")]
802 cases.push(("yaml", "yaml", "a:\n b: ' v '\n", "a.b"));
803 #[cfg(feature = "dotenv")]
804 cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
805 cases
806 }
807
808 #[test]
809 fn a_file_source_reads_one_address_out_of_every_format() {
810 for (name, extension, content, dot_path) in readable_formats() {
811 let path = temp_config(name, extension, content);
812 let source = ValueSource::File {
813 path: path.clone(),
814 dot_path: dot_path.to_string(),
815 format: None,
816 };
817 let read = source.read();
818 let secret = source.read_secret();
819 std::fs::remove_file(&path).expect("remove test config");
820 assert_eq!(read.as_deref(), Ok(" v "), "{name}");
822 assert_eq!(
823 secret.expect("secret read").expose_secret(),
824 " v ",
825 "{name}"
826 );
827 }
828 }
829
830 #[test]
831 fn an_empty_string_is_still_a_value() {
832 let path = temp_config("empty", "json", r#"{"empty":""}"#);
833 let source = ValueSource::File {
834 path: path.clone(),
835 dot_path: "empty".to_string(),
836 format: None,
837 };
838 assert_eq!(source.read().as_deref(), Ok(""));
839 let secret = source.read_secret().expect("empty secret remains explicit");
840 assert!(secret.is_empty());
841 std::fs::remove_file(&path).expect("remove test config");
842 }
843
844 #[cfg(feature = "ini")]
851 #[test]
852 fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
853 let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
854 let named = ValueSource::File {
855 path: path.clone(),
856 dot_path: "http-password".to_string(),
857 format: Some("ini".to_string()),
858 };
859 let unnamed = ValueSource::File {
860 path: path.clone(),
861 dot_path: "http-password".to_string(),
862 format: None,
863 };
864 let bad_name = ValueSource::File {
865 path: path.clone(),
866 dot_path: "http-password".to_string(),
867 format: Some("nonsense".to_string()),
868 };
869 let read = named.read_secret();
870 let without = unnamed.read();
871 let bad = bad_name.read();
872 std::fs::remove_file(&path).expect("remove test config");
873
874 assert_eq!(read.expect("named format").expose_secret(), "abc123");
875 let without = without.expect_err("no extension to detect");
878 assert!(without.message().contains("file+FORMAT:"), "{without}");
879 let bad = bad.expect_err("unknown format");
880 assert!(
881 bad.message().contains("not a format this build reads"),
882 "{bad}"
883 );
884 }
885
886 #[test]
889 fn a_non_string_scalar_is_a_value_but_never_a_secret() {
890 let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
891 let port = ValueSource::File {
892 path: path.clone(),
893 dot_path: "port".to_string(),
894 format: None,
895 };
896 assert_eq!(port.read().as_deref(), Ok("5432"));
897 let error = port.read_secret().expect_err("a secret must be a string");
898 assert!(error.message().contains("must be a string"), "{error}");
899
900 let on = ValueSource::File {
901 path: path.clone(),
902 dot_path: "on".to_string(),
903 format: None,
904 };
905 assert_eq!(on.read().as_deref(), Ok("true"));
906 std::fs::remove_file(&path).expect("remove test config");
907 }
908
909 #[test]
912 fn a_secret_read_never_echoes_what_it_read() {
913 let canary = "AFDATA_SOURCE_CANARY";
914 let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
917 let source = ValueSource::File {
918 path: path.clone(),
919 dot_path: "a".to_string(),
920 format: None,
921 };
922 let plain = source.read().expect_err("malformed");
923 let secret = source.read_secret().expect_err("malformed");
924 std::fs::remove_file(&path).expect("remove test config");
925 assert!(
926 !secret.message().contains(canary),
927 "secret read leaked: {secret}"
928 );
929 assert!(plain.message().contains("cannot read"), "{plain}");
931 }
932
933 #[test]
934 fn a_collection_is_not_a_value() {
935 let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
936 for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
937 let source = ValueSource::File {
938 path: path.clone(),
939 dot_path: dot_path.to_string(),
940 format: None,
941 };
942 let error = source.read().expect_err(dot_path);
943 assert!(error.message().contains(expected), "{dot_path}: {error}");
944 }
945 std::fs::remove_file(&path).expect("remove test config");
946 }
947
948 #[test]
950 fn a_host_scheme_is_not_this_crates_to_read() {
951 let error = SourceSet::config()
952 .host_scheme("container", "container:NAME")
953 .parse("container:x")
954 .expect("parses")
955 .read()
956 .expect_err("this crate cannot read it");
957 assert_eq!(error.code(), "value_source_unreadable");
958 }
959
960 #[test]
961 fn an_unset_environment_source_names_what_it_tried() {
962 const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
963 let error = ValueSource::Env(ABSENT.to_string())
964 .read()
965 .expect_err("unset");
966 assert_eq!(error.code(), "value_source_unreadable");
967 assert!(error.message().contains(ABSENT), "{error}");
968 }
969
970 #[test]
973 fn a_secret_string_cannot_be_printed_by_accident() {
974 let secret = SecretString::new("s3cret");
975 assert_eq!(format!("{secret}"), "***");
976 assert_eq!(format!("{secret:?}"), "***");
977 assert!(!format!("{secret:?} {secret}").contains("s3cret"));
978 assert_eq!(secret.expose_secret(), "s3cret");
979 #[derive(Debug)]
981 struct Config {
982 #[allow(dead_code)]
983 token_secret: SecretString,
984 }
985 let printed = format!(
986 "{:?}",
987 Config {
988 token_secret: secret
989 }
990 );
991 assert!(!printed.contains("s3cret"), "{printed}");
992 }
993
994 #[test]
995 fn a_stream_is_read_verbatim_and_capped() {
996 assert_eq!(
997 read_stream(" v \n".as_bytes(), "test").as_deref(),
998 Ok(" v \n")
999 );
1000 let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
1001 let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
1002 assert!(error.message().contains("exceeds"), "{error}");
1003 }
1004
1005 #[test]
1006 fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
1007 let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
1008 assert_eq!(
1009 read_prompt_line(std::io::Cursor::new(exact))
1010 .expect("cap-sized line")
1011 .len(),
1012 MAX_STREAM_BYTES
1013 );
1014 let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
1015 let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
1016 assert!(error.message().contains("exceeds"), "{error}");
1017 }
1018
1019 #[cfg(unix)]
1020 #[test]
1021 fn an_fd_source_never_closes_the_callers_descriptor() {
1022 use std::io::{Read, Seek};
1023 use std::os::fd::AsRawFd;
1024
1025 let path = temp_config("fd", "txt", "descriptor value");
1026 let mut file = std::fs::File::open(&path).expect("open test descriptor");
1027 let source = ValueSource::Fd(file.as_raw_fd());
1028 assert_eq!(source.read().as_deref(), Ok("descriptor value"));
1029
1030 file.rewind()
1031 .expect("the caller still owns an open descriptor");
1032 let mut reread = String::new();
1033 file.read_to_string(&mut reread)
1034 .expect("read through caller-owned descriptor");
1035 assert_eq!(reread, "descriptor value");
1036 std::fs::remove_file(&path).expect("remove test config");
1037 }
1038}