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(all(unix, feature = "libc"))]
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 original = disable_terminal_echo(&tty)
291 .map_err(|error| SourceError::unreadable(format!("disable terminal echo: {error}")))?;
292 let _echo = EchoGuard {
293 tty: restore_tty,
294 original,
295 };
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(all(unix, not(feature = "libc")))]
309fn read_prompt() -> Result<String> {
310 Err(SourceError::unreadable(
311 "the `prompt` source needs Cargo feature `libc` to turn terminal echo off",
312 ))
313}
314
315#[cfg(windows)]
316fn read_prompt() -> Result<String> {
317 use std::io::Write as _;
318
319 let input = std::fs::OpenOptions::new()
325 .read(true)
326 .write(true)
327 .open("CONIN$")
328 .map_err(|error| SourceError::unreadable(format!("open the console input: {error}")))?;
329 let mut output = std::fs::OpenOptions::new()
330 .write(true)
331 .open("CONOUT$")
332 .map_err(|error| SourceError::unreadable(format!("open the console output: {error}")))?;
333 let restore_console = input.try_clone().map_err(|error| {
334 SourceError::unreadable(format!("prepare console echo restoration: {error}"))
335 })?;
336 let restore_output = output.try_clone().map_err(|error| {
337 SourceError::unreadable(format!("prepare console echo restoration: {error}"))
338 })?;
339 let original = disable_console_echo(&input)
340 .map_err(|error| SourceError::unreadable(format!("disable console echo: {error}")))?;
341 let _echo = EchoGuard {
342 console: restore_console,
343 output: restore_output,
344 original,
345 };
346 write!(output, "Value: ")
347 .map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
348 let value = read_console_line(&input);
349 let _ = writeln!(output);
353 value
354}
355
356#[cfg(all(not(unix), not(windows)))]
357fn read_prompt() -> Result<String> {
358 Err(SourceError::unreadable(
359 "the `prompt` source is unsupported on this platform",
360 ))
361}
362
363#[cfg(windows)]
370mod windows_console {
371 use std::ffi::c_void;
372
373 pub(super) const ENABLE_ECHO_INPUT: u32 = 0x0004;
376
377 #[link(name = "kernel32")]
378 unsafe extern "system" {
379 pub(super) fn GetConsoleMode(console: *mut c_void, mode: *mut u32) -> i32;
380 pub(super) fn SetConsoleMode(console: *mut c_void, mode: u32) -> i32;
381 pub(super) fn ReadConsoleW(
382 console: *mut c_void,
383 buffer: *mut u16,
384 units_to_read: u32,
385 units_read: *mut u32,
386 input_control: *mut c_void,
387 ) -> i32;
388 }
389}
390
391#[cfg(windows)]
399fn disable_console_echo(console: &std::fs::File) -> std::io::Result<u32> {
400 let original = console_mode(console)?;
401 set_console_mode(console, original & !windows_console::ENABLE_ECHO_INPUT)?;
402 if console_mode(console)? & windows_console::ENABLE_ECHO_INPUT != 0 {
403 let _ = set_console_mode(console, original);
406 return Err(std::io::Error::other("console echo is still enabled"));
407 }
408 Ok(original)
409}
410
411#[cfg(windows)]
412fn console_mode(console: &std::fs::File) -> std::io::Result<u32> {
413 use std::os::windows::io::AsRawHandle as _;
414
415 let mut mode = 0u32;
416 let status = unsafe { windows_console::GetConsoleMode(console.as_raw_handle(), &mut mode) };
419 if status == 0 {
420 return Err(std::io::Error::last_os_error());
421 }
422 Ok(mode)
423}
424
425#[cfg(windows)]
426fn set_console_mode(console: &std::fs::File, mode: u32) -> std::io::Result<()> {
427 use std::os::windows::io::AsRawHandle as _;
428
429 let status = unsafe { windows_console::SetConsoleMode(console.as_raw_handle(), mode) };
432 if status == 0 {
433 return Err(std::io::Error::last_os_error());
434 }
435 Ok(())
436}
437
438#[cfg(windows)]
448fn read_console_line(console: &std::fs::File) -> Result<String> {
449 use std::os::windows::io::AsRawHandle as _;
450
451 let mut buffer = vec![0u16; MAX_STREAM_BYTES + 4];
456 let units_to_read = u32::try_from(buffer.len())
457 .map_err(|_| SourceError::unreadable("the console read buffer does not fit a request"))?;
458 let mut units_read = 0u32;
459 let status = unsafe {
463 windows_console::ReadConsoleW(
464 console.as_raw_handle(),
465 buffer.as_mut_ptr(),
466 units_to_read,
467 &mut units_read,
468 std::ptr::null_mut(),
469 )
470 };
471 if status == 0 {
472 return Err(SourceError::unreadable(format!(
473 "read from the console: {}",
474 std::io::Error::last_os_error()
475 )));
476 }
477 let units = buffer
478 .get(..units_read as usize)
479 .ok_or_else(|| SourceError::unreadable("the console reported reading past its buffer"))?;
480 let text = String::from_utf16(units)
481 .map_err(|_| SourceError::unreadable("the console answered malformed UTF-16"))?;
482 read_prompt_line(std::io::Cursor::new(text.as_bytes()))
484}
485
486#[cfg(all(unix, feature = "libc"))]
495fn disable_terminal_echo(tty: &std::fs::File) -> std::io::Result<libc::termios> {
496 let original = terminal_attributes(tty)?;
497 let mut quiet = original;
498 quiet.c_lflag &= !libc::ECHO;
499 set_terminal_attributes(tty, &quiet)?;
500 if terminal_attributes(tty)?.c_lflag & libc::ECHO != 0 {
501 let _ = set_terminal_attributes(tty, &original);
504 return Err(std::io::Error::other("terminal echo is still enabled"));
505 }
506 Ok(original)
507}
508
509#[cfg(all(unix, feature = "libc"))]
510fn terminal_attributes(tty: &std::fs::File) -> std::io::Result<libc::termios> {
511 use std::os::fd::AsRawFd as _;
512
513 let mut attributes = std::mem::MaybeUninit::<libc::termios>::uninit();
514 let status = unsafe { libc::tcgetattr(tty.as_raw_fd(), attributes.as_mut_ptr()) };
517 if status != 0 {
518 return Err(std::io::Error::last_os_error());
519 }
520 Ok(unsafe { attributes.assume_init() })
522}
523
524#[cfg(all(unix, feature = "libc"))]
525fn set_terminal_attributes(tty: &std::fs::File, attributes: &libc::termios) -> std::io::Result<()> {
526 use std::os::fd::AsRawFd as _;
527
528 let status = unsafe { libc::tcsetattr(tty.as_raw_fd(), libc::TCSAFLUSH, attributes) };
533 if status != 0 {
534 return Err(std::io::Error::last_os_error());
535 }
536 Ok(())
537}
538
539#[cfg(any(all(unix, feature = "libc"), windows, test))]
540fn read_prompt_line<R: std::io::BufRead>(reader: R) -> Result<String> {
541 use std::io::BufRead;
542
543 let mut limited = reader.take((MAX_STREAM_BYTES + 2) as u64);
546 let mut value = String::new();
547 limited
548 .read_line(&mut value)
549 .map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
550 let value = value.trim_end_matches(['\r', '\n']);
551 if value.len() > MAX_STREAM_BYTES {
552 return Err(SourceError::unreadable(format!(
553 "prompt exceeds {MAX_STREAM_BYTES} bytes"
554 )));
555 }
556 Ok(value.to_string())
557}
558
559#[cfg(all(unix, feature = "libc"))]
561struct EchoGuard {
562 tty: std::fs::File,
563 original: libc::termios,
564}
565
566#[cfg(all(unix, feature = "libc"))]
567impl Drop for EchoGuard {
568 fn drop(&mut self) {
569 use std::io::Write as _;
570
571 if set_terminal_attributes(&self.tty, &self.original).is_ok() {
572 return;
573 }
574 let _ = writeln!(
579 &mut self.tty,
580 "\nwarning: could not restore terminal echo; run `stty echo` to fix this terminal"
581 );
582 }
583}
584
585#[cfg(windows)]
591struct EchoGuard {
592 console: std::fs::File,
593 output: std::fs::File,
594 original: u32,
595}
596
597#[cfg(windows)]
598impl Drop for EchoGuard {
599 fn drop(&mut self) {
600 use std::io::Write as _;
601
602 if set_console_mode(&self.console, self.original).is_ok() {
603 return;
604 }
605 let _ = writeln!(
610 &mut self.output,
611 "\nwarning: could not restore console echo; close this console window to get it back"
612 );
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[cfg(all(unix, feature = "libc"))]
621 #[test]
622 fn terminal_echo_control_goes_to_the_terminal_not_to_a_program() {
623 use std::io::Write as _;
624
625 let path = std::env::temp_dir().join(format!(
630 "afdata_not_a_tty_{}_{}",
631 std::process::id(),
632 std::time::SystemTime::now()
633 .duration_since(std::time::UNIX_EPOCH)
634 .map(|d| d.as_nanos())
635 .unwrap_or(0)
636 ));
637 let mut file = match std::fs::File::create(&path) {
638 Ok(file) => file,
639 Err(_) => return,
640 };
641 let _ = file.write_all(b"not a terminal");
642
643 let error = disable_terminal_echo(&file)
644 .err()
645 .map(|error| error.raw_os_error());
646
647 let _ = std::fs::remove_file(&path);
648 assert_eq!(
649 error,
650 Some(Some(libc::ENOTTY)),
651 "echo control must fail as a terminal call, not as a missing program"
652 );
653 }
654
655 #[cfg(all(unix, feature = "libc"))]
656 #[test]
657 fn a_failed_restore_is_reported_rather_than_swallowed() {
658 let path = std::env::temp_dir().join(format!(
663 "afdata_echo_guard_{}_{}",
664 std::process::id(),
665 std::time::SystemTime::now()
666 .duration_since(std::time::UNIX_EPOCH)
667 .map(|d| d.as_nanos())
668 .unwrap_or(0)
669 ));
670 let Ok(file) = std::fs::File::options()
671 .create(true)
672 .truncate(true)
673 .read(true)
674 .write(true)
675 .open(&path)
676 else {
677 return;
678 };
679 let original = unsafe { std::mem::zeroed::<libc::termios>() };
683 drop(EchoGuard {
684 tty: file,
685 original,
686 });
687
688 let announced = std::fs::read_to_string(&path).unwrap_or_default();
689 let _ = std::fs::remove_file(&path);
690 assert!(
691 announced.contains("could not restore terminal echo"),
692 "a terminal left without echo must say so: {announced:?}"
693 );
694 }
695
696 #[cfg(windows)]
699 mod windows_echo {
700 use super::super::{EchoGuard, disable_console_echo};
701
702 const ERROR_INVALID_HANDLE: i32 = 6;
707
708 fn scratch_file(label: &str) -> Option<(std::path::PathBuf, std::fs::File)> {
709 let path = std::env::temp_dir().join(format!(
710 "afdata_{label}_{}_{}",
711 std::process::id(),
712 std::time::SystemTime::now()
713 .duration_since(std::time::UNIX_EPOCH)
714 .map(|d| d.as_nanos())
715 .unwrap_or(0)
716 ));
717 let file = std::fs::File::options()
718 .create(true)
719 .truncate(true)
720 .read(true)
721 .write(true)
722 .open(&path)
723 .ok()?;
724 Some((path, file))
725 }
726
727 #[test]
728 fn echo_control_goes_to_the_console_not_to_a_program() {
729 let Some((path, file)) = scratch_file("not_a_console") else {
730 return;
731 };
732
733 let error = disable_console_echo(&file)
734 .err()
735 .and_then(|error| error.raw_os_error());
736
737 drop(file);
738 let _ = std::fs::remove_file(&path);
739 assert_eq!(
740 error,
741 Some(ERROR_INVALID_HANDLE),
742 "echo control must fail as a console call, not as a missing program"
743 );
744 }
745
746 #[test]
747 fn a_failed_restore_is_reported_rather_than_swallowed() {
748 let Some((path, file)) = scratch_file("console_echo_guard") else {
749 return;
750 };
751 let Ok(output) = file.try_clone() else {
752 let _ = std::fs::remove_file(&path);
753 return;
754 };
755
756 drop(EchoGuard {
759 console: file,
760 output,
761 original: 0,
762 });
763
764 let announced = std::fs::read_to_string(&path).unwrap_or_default();
765 let _ = std::fs::remove_file(&path);
766 assert!(
767 announced.contains("could not restore console echo"),
768 "a console left without echo must say so: {announced:?}"
769 );
770 }
771 }
772
773 use crate::cli_spec::SourceSet;
774 use std::path::PathBuf;
775
776 fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
777 let path = std::env::temp_dir().join(format!(
778 "afdata-value-source-{name}-{}.{extension}",
779 std::process::id()
780 ));
781 std::fs::write(&path, content).expect("write test config");
782 path
783 }
784
785 fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
791 #[allow(unused_mut)]
794 let mut cases: Vec<(&str, &str, &str, &str)> =
795 vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
796 #[cfg(feature = "toml")]
797 cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
798 #[cfg(feature = "yaml")]
799 cases.push(("yaml", "yaml", "a:\n b: ' v '\n", "a.b"));
800 #[cfg(feature = "dotenv")]
801 cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
802 cases
803 }
804
805 #[test]
806 fn a_file_source_reads_one_address_out_of_every_format() {
807 for (name, extension, content, dot_path) in readable_formats() {
808 let path = temp_config(name, extension, content);
809 let source = ValueSource::File {
810 path: path.clone(),
811 dot_path: dot_path.to_string(),
812 format: None,
813 };
814 let read = source.read();
815 let secret = source.read_secret();
816 std::fs::remove_file(&path).expect("remove test config");
817 assert_eq!(read.as_deref(), Ok(" v "), "{name}");
819 assert_eq!(
820 secret.expect("secret read").expose_secret(),
821 " v ",
822 "{name}"
823 );
824 }
825 }
826
827 #[test]
828 fn an_empty_string_is_still_a_value() {
829 let path = temp_config("empty", "json", r#"{"empty":""}"#);
830 let source = ValueSource::File {
831 path: path.clone(),
832 dot_path: "empty".to_string(),
833 format: None,
834 };
835 assert_eq!(source.read().as_deref(), Ok(""));
836 let secret = source.read_secret().expect("empty secret remains explicit");
837 assert!(secret.is_empty());
838 std::fs::remove_file(&path).expect("remove test config");
839 }
840
841 #[cfg(feature = "ini")]
848 #[test]
849 fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
850 let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
851 let named = ValueSource::File {
852 path: path.clone(),
853 dot_path: "http-password".to_string(),
854 format: Some("ini".to_string()),
855 };
856 let unnamed = ValueSource::File {
857 path: path.clone(),
858 dot_path: "http-password".to_string(),
859 format: None,
860 };
861 let bad_name = ValueSource::File {
862 path: path.clone(),
863 dot_path: "http-password".to_string(),
864 format: Some("nonsense".to_string()),
865 };
866 let read = named.read_secret();
867 let without = unnamed.read();
868 let bad = bad_name.read();
869 std::fs::remove_file(&path).expect("remove test config");
870
871 assert_eq!(read.expect("named format").expose_secret(), "abc123");
872 let without = without.expect_err("no extension to detect");
875 assert!(without.message().contains("file+FORMAT:"), "{without}");
876 let bad = bad.expect_err("unknown format");
877 assert!(
878 bad.message().contains("not a format this build reads"),
879 "{bad}"
880 );
881 }
882
883 #[test]
886 fn a_non_string_scalar_is_a_value_but_never_a_secret() {
887 let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
888 let port = ValueSource::File {
889 path: path.clone(),
890 dot_path: "port".to_string(),
891 format: None,
892 };
893 assert_eq!(port.read().as_deref(), Ok("5432"));
894 let error = port.read_secret().expect_err("a secret must be a string");
895 assert!(error.message().contains("must be a string"), "{error}");
896
897 let on = ValueSource::File {
898 path: path.clone(),
899 dot_path: "on".to_string(),
900 format: None,
901 };
902 assert_eq!(on.read().as_deref(), Ok("true"));
903 std::fs::remove_file(&path).expect("remove test config");
904 }
905
906 #[test]
909 fn a_secret_read_never_echoes_what_it_read() {
910 let canary = "AFDATA_SOURCE_CANARY";
911 let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
914 let source = ValueSource::File {
915 path: path.clone(),
916 dot_path: "a".to_string(),
917 format: None,
918 };
919 let plain = source.read().expect_err("malformed");
920 let secret = source.read_secret().expect_err("malformed");
921 std::fs::remove_file(&path).expect("remove test config");
922 assert!(
923 !secret.message().contains(canary),
924 "secret read leaked: {secret}"
925 );
926 assert!(plain.message().contains("cannot read"), "{plain}");
928 }
929
930 #[test]
931 fn a_collection_is_not_a_value() {
932 let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
933 for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
934 let source = ValueSource::File {
935 path: path.clone(),
936 dot_path: dot_path.to_string(),
937 format: None,
938 };
939 let error = source.read().expect_err(dot_path);
940 assert!(error.message().contains(expected), "{dot_path}: {error}");
941 }
942 std::fs::remove_file(&path).expect("remove test config");
943 }
944
945 #[test]
947 fn a_host_scheme_is_not_this_crates_to_read() {
948 let error = SourceSet::config()
949 .host_scheme("container", "container:NAME")
950 .parse("container:x")
951 .expect("parses")
952 .read()
953 .expect_err("this crate cannot read it");
954 assert_eq!(error.code(), "value_source_unreadable");
955 }
956
957 #[test]
958 fn an_unset_environment_source_names_what_it_tried() {
959 const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
960 let error = ValueSource::Env(ABSENT.to_string())
961 .read()
962 .expect_err("unset");
963 assert_eq!(error.code(), "value_source_unreadable");
964 assert!(error.message().contains(ABSENT), "{error}");
965 }
966
967 #[test]
970 fn a_secret_string_cannot_be_printed_by_accident() {
971 let secret = SecretString::new("s3cret");
972 assert_eq!(format!("{secret}"), "***");
973 assert_eq!(format!("{secret:?}"), "***");
974 assert!(!format!("{secret:?} {secret}").contains("s3cret"));
975 assert_eq!(secret.expose_secret(), "s3cret");
976 #[derive(Debug)]
978 struct Config {
979 #[allow(dead_code)]
980 token_secret: SecretString,
981 }
982 let printed = format!(
983 "{:?}",
984 Config {
985 token_secret: secret
986 }
987 );
988 assert!(!printed.contains("s3cret"), "{printed}");
989 }
990
991 #[test]
992 fn a_stream_is_read_verbatim_and_capped() {
993 assert_eq!(
994 read_stream(" v \n".as_bytes(), "test").as_deref(),
995 Ok(" v \n")
996 );
997 let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
998 let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
999 assert!(error.message().contains("exceeds"), "{error}");
1000 }
1001
1002 #[test]
1003 fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
1004 let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
1005 assert_eq!(
1006 read_prompt_line(std::io::Cursor::new(exact))
1007 .expect("cap-sized line")
1008 .len(),
1009 MAX_STREAM_BYTES
1010 );
1011 let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
1012 let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
1013 assert!(error.message().contains("exceeds"), "{error}");
1014 }
1015
1016 #[cfg(unix)]
1017 #[test]
1018 fn an_fd_source_never_closes_the_callers_descriptor() {
1019 use std::io::{Read, Seek};
1020 use std::os::fd::AsRawFd;
1021
1022 let path = temp_config("fd", "txt", "descriptor value");
1023 let mut file = std::fs::File::open(&path).expect("open test descriptor");
1024 let source = ValueSource::Fd(file.as_raw_fd());
1025 assert_eq!(source.read().as_deref(), Ok("descriptor value"));
1026
1027 file.rewind()
1028 .expect("the caller still owns an open descriptor");
1029 let mut reread = String::new();
1030 file.read_to_string(&mut reread)
1031 .expect("read through caller-owned descriptor");
1032 assert_eq!(reread, "descriptor value");
1033 std::fs::remove_file(&path).expect("remove test config");
1034 }
1035}