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/// A stream named by a source carries one value, not a document.
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 => read_stream(std::io::stdin().lock(), "stdin"),
127        ValueSource::Fd(number) => read_fd(*number),
128        ValueSource::Prompt => read_prompt(),
129        ValueSource::Host { scheme, .. } => Err(SourceError::unreadable(format!(
130            "`{scheme}` is a host-defined source; this crate cannot read it"
131        ))),
132    }
133}
134
135#[derive(Clone, Copy, PartialEq, Eq)]
136enum Policy {
137    Plain,
138    Secret,
139}
140
141fn read_file(
142    path: &Path,
143    dot_path: &str,
144    named_format: Option<&str>,
145    policy: Policy,
146) -> Result<String> {
147    // A named format is resolved here rather than in the grammar: the CLI core
148    // that parses argv is not allowed to know what a document format is, so it
149    // carries the caller's word and this is where the word becomes a parser.
150    let format = match named_format {
151        Some(name) => Format::from_cli_name(name).ok_or_else(|| {
152            SourceError::invalid(format!("`file+{name}:` is not a format this build reads"))
153        })?,
154        None => Format::detect(path).ok_or_else(|| match Format::unavailable(path) {
155            Some(feature) => SourceError::unreadable(format!(
156                "cannot read {}: this build has no {feature} support",
157                path.display()
158            )),
159            None => SourceError::invalid(format!(
160                "cannot tell the config format of {} from its name; name it with \
161                 file+FORMAT:{}#{dot_path}, or use a .json/.toml/.yaml/.env/.ini file",
162                path.display(),
163                path.display()
164            )),
165        })?,
166    };
167    // `open_capped` rejects a non-regular file before reading a byte and limits
168    // the read, so naming a device or a huge file fails instead of hanging.
169    let document = DocumentFile::open_capped(path, Some(format), MAX_FILE_BYTES).map_err(
170        |error| match policy {
171            // `redacted_message` drops the parser detail, which for a
172            // secret-bearing file is the part that would quote the secret.
173            Policy::Secret => SourceError::unreadable(format!(
174                "cannot read {} config {}: {}",
175                format.name(),
176                path.display(),
177                error.redacted_message()
178            )),
179            Policy::Plain => SourceError::unreadable(format!(
180                "cannot read {} config {}: {error}",
181                format.name(),
182                path.display()
183            )),
184        },
185    )?;
186    let value = document.value_at(dot_path).map_err(|error| {
187        if error.code() == "document_path_not_found" {
188            SourceError::unreadable(format!("{dot_path} was not found in {}", path.display()))
189        } else {
190            SourceError::unreadable(format!("cannot resolve {dot_path} in {}", path.display()))
191        }
192    })?;
193    scalar(value, path, dot_path, policy)
194}
195
196fn scalar(value: Value, path: &Path, dot_path: &str, policy: Policy) -> Result<String> {
197    let refused = |kind: &str| {
198        SourceError::unreadable(format!(
199            "{dot_path} in {} is {kind}, which is not a value",
200            path.display()
201        ))
202    };
203    match value {
204        Value::String(value) => Ok(value),
205        // A credential is text. A number that resolves where a secret was
206        // expected is a mis-addressed dot path, not a password.
207        other if policy == Policy::Secret => Err(SourceError::unreadable(format!(
208            "{dot_path} in {} is {}; a secret must be a string",
209            path.display(),
210            other.kind_name()
211        ))),
212        Value::Integer(value) => Ok(value.to_string()),
213        Value::Unsigned(value) => Ok(value.to_string()),
214        Value::Float(value) => Ok(value.to_string()),
215        Value::Number(value) => Ok(value),
216        Value::Bool(value) => Ok(value.to_string()),
217        Value::Null => Err(refused("null")),
218        Value::Array(_) => Err(refused("an array")),
219        Value::Object(_) => Err(refused("an object")),
220    }
221}
222
223/// Verbatim, including any trailing newline: a value may legitimately end in
224/// whitespace, and this crate cannot tell that from a shell that added one.
225/// Use `printf '%s'` rather than `echo`.
226fn read_stream<R: std::io::Read>(reader: R, source: &str) -> Result<String> {
227    use std::io::Read;
228    let mut bytes = Vec::new();
229    reader
230        .take((MAX_STREAM_BYTES + 1) as u64)
231        .read_to_end(&mut bytes)
232        .map_err(|error| SourceError::unreadable(format!("read from {source}: {error}")))?;
233    if bytes.len() > MAX_STREAM_BYTES {
234        return Err(SourceError::unreadable(format!(
235            "{source} exceeds {MAX_STREAM_BYTES} bytes"
236        )));
237    }
238    String::from_utf8(bytes)
239        .map_err(|_| SourceError::unreadable(format!("{source} must carry valid UTF-8")))
240}
241
242#[cfg(unix)]
243fn read_fd(number: i32) -> Result<String> {
244    #[cfg(feature = "libc")]
245    let file = {
246        use std::os::fd::FromRawFd;
247
248        // Duplicate the caller-owned descriptor: `read(&self)` must not close
249        // a handle it did not create, and leaving the number in `ValueSource`
250        // after closing it could make a later call read an unrelated descriptor
251        // that the process reused.
252        // SAFETY: `dup` accepts any integer and reports an invalid descriptor as
253        // `-1`. `from_raw_fd` is called only for the new descriptor it returned.
254        let duplicated = unsafe { libc::dup(number) };
255        if duplicated < 0 {
256            return Err(SourceError::unreadable(format!(
257                "open file descriptor {number}: {}",
258                std::io::Error::last_os_error()
259            )));
260        }
261        // SAFETY: `duplicated` is a fresh owned descriptor from successful
262        // `dup`, transferred exactly once into `File`.
263        unsafe { std::fs::File::from_raw_fd(duplicated) }
264    };
265    #[cfg(not(feature = "libc"))]
266    let file = std::fs::File::open(format!("/dev/fd/{number}")).map_err(|error| {
267        SourceError::unreadable(format!("open file descriptor {number}: {error}"))
268    })?;
269    read_stream(file, "file descriptor")
270}
271
272#[cfg(not(unix))]
273fn read_fd(_number: i32) -> Result<String> {
274    Err(SourceError::unreadable(
275        "the `fd` source is unsupported on this platform",
276    ))
277}
278
279#[cfg(all(unix, feature = "libc"))]
280fn read_prompt() -> Result<String> {
281    use std::io::Write;
282
283    let mut tty = std::fs::OpenOptions::new()
284        .read(true)
285        .write(true)
286        .open("/dev/tty")
287        .map_err(|error| {
288            SourceError::unreadable(format!("open the controlling terminal: {error}"))
289        })?;
290    let restore_tty = tty.try_clone().map_err(|error| {
291        SourceError::unreadable(format!("prepare terminal echo restoration: {error}"))
292    })?;
293    let original = disable_terminal_echo(&tty)
294        .map_err(|error| SourceError::unreadable(format!("disable terminal echo: {error}")))?;
295    let _echo = EchoGuard {
296        tty: restore_tty,
297        original,
298    };
299    write!(tty, "Value: ")
300        .map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
301    let reader = std::io::BufReader::new(&mut tty);
302    let value = read_prompt_line(reader);
303    // The newline the person typed is the terminator, not part of the value.
304    let _ = writeln!(tty);
305    value
306}
307
308// Reading a secret with the echo still on is not a degraded read, it is the
309// wrong one — the value ends up on the screen and in whatever recorded it. So a
310// build that cannot turn echo off refuses the source instead.
311#[cfg(all(unix, not(feature = "libc")))]
312fn read_prompt() -> Result<String> {
313    Err(SourceError::unreadable(
314        "the `prompt` source needs Cargo feature `libc` to turn terminal echo off",
315    ))
316}
317
318#[cfg(windows)]
319fn read_prompt() -> Result<String> {
320    use std::io::Write as _;
321
322    // `CONIN$` and `CONOUT$` are this process's own console, the way `/dev/tty`
323    // is its controlling terminal: they are the console whatever stdin and
324    // stdout were redirected to, which is the whole reason a prompt does not
325    // read stdin. Input has to be opened for writing as well — `SetConsoleMode`
326    // is a write to the input buffer's settings, not a read of them.
327    let input = std::fs::OpenOptions::new()
328        .read(true)
329        .write(true)
330        .open("CONIN$")
331        .map_err(|error| SourceError::unreadable(format!("open the console input: {error}")))?;
332    let mut output = std::fs::OpenOptions::new()
333        .write(true)
334        .open("CONOUT$")
335        .map_err(|error| SourceError::unreadable(format!("open the console output: {error}")))?;
336    let restore_console = input.try_clone().map_err(|error| {
337        SourceError::unreadable(format!("prepare console echo restoration: {error}"))
338    })?;
339    let restore_output = output.try_clone().map_err(|error| {
340        SourceError::unreadable(format!("prepare console echo restoration: {error}"))
341    })?;
342    let original = disable_console_echo(&input)
343        .map_err(|error| SourceError::unreadable(format!("disable console echo: {error}")))?;
344    let _echo = EchoGuard {
345        console: restore_console,
346        output: restore_output,
347        original,
348    };
349    write!(output, "Value: ")
350        .map_err(|error| SourceError::unreadable(format!("write the prompt: {error}")))?;
351    let value = read_console_line(&input);
352    // The newline the person typed is the terminator, not part of the value.
353    // With echo off the console printed nothing for it either, so the cursor is
354    // still on the prompt line until this is written.
355    let _ = writeln!(output);
356    value
357}
358
359#[cfg(all(not(unix), not(windows)))]
360fn read_prompt() -> Result<String> {
361    Err(SourceError::unreadable(
362        "the `prompt` source is unsupported on this platform",
363    ))
364}
365
366/// The console API this needs, declared rather than depended on.
367///
368/// Three functions from `kernel32`, which is already linked into every Windows
369/// program. A crate wrapping the whole Win32 surface would be a dependency —
370/// and a supply chain — for what fits here in fifteen lines, and this spore
371/// already reaches the known-folder API the same way.
372#[cfg(windows)]
373mod windows_console {
374    use std::ffi::c_void;
375
376    /// Keystrokes are echoed as they are typed. Clearing this bit is the entire
377    /// point of the module.
378    pub(super) const ENABLE_ECHO_INPUT: u32 = 0x0004;
379
380    #[link(name = "kernel32")]
381    unsafe extern "system" {
382        pub(super) fn GetConsoleMode(console: *mut c_void, mode: *mut u32) -> i32;
383        pub(super) fn SetConsoleMode(console: *mut c_void, mode: u32) -> i32;
384        pub(super) fn ReadConsoleW(
385            console: *mut c_void,
386            buffer: *mut u16,
387            units_to_read: u32,
388            units_read: *mut u32,
389            input_control: *mut c_void,
390        ) -> i32;
391    }
392}
393
394/// Turn echo off on the console, returning the mode to put back.
395///
396/// The unix note about not spawning `stty` applies here for the same reasons,
397/// and so does the one about verifying: `SetConsoleMode` rejects the whole mode
398/// word or accepts it, but what the console ends up with is still worth reading
399/// back — `ENABLE_ECHO_INPUT` is only honoured alongside `ENABLE_LINE_INPUT`,
400/// so a console already in raw mode can take the call and echo anyway.
401#[cfg(windows)]
402fn disable_console_echo(console: &std::fs::File) -> std::io::Result<u32> {
403    let original = console_mode(console)?;
404    set_console_mode(console, original & !windows_console::ENABLE_ECHO_INPUT)?;
405    if console_mode(console)? & windows_console::ENABLE_ECHO_INPUT != 0 {
406        // Put back what was there before refusing: a half-applied change is
407        // still a change.
408        let _ = set_console_mode(console, original);
409        return Err(std::io::Error::other("console echo is still enabled"));
410    }
411    Ok(original)
412}
413
414#[cfg(windows)]
415fn console_mode(console: &std::fs::File) -> std::io::Result<u32> {
416    use std::os::windows::io::AsRawHandle as _;
417
418    let mut mode = 0u32;
419    // SAFETY: `console` is a live open handle for the duration of the call, and
420    // `mode` is a writable `u32` the API fills in on success.
421    let status = unsafe { windows_console::GetConsoleMode(console.as_raw_handle(), &mut mode) };
422    if status == 0 {
423        return Err(std::io::Error::last_os_error());
424    }
425    Ok(mode)
426}
427
428#[cfg(windows)]
429fn set_console_mode(console: &std::fs::File, mode: u32) -> std::io::Result<()> {
430    use std::os::windows::io::AsRawHandle as _;
431
432    // SAFETY: `console` is a live open handle for the duration of the call, and
433    // `mode` is passed by value.
434    let status = unsafe { windows_console::SetConsoleMode(console.as_raw_handle(), mode) };
435    if status == 0 {
436        return Err(std::io::Error::last_os_error());
437    }
438    Ok(())
439}
440
441/// Read one line of UTF-16 from the console.
442///
443/// Deliberately not the `Read` implementation on the same handle: that goes
444/// through `ReadFile`, which answers bytes in the console's current input code
445/// page. On a console whose code page is not UTF-8 — the default on most
446/// installs outside the US — a value with any character beyond ASCII comes back
447/// as different bytes, and this crate would then either refuse it as invalid
448/// UTF-8 or accept a silently different secret. `ReadConsoleW` answers UTF-16,
449/// which converts losslessly.
450#[cfg(windows)]
451fn read_console_line(console: &std::fs::File) -> Result<String> {
452    use std::os::windows::io::AsRawHandle as _;
453
454    // Enough to hold any accepted value plus the shortest overlong one, so
455    // exceeding the cap is detected here rather than truncated into a valid
456    // read: a UTF-16 sequence is never longer in units than its UTF-8 encoding
457    // is in bytes, and the extra units cover CRLF.
458    let mut buffer = vec![0u16; MAX_STREAM_BYTES + 4];
459    let units_to_read = u32::try_from(buffer.len())
460        .map_err(|_| SourceError::unreadable("the console read buffer does not fit a request"))?;
461    let mut units_read = 0u32;
462    // SAFETY: `console` is a live open handle, `buffer` is a writable
463    // allocation of exactly `units_to_read` `u16`s, and `units_read` is a
464    // writable `u32`. The input-control argument is optional and null here.
465    let status = unsafe {
466        windows_console::ReadConsoleW(
467            console.as_raw_handle(),
468            buffer.as_mut_ptr(),
469            units_to_read,
470            &mut units_read,
471            std::ptr::null_mut(),
472        )
473    };
474    if status == 0 {
475        return Err(SourceError::unreadable(format!(
476            "read from the console: {}",
477            std::io::Error::last_os_error()
478        )));
479    }
480    let units = buffer
481        .get(..units_read as usize)
482        .ok_or_else(|| SourceError::unreadable("the console reported reading past its buffer"))?;
483    let text = String::from_utf16(units)
484        .map_err(|_| SourceError::unreadable("the console answered malformed UTF-16"))?;
485    // The same cap and the same line ending handling as every other prompt.
486    read_prompt_line(std::io::Cursor::new(text.as_bytes()))
487}
488
489/// Turn echo off on `tty`, returning the settings to put back.
490///
491/// This talks to the terminal directly rather than running `stty`. Spawning
492/// anything here means resolving a name through `PATH` immediately before a
493/// secret is typed, and it makes the one thing standing between the value and
494/// the screen depend on a program being installed. `tcsetattr` also reports
495/// success when it applied only some of what was asked, so what it did is read
496/// back and checked rather than assumed.
497#[cfg(all(unix, feature = "libc"))]
498fn disable_terminal_echo(tty: &std::fs::File) -> std::io::Result<libc::termios> {
499    let original = terminal_attributes(tty)?;
500    let mut quiet = original;
501    quiet.c_lflag &= !libc::ECHO;
502    set_terminal_attributes(tty, &quiet)?;
503    if terminal_attributes(tty)?.c_lflag & libc::ECHO != 0 {
504        // Put back what was there before refusing: a half-applied change is
505        // still a change.
506        let _ = set_terminal_attributes(tty, &original);
507        return Err(std::io::Error::other("terminal echo is still enabled"));
508    }
509    Ok(original)
510}
511
512#[cfg(all(unix, feature = "libc"))]
513fn terminal_attributes(tty: &std::fs::File) -> std::io::Result<libc::termios> {
514    use std::os::fd::AsRawFd as _;
515
516    let mut attributes = std::mem::MaybeUninit::<libc::termios>::uninit();
517    // SAFETY: `tty` is a live open descriptor for the duration of the call, and
518    // `tcgetattr` initializes the whole `termios` it is given on success.
519    let status = unsafe { libc::tcgetattr(tty.as_raw_fd(), attributes.as_mut_ptr()) };
520    if status != 0 {
521        return Err(std::io::Error::last_os_error());
522    }
523    // SAFETY: `tcgetattr` reported success, so the value is initialized.
524    Ok(unsafe { attributes.assume_init() })
525}
526
527#[cfg(all(unix, feature = "libc"))]
528fn set_terminal_attributes(tty: &std::fs::File, attributes: &libc::termios) -> std::io::Result<()> {
529    use std::os::fd::AsRawFd as _;
530
531    // `TCSAFLUSH` discards input typed before the change took effect, so
532    // keystrokes racing the switch cannot be echoed after it.
533    // SAFETY: `tty` is a live open descriptor and `attributes` is an
534    // initialized `termios` that outlives the call.
535    let status = unsafe { libc::tcsetattr(tty.as_raw_fd(), libc::TCSAFLUSH, attributes) };
536    if status != 0 {
537        return Err(std::io::Error::last_os_error());
538    }
539    Ok(())
540}
541
542#[cfg(any(all(unix, feature = "libc"), windows, test))]
543fn read_prompt_line<R: std::io::BufRead>(reader: R) -> Result<String> {
544    use std::io::BufRead;
545
546    // Leave room for CRLF beyond the value cap. `Take` bounds `read_line`'s
547    // allocation even when the terminal never sends a newline.
548    let mut limited = reader.take((MAX_STREAM_BYTES + 2) as u64);
549    let mut value = String::new();
550    let read = limited
551        .read_line(&mut value)
552        .map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
553    if read == 0 {
554        return Err(SourceError::unreadable(
555            "the terminal closed before a value was entered",
556        ));
557    }
558    let value = match value.strip_suffix('\n') {
559        Some(line) => line.strip_suffix('\r').unwrap_or(line),
560        None => &value,
561    };
562    if value.len() > MAX_STREAM_BYTES {
563        return Err(SourceError::unreadable(format!(
564            "prompt exceeds {MAX_STREAM_BYTES} bytes"
565        )));
566    }
567    Ok(value.to_string())
568}
569
570/// Puts terminal echo back however the read ended, including on a panic.
571#[cfg(all(unix, feature = "libc"))]
572struct EchoGuard {
573    tty: std::fs::File,
574    original: libc::termios,
575}
576
577#[cfg(all(unix, feature = "libc"))]
578impl Drop for EchoGuard {
579    fn drop(&mut self) {
580        use std::io::Write as _;
581
582        if set_terminal_attributes(&self.tty, &self.original).is_ok() {
583            return;
584        }
585        // The terminal was borrowed and must be handed back. Failing quietly
586        // leaves the person typing into a shell that shows nothing and looks
587        // broken, with no clue why. Said on the terminal itself, which is where
588        // the damage is, rather than through the caller's structured output.
589        let _ = writeln!(
590            &mut self.tty,
591            "\nwarning: could not restore terminal echo; run `stty echo` to fix this terminal"
592        );
593    }
594}
595
596/// Puts console echo back however the read ended, including on a panic.
597///
598/// Two handles rather than the unix one: `CONIN$` carries the mode to restore
599/// and cannot be written to, `CONOUT$` is where anything a person should read
600/// has to go.
601#[cfg(windows)]
602struct EchoGuard {
603    console: std::fs::File,
604    output: std::fs::File,
605    original: u32,
606}
607
608#[cfg(windows)]
609impl Drop for EchoGuard {
610    fn drop(&mut self) {
611        use std::io::Write as _;
612
613        if set_console_mode(&self.console, self.original).is_ok() {
614            return;
615        }
616        // As on unix: the console was borrowed and must be handed back, and a
617        // console that shows nothing while a person types looks broken rather
618        // than quiet. Windows has no `stty echo` to name — the mode belongs to
619        // this console's input buffer and goes when the console does.
620        let _ = writeln!(
621            &mut self.output,
622            "\nwarning: could not restore console echo; close this console window to get it back"
623        );
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[cfg(all(unix, feature = "libc"))]
632    #[test]
633    fn terminal_echo_control_goes_to_the_terminal_not_to_a_program() {
634        use std::io::Write as _;
635
636        // A regular file is not a terminal. `tcsetattr` answers ENOTTY, which
637        // is an answer only the terminal API can give: a spawned `stty`
638        // resolved through `PATH` would report a child's exit status instead,
639        // and whatever `PATH` happened to name would already have run.
640        let path = std::env::temp_dir().join(format!(
641            "afdata_not_a_tty_{}_{}",
642            std::process::id(),
643            std::time::SystemTime::now()
644                .duration_since(std::time::UNIX_EPOCH)
645                .map(|d| d.as_nanos())
646                .unwrap_or(0)
647        ));
648        let mut file = match std::fs::File::create(&path) {
649            Ok(file) => file,
650            Err(_) => return,
651        };
652        let _ = file.write_all(b"not a terminal");
653
654        let error = disable_terminal_echo(&file)
655            .err()
656            .map(|error| error.raw_os_error());
657
658        let _ = std::fs::remove_file(&path);
659        assert_eq!(
660            error,
661            Some(Some(libc::ENOTTY)),
662            "echo control must fail as a terminal call, not as a missing program"
663        );
664    }
665
666    #[cfg(all(unix, feature = "libc"))]
667    #[test]
668    fn a_failed_restore_is_reported_rather_than_swallowed() {
669        // The guard writes its warning to the terminal it was handed. Dropping
670        // one over a non-terminal exercises the failure path end to end: it
671        // must neither panic nor hang, and the file it wrote to shows that the
672        // failure was announced instead of ignored.
673        let path = std::env::temp_dir().join(format!(
674            "afdata_echo_guard_{}_{}",
675            std::process::id(),
676            std::time::SystemTime::now()
677                .duration_since(std::time::UNIX_EPOCH)
678                .map(|d| d.as_nanos())
679                .unwrap_or(0)
680        ));
681        let Ok(file) = std::fs::File::options()
682            .create(true)
683            .truncate(true)
684            .read(true)
685            .write(true)
686            .open(&path)
687        else {
688            return;
689        };
690        // SAFETY: a zeroed `termios` is a valid value to hand back to
691        // `tcsetattr`; this descriptor is not a terminal, so it never reaches
692        // one.
693        let original = unsafe { std::mem::zeroed::<libc::termios>() };
694        drop(EchoGuard {
695            tty: file,
696            original,
697        });
698
699        let announced = std::fs::read_to_string(&path).unwrap_or_default();
700        let _ = std::fs::remove_file(&path);
701        assert!(
702            announced.contains("could not restore terminal echo"),
703            "a terminal left without echo must say so: {announced:?}"
704        );
705    }
706
707    /// The Windows counterpart of the two tests above, asserting the same two
708    /// properties against the console API.
709    #[cfg(windows)]
710    mod windows_echo {
711        use super::super::{EchoGuard, disable_console_echo};
712
713        /// `ERROR_INVALID_HANDLE`. A regular file is not a console, and this is
714        /// the console API saying so — a spawned helper resolved through `PATH`
715        /// would report a child's exit status instead, and whatever `PATH`
716        /// happened to name would already have run.
717        const ERROR_INVALID_HANDLE: i32 = 6;
718
719        fn scratch_file(label: &str) -> Option<(std::path::PathBuf, std::fs::File)> {
720            let path = std::env::temp_dir().join(format!(
721                "afdata_{label}_{}_{}",
722                std::process::id(),
723                std::time::SystemTime::now()
724                    .duration_since(std::time::UNIX_EPOCH)
725                    .map(|d| d.as_nanos())
726                    .unwrap_or(0)
727            ));
728            let file = std::fs::File::options()
729                .create(true)
730                .truncate(true)
731                .read(true)
732                .write(true)
733                .open(&path)
734                .ok()?;
735            Some((path, file))
736        }
737
738        #[test]
739        fn echo_control_goes_to_the_console_not_to_a_program() {
740            let Some((path, file)) = scratch_file("not_a_console") else {
741                return;
742            };
743
744            let error = disable_console_echo(&file)
745                .err()
746                .and_then(|error| error.raw_os_error());
747
748            drop(file);
749            let _ = std::fs::remove_file(&path);
750            assert_eq!(
751                error,
752                Some(ERROR_INVALID_HANDLE),
753                "echo control must fail as a console call, not as a missing program"
754            );
755        }
756
757        #[test]
758        fn a_failed_restore_is_reported_rather_than_swallowed() {
759            let Some((path, file)) = scratch_file("console_echo_guard") else {
760                return;
761            };
762            let Ok(output) = file.try_clone() else {
763                let _ = std::fs::remove_file(&path);
764                return;
765            };
766
767            // Restoring over a non-console fails, which is the path under test:
768            // it must neither panic nor hang, and must leave the warning behind.
769            drop(EchoGuard {
770                console: file,
771                output,
772                original: 0,
773            });
774
775            let announced = std::fs::read_to_string(&path).unwrap_or_default();
776            let _ = std::fs::remove_file(&path);
777            assert!(
778                announced.contains("could not restore console echo"),
779                "a console left without echo must say so: {announced:?}"
780            );
781        }
782    }
783
784    use crate::cli_spec::SourceSet;
785    use std::path::PathBuf;
786
787    fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
788        let path = std::env::temp_dir().join(format!(
789            "afdata-value-source-{name}-{}.{extension}",
790            std::process::id()
791        ));
792        std::fs::write(&path, content).expect("write test config");
793        path
794    }
795
796    /// Every format this build can parse. The list is assembled by feature
797    /// because `scripts/test.sh unit` also runs `--no-default-features
798    /// --features cli`, where a `.toml` file is not a format this binary knows
799    /// — and a source that answers "no toml support" there is correct
800    /// behavior, not a failure to assert against.
801    fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
802        // In the `cli`-only test build none of the conditional pushes compile,
803        // while every normal build needs the mutability.
804        #[allow(unused_mut)]
805        let mut cases: Vec<(&str, &str, &str, &str)> =
806            vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
807        #[cfg(feature = "toml")]
808        cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
809        #[cfg(feature = "yaml")]
810        cases.push(("yaml", "yaml", "a:\n  b: ' v '\n", "a.b"));
811        #[cfg(feature = "dotenv")]
812        cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
813        cases
814    }
815
816    #[test]
817    fn a_file_source_reads_one_address_out_of_every_format() {
818        for (name, extension, content, dot_path) in readable_formats() {
819            let path = temp_config(name, extension, content);
820            let source = ValueSource::File {
821                path: path.clone(),
822                dot_path: dot_path.to_string(),
823                format: None,
824            };
825            let read = source.read();
826            let secret = source.read_secret();
827            std::fs::remove_file(&path).expect("remove test config");
828            // Verbatim: surrounding space is part of the value.
829            assert_eq!(read.as_deref(), Ok(" v "), "{name}");
830            assert_eq!(
831                secret.expect("secret read").expose_secret(),
832                " v ",
833                "{name}"
834            );
835        }
836    }
837
838    #[test]
839    fn an_empty_string_is_still_a_value() {
840        let path = temp_config("empty", "json", r#"{"empty":""}"#);
841        let source = ValueSource::File {
842            path: path.clone(),
843            dot_path: "empty".to_string(),
844            format: None,
845        };
846        assert_eq!(source.read().as_deref(), Ok(""));
847        let secret = source.read_secret().expect("empty secret remains explicit");
848        assert!(secret.is_empty());
849        std::fs::remove_file(&path).expect("remove test config");
850    }
851
852    /// A filename does not always say what a file is. `phoenix.conf`,
853    /// `/etc/*.conf`, an extensionless credential file — refusing those sent
854    /// callers back to `grep | cut`, which is the thing a source replaces.
855    ///
856    /// INI-gated: without that parser this build genuinely cannot read the
857    /// file, and refusing to is the correct answer rather than a failure.
858    #[cfg(feature = "ini")]
859    #[test]
860    fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
861        let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
862        let named = ValueSource::File {
863            path: path.clone(),
864            dot_path: "http-password".to_string(),
865            format: Some("ini".to_string()),
866        };
867        let unnamed = ValueSource::File {
868            path: path.clone(),
869            dot_path: "http-password".to_string(),
870            format: None,
871        };
872        let bad_name = ValueSource::File {
873            path: path.clone(),
874            dot_path: "http-password".to_string(),
875            format: Some("nonsense".to_string()),
876        };
877        let read = named.read_secret();
878        let without = unnamed.read();
879        let bad = bad_name.read();
880        std::fs::remove_file(&path).expect("remove test config");
881
882        assert_eq!(read.expect("named format").expose_secret(), "abc123");
883        // Without the name, the error says how to supply one rather than only
884        // that it could not guess.
885        let without = without.expect_err("no extension to detect");
886        assert!(without.message().contains("file+FORMAT:"), "{without}");
887        let bad = bad.expect_err("unknown format");
888        assert!(
889            bad.message().contains("not a format this build reads"),
890            "{bad}"
891        );
892    }
893
894    /// The policy difference, in one place: an ordinary value may be a port; a
895    /// secret may not.
896    #[test]
897    fn a_non_string_scalar_is_a_value_but_never_a_secret() {
898        let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
899        let port = ValueSource::File {
900            path: path.clone(),
901            dot_path: "port".to_string(),
902            format: None,
903        };
904        assert_eq!(port.read().as_deref(), Ok("5432"));
905        let error = port.read_secret().expect_err("a secret must be a string");
906        assert!(error.message().contains("must be a string"), "{error}");
907
908        let on = ValueSource::File {
909            path: path.clone(),
910            dot_path: "on".to_string(),
911            format: None,
912        };
913        assert_eq!(on.read().as_deref(), Ok("true"));
914        std::fs::remove_file(&path).expect("remove test config");
915    }
916
917    /// The other policy difference: a plain read explains itself, a secret read
918    /// refuses to quote anything it saw.
919    #[test]
920    fn a_secret_read_never_echoes_what_it_read() {
921        let canary = "AFDATA_SOURCE_CANARY";
922        // JSON so the assertion holds in every feature combination the gate
923        // builds; what is under test is the policy, not the parser.
924        let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
925        let source = ValueSource::File {
926            path: path.clone(),
927            dot_path: "a".to_string(),
928            format: None,
929        };
930        let plain = source.read().expect_err("malformed");
931        let secret = source.read_secret().expect_err("malformed");
932        std::fs::remove_file(&path).expect("remove test config");
933        assert!(
934            !secret.message().contains(canary),
935            "secret read leaked: {secret}"
936        );
937        // The plain read is allowed to be helpful; that is the difference.
938        assert!(plain.message().contains("cannot read"), "{plain}");
939    }
940
941    #[test]
942    fn a_collection_is_not_a_value() {
943        let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
944        for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
945            let source = ValueSource::File {
946                path: path.clone(),
947                dot_path: dot_path.to_string(),
948                format: None,
949            };
950            let error = source.read().expect_err(dot_path);
951            assert!(error.message().contains(expected), "{dot_path}: {error}");
952        }
953        std::fs::remove_file(&path).expect("remove test config");
954    }
955
956    /// Parsed by the core, read by nobody: a host scheme is the host's to read.
957    #[test]
958    fn a_host_scheme_is_not_this_crates_to_read() {
959        let error = SourceSet::config()
960            .host_scheme("container", "container:NAME")
961            .parse("container:x")
962            .expect("parses")
963            .read()
964            .expect_err("this crate cannot read it");
965        assert_eq!(error.code(), "value_source_unreadable");
966    }
967
968    #[test]
969    fn an_unset_environment_source_names_what_it_tried() {
970        const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
971        let error = ValueSource::Env(ABSENT.to_string())
972            .read()
973            .expect_err("unset");
974        assert_eq!(error.code(), "value_source_unreadable");
975        assert!(error.message().contains(ABSENT), "{error}");
976    }
977
978    /// Including through `{:?}`, which is how a secret reaches a log nobody
979    /// meant to write.
980    #[test]
981    fn a_secret_string_cannot_be_printed_by_accident() {
982        let secret = SecretString::new("s3cret");
983        assert_eq!(format!("{secret}"), "***");
984        assert_eq!(format!("{secret:?}"), "***");
985        assert!(!format!("{secret:?} {secret}").contains("s3cret"));
986        assert_eq!(secret.expose_secret(), "s3cret");
987        // Held inside something else, it stays redacted there too.
988        #[derive(Debug)]
989        struct Config {
990            #[allow(dead_code)]
991            token_secret: SecretString,
992        }
993        let printed = format!(
994            "{:?}",
995            Config {
996                token_secret: secret
997            }
998        );
999        assert!(!printed.contains("s3cret"), "{printed}");
1000    }
1001
1002    #[test]
1003    fn a_stream_is_read_verbatim_and_capped() {
1004        assert_eq!(
1005            read_stream(" v \n".as_bytes(), "test").as_deref(),
1006            Ok(" v \n")
1007        );
1008        let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
1009        let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
1010        assert!(error.message().contains("exceeds"), "{error}");
1011    }
1012
1013    #[test]
1014    fn prompt_eof_is_not_an_empty_answer_and_only_one_line_ending_is_removed() {
1015        assert!(read_prompt_line(std::io::Cursor::new(b"")).is_err());
1016        for (input, expected) in [
1017            ("\n", ""),
1018            ("\r\n", ""),
1019            ("value\r\r\n", "value\r"),
1020            ("value\r", "value\r"),
1021        ] {
1022            assert_eq!(
1023                read_prompt_line(std::io::Cursor::new(input)).expect("answer"),
1024                expected
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
1031        let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
1032        assert_eq!(
1033            read_prompt_line(std::io::Cursor::new(exact))
1034                .expect("cap-sized line")
1035                .len(),
1036            MAX_STREAM_BYTES
1037        );
1038        let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
1039        let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
1040        assert!(error.message().contains("exceeds"), "{error}");
1041    }
1042
1043    #[cfg(unix)]
1044    #[test]
1045    fn an_fd_source_never_closes_the_callers_descriptor() {
1046        use std::io::{Read, Seek};
1047        use std::os::fd::AsRawFd;
1048
1049        let path = temp_config("fd", "txt", "descriptor value");
1050        let mut file = std::fs::File::open(&path).expect("open test descriptor");
1051        let source = ValueSource::Fd(file.as_raw_fd());
1052        assert_eq!(source.read().as_deref(), Ok("descriptor value"));
1053
1054        file.rewind()
1055            .expect("the caller still owns an open descriptor");
1056        let mut reread = String::new();
1057        file.read_to_string(&mut reread)
1058            .expect("read through caller-owned descriptor");
1059        assert_eq!(reread, "descriptor value");
1060        std::fs::remove_file(&path).expect("remove test config");
1061    }
1062}