Skip to main content

agent_first_data/
value_source.rs

1//! Reading a value that named where it is, and the policy that separates a
2//! printable value from a credential.
3//!
4//! The grammar — which sources exist, which an argument accepts, how one value
5//! is classified — belongs to the CLI core and lives in
6//! [`crate::cli_spec::SourceSet`]. This is the other half: doing the read, and
7//! deciding what may then be done with the result.
8//!
9//! # Mechanism there, policy here
10//!
11//! Nothing about a source is specific to secrets — reading a dot path out of a
12//! config file is the same operation whether it yields a password or a port.
13//! What differs is what may be done with the result, and that difference is
14//! carried by the *return type* rather than by a flag someone can forget:
15//!
16//! - [`ValueSource::read`] answers a [`String`]. Its errors may quote the file
17//!   and the parser's own complaint, because being helpful is the point.
18//! - [`ValueSource::read_secret`] answers a
19//!   [`SecretString`](crate::value_source::SecretString), which cannot be
20//!   printed, logged, or serialized without saying `expose_secret` out loud.
21//!   Its errors are stripped of anything that could echo what was read, and it
22//!   refuses a non-string value outright — a credential is never a number.
23//!
24//! Both cap the read. An unbounded read of a caller-named path is a denial of
25//! service regardless of what the bytes turn out to be.
26
27use std::fmt;
28use std::path::Path;
29
30use crate::cli_spec::{SourceError, ValueSource};
31use crate::document::{DocumentFile, Format, Value};
32
33/// 16 MiB read limit for file-backed value sources, and the CLI's default
34/// document budget (`--max-file-bytes`).
35///
36/// Library callers of [`DocumentFile::open_capped`] choose their own budget.
37pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
38/// Credential streams and prompts retain their smaller bound.
39const MAX_STREAM_BYTES: usize = 1024 * 1024;
40
41type Result<T> = std::result::Result<T, SourceError>;
42
43// ── SecretString ────────────────────────────────────────────────────────────
44
45/// A string that cannot be printed by accident.
46///
47/// `Debug` and `Display` both render `***`, and there is deliberately no
48/// `Serialize`: a payload that genuinely needs the value asks for it with
49/// [`SecretString::expose_secret`], which is greppable in review in a way that
50/// `format!("{value}")` is not.
51#[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    /// The value itself. Call this at the boundary that needs it — the header
60    /// being signed, the connection being opened — and nowhere else.
61    #[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    /// Read the value.
92    ///
93    /// Errors may quote the file and the parser's complaint: for an ordinary
94    /// value, saying what was wrong with it is the whole point of the error.
95    /// A scalar that is not a string — a port, a boolean — is rendered in its
96    /// canonical spelling; a collection is refused.
97    pub fn read(&self) -> Result<String> {
98        read_with(self, Policy::Plain)
99    }
100
101    /// Read the value as a secret.
102    ///
103    /// The result cannot be printed without [`SecretString::expose_secret`],
104    /// errors carry nothing that could echo what was read, and a non-string
105    /// value is refused rather than coerced.
106    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    // A named format only says how to parse a document, and a whole-file read
160    // parses nothing; accepting one would silently ignore it.
161    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    // No document address means the complete UTF-8 file, without parsing or
176    // scalar conversion. This also supports documents passed to typed callers.
177    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    // A named format is resolved here rather than in the grammar: the CLI core
186    // that parses argv is not allowed to know what a document format is, so it
187    // carries the caller's word and this is where the word becomes a parser.
188    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    // `open_capped` rejects a non-regular file before reading a byte and limits
206    // the read, so naming a device or a huge file fails instead of hanging.
207    let document = DocumentFile::open_capped(path, Some(format), MAX_FILE_BYTES).map_err(
208        |error| match policy {
209            // `redacted_message` drops the parser detail, which for a
210            // secret-bearing file is the part that would quote the secret.
211            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        // A credential is text. A number that resolves where a secret was
244        // expected is a mis-addressed dot path, not a password.
245        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
261/// Verbatim, including any trailing newline: a value may legitimately end in
262/// whitespace, and this crate cannot tell that from a shell that added one.
263/// Use `printf '%s'` rather than `echo`.
264fn 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        // Duplicate the caller-owned descriptor: `read(&self)` must not close
295        // a handle it did not create, and leaving the number in `ValueSource`
296        // after closing it could make a later call read an unrelated descriptor
297        // that the process reused.
298        // SAFETY: `dup` accepts any integer and reports an invalid descriptor as
299        // `-1`. `from_raw_fd` is called only for the new descriptor it returned.
300        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        // SAFETY: `duplicated` is a fresh owned descriptor from successful
308        // `dup`, transferred exactly once into `File`.
309        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    // The newline the person typed is the terminator, not part of the value.
350    let _ = writeln!(tty);
351    value
352}
353
354// Reading a secret with the echo still on is not a degraded read, it is the
355// wrong one — the value ends up on the screen and in whatever recorded it. So a
356// build that cannot turn echo off refuses the source instead.
357#[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    // `CONIN$` and `CONOUT$` are this process's own console, the way `/dev/tty`
369    // is its controlling terminal: they are the console whatever stdin and
370    // stdout were redirected to, which is the whole reason a prompt does not
371    // read stdin. Input has to be opened for writing as well — `SetConsoleMode`
372    // is a write to the input buffer's settings, not a read of them.
373    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    // The newline the person typed is the terminator, not part of the value.
399    // With echo off the console printed nothing for it either, so the cursor is
400    // still on the prompt line until this is written.
401    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/// The console API this needs, declared rather than depended on.
413///
414/// Three functions from `kernel32`, which is already linked into every Windows
415/// program. A crate wrapping the whole Win32 surface would be a dependency —
416/// and a supply chain — for what fits here in fifteen lines, and this spore
417/// already reaches the known-folder API the same way.
418#[cfg(windows)]
419mod windows_console {
420    use std::ffi::c_void;
421
422    /// Keystrokes are echoed as they are typed. Clearing this bit is the entire
423    /// point of the module.
424    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/// Turn echo off on the console, returning the mode to put back.
441///
442/// The unix note about not spawning `stty` applies here for the same reasons,
443/// and so does the one about verifying: `SetConsoleMode` rejects the whole mode
444/// word or accepts it, but what the console ends up with is still worth reading
445/// back — `ENABLE_ECHO_INPUT` is only honoured alongside `ENABLE_LINE_INPUT`,
446/// so a console already in raw mode can take the call and echo anyway.
447#[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        // Put back what was there before refusing: a half-applied change is
453        // still a change.
454        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    // SAFETY: `console` is a live open handle for the duration of the call, and
466    // `mode` is a writable `u32` the API fills in on success.
467    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    // SAFETY: `console` is a live open handle for the duration of the call, and
479    // `mode` is passed by value.
480    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/// Read one line of UTF-16 from the console.
488///
489/// Deliberately not the `Read` implementation on the same handle: that goes
490/// through `ReadFile`, which answers bytes in the console's current input code
491/// page. On a console whose code page is not UTF-8 — the default on most
492/// installs outside the US — a value with any character beyond ASCII comes back
493/// as different bytes, and this crate would then either refuse it as invalid
494/// UTF-8 or accept a silently different secret. `ReadConsoleW` answers UTF-16,
495/// which converts losslessly.
496#[cfg(windows)]
497fn read_console_line(console: &std::fs::File) -> Result<String> {
498    use std::os::windows::io::AsRawHandle as _;
499
500    // Enough to hold any accepted value plus the shortest overlong one, so
501    // exceeding the cap is detected here rather than truncated into a valid
502    // read: a UTF-16 sequence is never longer in units than its UTF-8 encoding
503    // is in bytes, and the extra units cover CRLF.
504    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    // SAFETY: `console` is a live open handle, `buffer` is a writable
509    // allocation of exactly `units_to_read` `u16`s, and `units_read` is a
510    // writable `u32`. The input-control argument is optional and null here.
511    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    // The same cap and the same line ending handling as every other prompt.
532    read_prompt_line(std::io::Cursor::new(text.as_bytes()))
533}
534
535/// Turn echo off on `tty`, returning the settings to put back.
536///
537/// This talks to the terminal directly rather than running `stty`. Spawning
538/// anything here means resolving a name through `PATH` immediately before a
539/// secret is typed, and it makes the one thing standing between the value and
540/// the screen depend on a program being installed. `tcsetattr` also reports
541/// success when it applied only some of what was asked, so what it did is read
542/// back and checked rather than assumed.
543#[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        // Put back what was there before refusing: a half-applied change is
551        // still a change.
552        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    // SAFETY: `tty` is a live open descriptor for the duration of the call, and
564    // `tcgetattr` initializes the whole `termios` it is given on success.
565    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    // SAFETY: `tcgetattr` reported success, so the value is initialized.
570    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    // `TCSAFLUSH` discards input typed before the change took effect, so
578    // keystrokes racing the switch cannot be echoed after it.
579    // SAFETY: `tty` is a live open descriptor and `attributes` is an
580    // initialized `termios` that outlives the call.
581    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    // Leave room for CRLF beyond the value cap. `Take` bounds `read_line`'s
593    // allocation even when the terminal never sends a newline.
594    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/// Puts terminal echo back however the read ended, including on a panic.
617#[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        // The terminal was borrowed and must be handed back. Failing quietly
632        // leaves the person typing into a shell that shows nothing and looks
633        // broken, with no clue why. Said on the terminal itself, which is where
634        // the damage is, rather than through the caller's structured output.
635        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/// Puts console echo back however the read ended, including on a panic.
643///
644/// Two handles rather than the unix one: `CONIN$` carries the mode to restore
645/// and cannot be written to, `CONOUT$` is where anything a person should read
646/// has to go.
647#[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        // As on unix: the console was borrowed and must be handed back, and a
663        // console that shows nothing while a person types looks broken rather
664        // than quiet. Windows has no `stty echo` to name — the mode belongs to
665        // this console's input buffer and goes when the console does.
666        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        // A regular file is not a terminal. `tcsetattr` answers ENOTTY, which
708        // is an answer only the terminal API can give: a spawned `stty`
709        // resolved through `PATH` would report a child's exit status instead,
710        // and whatever `PATH` happened to name would already have run.
711        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        // The guard writes its warning to the terminal it was handed. Dropping
741        // one over a non-terminal exercises the failure path end to end: it
742        // must neither panic nor hang, and the file it wrote to shows that the
743        // failure was announced instead of ignored.
744        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        // SAFETY: a zeroed `termios` is a valid value to hand back to
762        // `tcsetattr`; this descriptor is not a terminal, so it never reaches
763        // one.
764        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    /// The Windows counterpart of the two tests above, asserting the same two
779    /// properties against the console API.
780    #[cfg(windows)]
781    mod windows_echo {
782        use super::super::{EchoGuard, disable_console_echo};
783
784        /// `ERROR_INVALID_HANDLE`. A regular file is not a console, and this is
785        /// the console API saying so — a spawned helper resolved through `PATH`
786        /// would report a child's exit status instead, and whatever `PATH`
787        /// happened to name would already have run.
788        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            // Restoring over a non-console fails, which is the path under test:
839            // it must neither panic nor hang, and must leave the warning behind.
840            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    /// Every format this build can parse. The list is assembled by feature
868    /// because `scripts/test.sh unit` also runs `--no-default-features
869    /// --features cli`, where a `.toml` file is not a format this binary knows
870    /// — and a source that answers "no toml support" there is correct
871    /// behavior, not a failure to assert against.
872    fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
873        // In the `cli`-only test build none of the conditional pushes compile,
874        // while every normal build needs the mutability.
875        #[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            // Verbatim: surrounding space is part of the value.
900            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    /// A filename does not always say what a file is. `phoenix.conf`,
924    /// `/etc/*.conf`, an extensionless credential file — refusing those sent
925    /// callers back to `grep | cut`, which is the thing a source replaces.
926    ///
927    /// INI-gated: without that parser this build genuinely cannot read the
928    /// file, and refusing to is the correct answer rather than a failure.
929    #[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        // Without the name, the error says how to supply one rather than only
955        // that it could not guess.
956        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    /// The policy difference, in one place: an ordinary value may be a port; a
977    /// secret may not.
978    #[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    /// The other policy difference: a plain read explains itself, a secret read
1000    /// refuses to quote anything it saw.
1001    #[test]
1002    fn a_secret_read_never_echoes_what_it_read() {
1003        let canary = "AFDATA_SOURCE_CANARY";
1004        // JSON so the assertion holds in every feature combination the gate
1005        // builds; what is under test is the policy, not the parser.
1006        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        // The plain read is allowed to be helpful; that is the difference.
1020        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    /// Parsed by the core, read by nobody: a host scheme is the host's to read.
1039    #[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    /// Including through `{:?}`, which is how a secret reaches a log nobody
1061    /// meant to write.
1062    #[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        // Held inside something else, it stays redacted there too.
1070        #[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}