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 if policy == Policy::Secret => {
127 read_stream(std::io::stdin().lock(), "stdin")
128 }
129 ValueSource::Stdin => {
130 read_stream_with_limit(std::io::stdin().lock(), "stdin", MAX_FILE_BYTES as usize)
131 }
132 ValueSource::Fd(number) => read_fd(
133 *number,
134 if policy == Policy::Secret {
135 MAX_STREAM_BYTES
136 } else {
137 MAX_FILE_BYTES as usize
138 },
139 ),
140 ValueSource::Prompt => read_prompt(),
141 ValueSource::Host { scheme, .. } => Err(SourceError::unreadable(format!(
142 "`{scheme}` is a host-defined source; this crate cannot read it"
143 ))),
144 }
145}
146
147#[derive(Clone, Copy, PartialEq, Eq)]
148enum Policy {
149 Plain,
150 Secret,
151}
152
153fn read_file(
154 path: &Path,
155 dot_path: &str,
156 named_format: Option<&str>,
157 policy: Policy,
158) -> Result<String> {
159 if let Some(name) = named_format
162 && dot_path.is_empty()
163 {
164 return Err(SourceError::invalid(format!(
165 "`file+{name}:` needs a `#DOT_PATH`; use `file:PATH` to read the whole file"
166 )));
167 }
168 if let Some(name) = named_format
169 && Format::from_cli_name(name).is_none()
170 {
171 return Err(SourceError::invalid(format!(
172 "`file+{name}:` is not a format this build reads"
173 )));
174 }
175 if dot_path.is_empty() {
178 return crate::document::file::read_capped_file(
179 path,
180 MAX_FILE_BYTES,
181 crate::document::SymlinkPolicy::Follow,
182 )
183 .map_err(|error| SourceError::unreadable(error.redacted_message()));
184 }
185 let format = match named_format {
189 Some(name) => Format::from_cli_name(name).ok_or_else(|| {
190 SourceError::invalid(format!("`file+{name}:` is not a format this build reads"))
191 })?,
192 None => Format::detect(path).ok_or_else(|| match Format::unavailable(path) {
193 Some(feature) => SourceError::unreadable(format!(
194 "cannot read {}: this build has no {feature} support",
195 path.display()
196 )),
197 None => SourceError::invalid(format!(
198 "cannot tell the config format of {} from its name; name it with \
199 file+FORMAT:{}#{dot_path}, or use a .json/.toml/.yaml/.env/.ini file",
200 path.display(),
201 path.display()
202 )),
203 })?,
204 };
205 let document = DocumentFile::open_capped(path, Some(format), MAX_FILE_BYTES).map_err(
208 |error| match policy {
209 Policy::Secret => SourceError::unreadable(format!(
212 "cannot read {} config {}: {}",
213 format.name(),
214 path.display(),
215 error.redacted_message()
216 )),
217 Policy::Plain => SourceError::unreadable(format!(
218 "cannot read {} config {}: {error}",
219 format.name(),
220 path.display()
221 )),
222 },
223 )?;
224 let value = document.value_at(dot_path).map_err(|error| {
225 if error.code() == "document_path_not_found" {
226 SourceError::unreadable(format!("{dot_path} was not found in {}", path.display()))
227 } else {
228 SourceError::unreadable(format!("cannot resolve {dot_path} in {}", path.display()))
229 }
230 })?;
231 scalar(value, path, dot_path, policy)
232}
233
234fn scalar(value: Value, path: &Path, dot_path: &str, policy: Policy) -> Result<String> {
235 let refused = |kind: &str| {
236 SourceError::unreadable(format!(
237 "{dot_path} in {} is {kind}, which is not a value",
238 path.display()
239 ))
240 };
241 match value {
242 Value::String(value) => Ok(value),
243 other if policy == Policy::Secret => Err(SourceError::unreadable(format!(
246 "{dot_path} in {} is {}; a secret must be a string",
247 path.display(),
248 other.kind_name()
249 ))),
250 Value::Integer(value) => Ok(value.to_string()),
251 Value::Unsigned(value) => Ok(value.to_string()),
252 Value::Float(value) => Ok(value.to_string()),
253 Value::Number(value) => Ok(value),
254 Value::Bool(value) => Ok(value.to_string()),
255 Value::Null => Err(refused("null")),
256 Value::Array(_) => Err(refused("an array")),
257 Value::Object(_) => Err(refused("an object")),
258 }
259}
260
261fn read_stream<R: std::io::Read>(reader: R, source: &str) -> Result<String> {
265 read_stream_with_limit(reader, source, MAX_STREAM_BYTES)
266}
267
268fn read_stream_with_limit<R: std::io::Read>(
269 reader: R,
270 source: &str,
271 max_bytes: usize,
272) -> Result<String> {
273 use std::io::Read;
274 let mut bytes = Vec::new();
275 reader
276 .take((max_bytes + 1) as u64)
277 .read_to_end(&mut bytes)
278 .map_err(|error| SourceError::unreadable(format!("read from {source}: {error}")))?;
279 if bytes.len() > max_bytes {
280 return Err(SourceError::unreadable(format!(
281 "{source} exceeds {max_bytes} bytes"
282 )));
283 }
284 String::from_utf8(bytes)
285 .map_err(|_| SourceError::unreadable(format!("{source} must carry valid UTF-8")))
286}
287
288#[cfg(unix)]
289fn read_fd(number: i32, max_bytes: usize) -> Result<String> {
290 #[cfg(feature = "libc")]
291 let file = {
292 use std::os::fd::FromRawFd;
293
294 let duplicated = unsafe { libc::dup(number) };
301 if duplicated < 0 {
302 return Err(SourceError::unreadable(format!(
303 "open file descriptor {number}: {}",
304 std::io::Error::last_os_error()
305 )));
306 }
307 unsafe { std::fs::File::from_raw_fd(duplicated) }
310 };
311 #[cfg(not(feature = "libc"))]
312 let file = std::fs::File::open(format!("/dev/fd/{number}")).map_err(|error| {
313 SourceError::unreadable(format!("open file descriptor {number}: {error}"))
314 })?;
315 read_stream_with_limit(file, "file descriptor", max_bytes)
316}
317
318#[cfg(not(unix))]
319fn read_fd(_number: i32, _max_bytes: usize) -> Result<String> {
320 Err(SourceError::unreadable(
321 "the `fd` source is unsupported on this platform",
322 ))
323}
324
325#[cfg(all(unix, feature = "libc"))]
326fn read_prompt() -> Result<String> {
327 use std::io::Write;
328
329 let mut tty = std::fs::OpenOptions::new()
330 .read(true)
331 .write(true)
332 .open("/dev/tty")
333 .map_err(|error| {
334 SourceError::unreadable(format!("open the controlling terminal: {error}"))
335 })?;
336 let restore_tty = tty.try_clone().map_err(|error| {
337 SourceError::unreadable(format!("prepare terminal echo restoration: {error}"))
338 })?;
339 let original = disable_terminal_echo(&tty)
340 .map_err(|error| SourceError::unreadable(format!("disable terminal echo: {error}")))?;
341 let _echo = EchoGuard {
342 tty: restore_tty,
343 original,
344 };
345 write!(tty, "Value: ")
346 .map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
347 let reader = std::io::BufReader::new(&mut tty);
348 let value = read_prompt_line(reader);
349 let _ = writeln!(tty);
351 value
352}
353
354#[cfg(all(unix, not(feature = "libc")))]
358fn read_prompt() -> Result<String> {
359 Err(SourceError::unreadable(
360 "the `prompt` source needs Cargo feature `libc` to turn terminal echo off",
361 ))
362}
363
364#[cfg(windows)]
365fn read_prompt() -> Result<String> {
366 use std::io::Write as _;
367
368 let input = std::fs::OpenOptions::new()
374 .read(true)
375 .write(true)
376 .open("CONIN$")
377 .map_err(|error| SourceError::unreadable(format!("open the console input: {error}")))?;
378 let mut output = std::fs::OpenOptions::new()
379 .write(true)
380 .open("CONOUT$")
381 .map_err(|error| SourceError::unreadable(format!("open the console output: {error}")))?;
382 let restore_console = input.try_clone().map_err(|error| {
383 SourceError::unreadable(format!("prepare console echo restoration: {error}"))
384 })?;
385 let restore_output = output.try_clone().map_err(|error| {
386 SourceError::unreadable(format!("prepare console echo restoration: {error}"))
387 })?;
388 let original = disable_console_echo(&input)
389 .map_err(|error| SourceError::unreadable(format!("disable console echo: {error}")))?;
390 let _echo = EchoGuard {
391 console: restore_console,
392 output: restore_output,
393 original,
394 };
395 write!(output, "Value: ")
396 .map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
397 let value = read_console_line(&input);
398 let _ = writeln!(output);
402 value
403}
404
405#[cfg(all(not(unix), not(windows)))]
406fn read_prompt() -> Result<String> {
407 Err(SourceError::unreadable(
408 "the `prompt` source is unsupported on this platform",
409 ))
410}
411
412#[cfg(windows)]
419mod windows_console {
420 use std::ffi::c_void;
421
422 pub(super) const ENABLE_ECHO_INPUT: u32 = 0x0004;
425
426 #[link(name = "kernel32")]
427 unsafe extern "system" {
428 pub(super) fn GetConsoleMode(console: *mut c_void, mode: *mut u32) -> i32;
429 pub(super) fn SetConsoleMode(console: *mut c_void, mode: u32) -> i32;
430 pub(super) fn ReadConsoleW(
431 console: *mut c_void,
432 buffer: *mut u16,
433 units_to_read: u32,
434 units_read: *mut u32,
435 input_control: *mut c_void,
436 ) -> i32;
437 }
438}
439
440#[cfg(windows)]
448fn disable_console_echo(console: &std::fs::File) -> std::io::Result<u32> {
449 let original = console_mode(console)?;
450 set_console_mode(console, original & !windows_console::ENABLE_ECHO_INPUT)?;
451 if console_mode(console)? & windows_console::ENABLE_ECHO_INPUT != 0 {
452 let _ = set_console_mode(console, original);
455 return Err(std::io::Error::other("console echo is still enabled"));
456 }
457 Ok(original)
458}
459
460#[cfg(windows)]
461fn console_mode(console: &std::fs::File) -> std::io::Result<u32> {
462 use std::os::windows::io::AsRawHandle as _;
463
464 let mut mode = 0u32;
465 let status = unsafe { windows_console::GetConsoleMode(console.as_raw_handle(), &mut mode) };
468 if status == 0 {
469 return Err(std::io::Error::last_os_error());
470 }
471 Ok(mode)
472}
473
474#[cfg(windows)]
475fn set_console_mode(console: &std::fs::File, mode: u32) -> std::io::Result<()> {
476 use std::os::windows::io::AsRawHandle as _;
477
478 let status = unsafe { windows_console::SetConsoleMode(console.as_raw_handle(), mode) };
481 if status == 0 {
482 return Err(std::io::Error::last_os_error());
483 }
484 Ok(())
485}
486
487#[cfg(windows)]
497fn read_console_line(console: &std::fs::File) -> Result<String> {
498 use std::os::windows::io::AsRawHandle as _;
499
500 let mut buffer = vec![0u16; MAX_STREAM_BYTES + 4];
505 let units_to_read = u32::try_from(buffer.len())
506 .map_err(|_| SourceError::unreadable("the console read buffer does not fit a request"))?;
507 let mut units_read = 0u32;
508 let status = unsafe {
512 windows_console::ReadConsoleW(
513 console.as_raw_handle(),
514 buffer.as_mut_ptr(),
515 units_to_read,
516 &mut units_read,
517 std::ptr::null_mut(),
518 )
519 };
520 if status == 0 {
521 return Err(SourceError::unreadable(format!(
522 "read from the console: {}",
523 std::io::Error::last_os_error()
524 )));
525 }
526 let units = buffer
527 .get(..units_read as usize)
528 .ok_or_else(|| SourceError::unreadable("the console reported reading past its buffer"))?;
529 let text = String::from_utf16(units)
530 .map_err(|_| SourceError::unreadable("the console answered malformed UTF-16"))?;
531 read_prompt_line(std::io::Cursor::new(text.as_bytes()))
533}
534
535#[cfg(all(unix, feature = "libc"))]
544fn disable_terminal_echo(tty: &std::fs::File) -> std::io::Result<libc::termios> {
545 let original = terminal_attributes(tty)?;
546 let mut quiet = original;
547 quiet.c_lflag &= !libc::ECHO;
548 set_terminal_attributes(tty, &quiet)?;
549 if terminal_attributes(tty)?.c_lflag & libc::ECHO != 0 {
550 let _ = set_terminal_attributes(tty, &original);
553 return Err(std::io::Error::other("terminal echo is still enabled"));
554 }
555 Ok(original)
556}
557
558#[cfg(all(unix, feature = "libc"))]
559fn terminal_attributes(tty: &std::fs::File) -> std::io::Result<libc::termios> {
560 use std::os::fd::AsRawFd as _;
561
562 let mut attributes = std::mem::MaybeUninit::<libc::termios>::uninit();
563 let status = unsafe { libc::tcgetattr(tty.as_raw_fd(), attributes.as_mut_ptr()) };
566 if status != 0 {
567 return Err(std::io::Error::last_os_error());
568 }
569 Ok(unsafe { attributes.assume_init() })
571}
572
573#[cfg(all(unix, feature = "libc"))]
574fn set_terminal_attributes(tty: &std::fs::File, attributes: &libc::termios) -> std::io::Result<()> {
575 use std::os::fd::AsRawFd as _;
576
577 let status = unsafe { libc::tcsetattr(tty.as_raw_fd(), libc::TCSAFLUSH, attributes) };
582 if status != 0 {
583 return Err(std::io::Error::last_os_error());
584 }
585 Ok(())
586}
587
588#[cfg(any(all(unix, feature = "libc"), windows, test))]
589fn read_prompt_line<R: std::io::BufRead>(reader: R) -> Result<String> {
590 use std::io::BufRead;
591
592 let mut limited = reader.take((MAX_STREAM_BYTES + 2) as u64);
595 let mut value = String::new();
596 let read = limited
597 .read_line(&mut value)
598 .map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
599 if read == 0 {
600 return Err(SourceError::unreadable(
601 "the terminal closed before a value was entered",
602 ));
603 }
604 let value = match value.strip_suffix('\n') {
605 Some(line) => line.strip_suffix('\r').unwrap_or(line),
606 None => &value,
607 };
608 if value.len() > MAX_STREAM_BYTES {
609 return Err(SourceError::unreadable(format!(
610 "prompt exceeds {MAX_STREAM_BYTES} bytes"
611 )));
612 }
613 Ok(value.to_string())
614}
615
616#[cfg(all(unix, feature = "libc"))]
618struct EchoGuard {
619 tty: std::fs::File,
620 original: libc::termios,
621}
622
623#[cfg(all(unix, feature = "libc"))]
624impl Drop for EchoGuard {
625 fn drop(&mut self) {
626 use std::io::Write as _;
627
628 if set_terminal_attributes(&self.tty, &self.original).is_ok() {
629 return;
630 }
631 let _ = writeln!(
636 &mut self.tty,
637 "\nwarning: could not restore terminal echo; run `stty echo` to fix this terminal"
638 );
639 }
640}
641
642#[cfg(windows)]
648struct EchoGuard {
649 console: std::fs::File,
650 output: std::fs::File,
651 original: u32,
652}
653
654#[cfg(windows)]
655impl Drop for EchoGuard {
656 fn drop(&mut self) {
657 use std::io::Write as _;
658
659 if set_console_mode(&self.console, self.original).is_ok() {
660 return;
661 }
662 let _ = writeln!(
667 &mut self.output,
668 "\nwarning: could not restore console echo; close this console window to get it back"
669 );
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 #[test]
678 fn whole_file_sources_preserve_text_and_accept_large_documents() {
679 let directory = tempfile::tempdir().expect("temporary source directory");
680 let path = directory.path().join("request.json");
681 let text = format!("{{\"text\":\"{}\"}}\n", "x".repeat(1300 * 1024));
682 std::fs::write(&path, &text).expect("write source");
683 let source = ValueSource::File {
684 path,
685 dot_path: String::new(),
686 format: None,
687 };
688 assert_eq!(source.read().expect("read complete file"), text);
689 assert_eq!(
690 read_stream_with_limit(text.as_bytes(), "stdin", MAX_FILE_BYTES as usize)
691 .expect("read stream"),
692 text
693 );
694 let directory_source = ValueSource::File {
695 path: directory.path().to_owned(),
696 dot_path: String::new(),
697 format: None,
698 };
699 assert!(directory_source.read().is_err());
700 }
701
702 #[cfg(all(unix, feature = "libc"))]
703 #[test]
704 fn terminal_echo_control_goes_to_the_terminal_not_to_a_program() {
705 use std::io::Write as _;
706
707 let path = std::env::temp_dir().join(format!(
712 "afdata_not_a_tty_{}_{}",
713 std::process::id(),
714 std::time::SystemTime::now()
715 .duration_since(std::time::UNIX_EPOCH)
716 .map(|d| d.as_nanos())
717 .unwrap_or(0)
718 ));
719 let mut file = match std::fs::File::create(&path) {
720 Ok(file) => file,
721 Err(_) => return,
722 };
723 let _ = file.write_all(b"not a terminal");
724
725 let error = disable_terminal_echo(&file)
726 .err()
727 .map(|error| error.raw_os_error());
728
729 let _ = std::fs::remove_file(&path);
730 assert_eq!(
731 error,
732 Some(Some(libc::ENOTTY)),
733 "echo control must fail as a terminal call, not as a missing program"
734 );
735 }
736
737 #[cfg(all(unix, feature = "libc"))]
738 #[test]
739 fn a_failed_restore_is_reported_rather_than_swallowed() {
740 let path = std::env::temp_dir().join(format!(
745 "afdata_echo_guard_{}_{}",
746 std::process::id(),
747 std::time::SystemTime::now()
748 .duration_since(std::time::UNIX_EPOCH)
749 .map(|d| d.as_nanos())
750 .unwrap_or(0)
751 ));
752 let Ok(file) = std::fs::File::options()
753 .create(true)
754 .truncate(true)
755 .read(true)
756 .write(true)
757 .open(&path)
758 else {
759 return;
760 };
761 let original = unsafe { std::mem::zeroed::<libc::termios>() };
765 drop(EchoGuard {
766 tty: file,
767 original,
768 });
769
770 let announced = std::fs::read_to_string(&path).unwrap_or_default();
771 let _ = std::fs::remove_file(&path);
772 assert!(
773 announced.contains("could not restore terminal echo"),
774 "a terminal left without echo must say so: {announced:?}"
775 );
776 }
777
778 #[cfg(windows)]
781 mod windows_echo {
782 use super::super::{EchoGuard, disable_console_echo};
783
784 const ERROR_INVALID_HANDLE: i32 = 6;
789
790 fn scratch_file(label: &str) -> Option<(std::path::PathBuf, std::fs::File)> {
791 let path = std::env::temp_dir().join(format!(
792 "afdata_{label}_{}_{}",
793 std::process::id(),
794 std::time::SystemTime::now()
795 .duration_since(std::time::UNIX_EPOCH)
796 .map(|d| d.as_nanos())
797 .unwrap_or(0)
798 ));
799 let file = std::fs::File::options()
800 .create(true)
801 .truncate(true)
802 .read(true)
803 .write(true)
804 .open(&path)
805 .ok()?;
806 Some((path, file))
807 }
808
809 #[test]
810 fn echo_control_goes_to_the_console_not_to_a_program() {
811 let Some((path, file)) = scratch_file("not_a_console") else {
812 return;
813 };
814
815 let error = disable_console_echo(&file)
816 .err()
817 .and_then(|error| error.raw_os_error());
818
819 drop(file);
820 let _ = std::fs::remove_file(&path);
821 assert_eq!(
822 error,
823 Some(ERROR_INVALID_HANDLE),
824 "echo control must fail as a console call, not as a missing program"
825 );
826 }
827
828 #[test]
829 fn a_failed_restore_is_reported_rather_than_swallowed() {
830 let Some((path, file)) = scratch_file("console_echo_guard") else {
831 return;
832 };
833 let Ok(output) = file.try_clone() else {
834 let _ = std::fs::remove_file(&path);
835 return;
836 };
837
838 drop(EchoGuard {
841 console: file,
842 output,
843 original: 0,
844 });
845
846 let announced = std::fs::read_to_string(&path).unwrap_or_default();
847 let _ = std::fs::remove_file(&path);
848 assert!(
849 announced.contains("could not restore console echo"),
850 "a console left without echo must say so: {announced:?}"
851 );
852 }
853 }
854
855 use crate::cli_spec::SourceSet;
856 use std::path::PathBuf;
857
858 fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
859 let path = std::env::temp_dir().join(format!(
860 "afdata-value-source-{name}-{}.{extension}",
861 std::process::id()
862 ));
863 std::fs::write(&path, content).expect("write test config");
864 path
865 }
866
867 fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
873 #[allow(unused_mut)]
876 let mut cases: Vec<(&str, &str, &str, &str)> =
877 vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
878 #[cfg(feature = "toml")]
879 cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
880 #[cfg(feature = "yaml")]
881 cases.push(("yaml", "yaml", "a:\n b: ' v '\n", "a.b"));
882 #[cfg(feature = "dotenv")]
883 cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
884 cases
885 }
886
887 #[test]
888 fn a_file_source_reads_one_address_out_of_every_format() {
889 for (name, extension, content, dot_path) in readable_formats() {
890 let path = temp_config(name, extension, content);
891 let source = ValueSource::File {
892 path: path.clone(),
893 dot_path: dot_path.to_string(),
894 format: None,
895 };
896 let read = source.read();
897 let secret = source.read_secret();
898 std::fs::remove_file(&path).expect("remove test config");
899 assert_eq!(read.as_deref(), Ok(" v "), "{name}");
901 assert_eq!(
902 secret.expect("secret read").expose_secret(),
903 " v ",
904 "{name}"
905 );
906 }
907 }
908
909 #[test]
910 fn an_empty_string_is_still_a_value() {
911 let path = temp_config("empty", "json", r#"{"empty":""}"#);
912 let source = ValueSource::File {
913 path: path.clone(),
914 dot_path: "empty".to_string(),
915 format: None,
916 };
917 assert_eq!(source.read().as_deref(), Ok(""));
918 let secret = source.read_secret().expect("empty secret remains explicit");
919 assert!(secret.is_empty());
920 std::fs::remove_file(&path).expect("remove test config");
921 }
922
923 #[cfg(feature = "ini")]
930 #[test]
931 fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
932 let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
933 let named = ValueSource::File {
934 path: path.clone(),
935 dot_path: "http-password".to_string(),
936 format: Some("ini".to_string()),
937 };
938 let unnamed = ValueSource::File {
939 path: path.clone(),
940 dot_path: "http-password".to_string(),
941 format: None,
942 };
943 let bad_name = ValueSource::File {
944 path: path.clone(),
945 dot_path: "http-password".to_string(),
946 format: Some("nonsense".to_string()),
947 };
948 let read = named.read_secret();
949 let without = unnamed.read();
950 let bad = bad_name.read();
951 std::fs::remove_file(&path).expect("remove test config");
952
953 assert_eq!(read.expect("named format").expose_secret(), "abc123");
954 let without = without.expect_err("no extension to detect");
957 assert!(without.message().contains("file+FORMAT:"), "{without}");
958 let bad = bad.expect_err("unknown format");
959 assert!(
960 bad.message().contains("not a format this build reads"),
961 "{bad}"
962 );
963 }
964
965 #[test]
966 fn a_named_format_without_an_address_is_refused_not_ignored() {
967 let source = ValueSource::File {
968 path: PathBuf::from("does-not-matter.cfg"),
969 dot_path: String::new(),
970 format: Some("toml".to_string()),
971 };
972 let error = source.read().expect_err("a format with nothing to parse");
973 assert!(error.message().contains("#DOT_PATH"), "{error}");
974 }
975
976 #[test]
979 fn a_non_string_scalar_is_a_value_but_never_a_secret() {
980 let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
981 let port = ValueSource::File {
982 path: path.clone(),
983 dot_path: "port".to_string(),
984 format: None,
985 };
986 assert_eq!(port.read().as_deref(), Ok("5432"));
987 let error = port.read_secret().expect_err("a secret must be a string");
988 assert!(error.message().contains("must be a string"), "{error}");
989
990 let on = ValueSource::File {
991 path: path.clone(),
992 dot_path: "on".to_string(),
993 format: None,
994 };
995 assert_eq!(on.read().as_deref(), Ok("true"));
996 std::fs::remove_file(&path).expect("remove test config");
997 }
998
999 #[test]
1002 fn a_secret_read_never_echoes_what_it_read() {
1003 let canary = "AFDATA_SOURCE_CANARY";
1004 let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
1007 let source = ValueSource::File {
1008 path: path.clone(),
1009 dot_path: "a".to_string(),
1010 format: None,
1011 };
1012 let plain = source.read().expect_err("malformed");
1013 let secret = source.read_secret().expect_err("malformed");
1014 std::fs::remove_file(&path).expect("remove test config");
1015 assert!(
1016 !secret.message().contains(canary),
1017 "secret read leaked: {secret}"
1018 );
1019 assert!(plain.message().contains("cannot read"), "{plain}");
1021 }
1022
1023 #[test]
1024 fn a_collection_is_not_a_value() {
1025 let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
1026 for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
1027 let source = ValueSource::File {
1028 path: path.clone(),
1029 dot_path: dot_path.to_string(),
1030 format: None,
1031 };
1032 let error = source.read().expect_err(dot_path);
1033 assert!(error.message().contains(expected), "{dot_path}: {error}");
1034 }
1035 std::fs::remove_file(&path).expect("remove test config");
1036 }
1037
1038 #[test]
1040 fn a_host_scheme_is_not_this_crates_to_read() {
1041 let error = SourceSet::config()
1042 .host_scheme("container", "container:NAME")
1043 .parse("container:x")
1044 .expect("parses")
1045 .read()
1046 .expect_err("this crate cannot read it");
1047 assert_eq!(error.code(), "value_source_unreadable");
1048 }
1049
1050 #[test]
1051 fn an_unset_environment_source_names_what_it_tried() {
1052 const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
1053 let error = ValueSource::Env(ABSENT.to_string())
1054 .read()
1055 .expect_err("unset");
1056 assert_eq!(error.code(), "value_source_unreadable");
1057 assert!(error.message().contains(ABSENT), "{error}");
1058 }
1059
1060 #[test]
1063 fn a_secret_string_cannot_be_printed_by_accident() {
1064 let secret = SecretString::new("s3cret");
1065 assert_eq!(format!("{secret}"), "***");
1066 assert_eq!(format!("{secret:?}"), "***");
1067 assert!(!format!("{secret:?} {secret}").contains("s3cret"));
1068 assert_eq!(secret.expose_secret(), "s3cret");
1069 #[derive(Debug)]
1071 struct Config {
1072 #[allow(dead_code)]
1073 token_secret: SecretString,
1074 }
1075 let printed = format!(
1076 "{:?}",
1077 Config {
1078 token_secret: secret
1079 }
1080 );
1081 assert!(!printed.contains("s3cret"), "{printed}");
1082 }
1083
1084 #[test]
1085 fn a_stream_is_read_verbatim_and_capped() {
1086 assert_eq!(
1087 read_stream(" v \n".as_bytes(), "test").as_deref(),
1088 Ok(" v \n")
1089 );
1090 let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
1091 let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
1092 assert!(error.message().contains("exceeds"), "{error}");
1093 }
1094
1095 #[test]
1096 fn prompt_eof_is_not_an_empty_answer_and_only_one_line_ending_is_removed() {
1097 assert!(read_prompt_line(std::io::Cursor::new(b"")).is_err());
1098 for (input, expected) in [
1099 ("\n", ""),
1100 ("\r\n", ""),
1101 ("value\r\r\n", "value\r"),
1102 ("value\r", "value\r"),
1103 ] {
1104 assert_eq!(
1105 read_prompt_line(std::io::Cursor::new(input)).expect("answer"),
1106 expected
1107 );
1108 }
1109 }
1110
1111 #[test]
1112 fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
1113 let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
1114 assert_eq!(
1115 read_prompt_line(std::io::Cursor::new(exact))
1116 .expect("cap-sized line")
1117 .len(),
1118 MAX_STREAM_BYTES
1119 );
1120 let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
1121 let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
1122 assert!(error.message().contains("exceeds"), "{error}");
1123 }
1124
1125 #[cfg(unix)]
1126 #[test]
1127 fn an_fd_source_never_closes_the_callers_descriptor() {
1128 use std::io::{Read, Seek};
1129 use std::os::fd::AsRawFd;
1130
1131 let path = temp_config("fd", "txt", "descriptor value");
1132 let mut file = std::fs::File::open(&path).expect("open test descriptor");
1133 let source = ValueSource::Fd(file.as_raw_fd());
1134 assert_eq!(source.read().as_deref(), Ok("descriptor value"));
1135
1136 file.rewind()
1137 .expect("the caller still owns an open descriptor");
1138 let mut reread = String::new();
1139 file.read_to_string(&mut reread)
1140 .expect("read through caller-owned descriptor");
1141 assert_eq!(reread, "descriptor value");
1142 std::fs::remove_file(&path).expect("remove test config");
1143 }
1144}