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    limited
551        .read_line(&mut value)
552        .map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
553    let value = value.trim_end_matches(['\r', '\n']);
554    if value.len() > MAX_STREAM_BYTES {
555        return Err(SourceError::unreadable(format!(
556            "prompt exceeds {MAX_STREAM_BYTES} bytes"
557        )));
558    }
559    Ok(value.to_string())
560}
561
562/// Puts terminal echo back however the read ended, including on a panic.
563#[cfg(all(unix, feature = "libc"))]
564struct EchoGuard {
565    tty: std::fs::File,
566    original: libc::termios,
567}
568
569#[cfg(all(unix, feature = "libc"))]
570impl Drop for EchoGuard {
571    fn drop(&mut self) {
572        use std::io::Write as _;
573
574        if set_terminal_attributes(&self.tty, &self.original).is_ok() {
575            return;
576        }
577        // The terminal was borrowed and must be handed back. Failing quietly
578        // leaves the person typing into a shell that shows nothing and looks
579        // broken, with no clue why. Said on the terminal itself, which is where
580        // the damage is, rather than through the caller's structured output.
581        let _ = writeln!(
582            &mut self.tty,
583            "\nwarning: could not restore terminal echo; run `stty echo` to fix this terminal"
584        );
585    }
586}
587
588/// Puts console echo back however the read ended, including on a panic.
589///
590/// Two handles rather than the unix one: `CONIN$` carries the mode to restore
591/// and cannot be written to, `CONOUT$` is where anything a person should read
592/// has to go.
593#[cfg(windows)]
594struct EchoGuard {
595    console: std::fs::File,
596    output: std::fs::File,
597    original: u32,
598}
599
600#[cfg(windows)]
601impl Drop for EchoGuard {
602    fn drop(&mut self) {
603        use std::io::Write as _;
604
605        if set_console_mode(&self.console, self.original).is_ok() {
606            return;
607        }
608        // As on unix: the console was borrowed and must be handed back, and a
609        // console that shows nothing while a person types looks broken rather
610        // than quiet. Windows has no `stty echo` to name — the mode belongs to
611        // this console's input buffer and goes when the console does.
612        let _ = writeln!(
613            &mut self.output,
614            "\nwarning: could not restore console echo; close this console window to get it back"
615        );
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[cfg(all(unix, feature = "libc"))]
624    #[test]
625    fn terminal_echo_control_goes_to_the_terminal_not_to_a_program() {
626        use std::io::Write as _;
627
628        // A regular file is not a terminal. `tcsetattr` answers ENOTTY, which
629        // is an answer only the terminal API can give: a spawned `stty`
630        // resolved through `PATH` would report a child's exit status instead,
631        // and whatever `PATH` happened to name would already have run.
632        let path = std::env::temp_dir().join(format!(
633            "afdata_not_a_tty_{}_{}",
634            std::process::id(),
635            std::time::SystemTime::now()
636                .duration_since(std::time::UNIX_EPOCH)
637                .map(|d| d.as_nanos())
638                .unwrap_or(0)
639        ));
640        let mut file = match std::fs::File::create(&path) {
641            Ok(file) => file,
642            Err(_) => return,
643        };
644        let _ = file.write_all(b"not a terminal");
645
646        let error = disable_terminal_echo(&file)
647            .err()
648            .map(|error| error.raw_os_error());
649
650        let _ = std::fs::remove_file(&path);
651        assert_eq!(
652            error,
653            Some(Some(libc::ENOTTY)),
654            "echo control must fail as a terminal call, not as a missing program"
655        );
656    }
657
658    #[cfg(all(unix, feature = "libc"))]
659    #[test]
660    fn a_failed_restore_is_reported_rather_than_swallowed() {
661        // The guard writes its warning to the terminal it was handed. Dropping
662        // one over a non-terminal exercises the failure path end to end: it
663        // must neither panic nor hang, and the file it wrote to shows that the
664        // failure was announced instead of ignored.
665        let path = std::env::temp_dir().join(format!(
666            "afdata_echo_guard_{}_{}",
667            std::process::id(),
668            std::time::SystemTime::now()
669                .duration_since(std::time::UNIX_EPOCH)
670                .map(|d| d.as_nanos())
671                .unwrap_or(0)
672        ));
673        let Ok(file) = std::fs::File::options()
674            .create(true)
675            .truncate(true)
676            .read(true)
677            .write(true)
678            .open(&path)
679        else {
680            return;
681        };
682        // SAFETY: a zeroed `termios` is a valid value to hand back to
683        // `tcsetattr`; this descriptor is not a terminal, so it never reaches
684        // one.
685        let original = unsafe { std::mem::zeroed::<libc::termios>() };
686        drop(EchoGuard {
687            tty: file,
688            original,
689        });
690
691        let announced = std::fs::read_to_string(&path).unwrap_or_default();
692        let _ = std::fs::remove_file(&path);
693        assert!(
694            announced.contains("could not restore terminal echo"),
695            "a terminal left without echo must say so: {announced:?}"
696        );
697    }
698
699    /// The Windows counterpart of the two tests above, asserting the same two
700    /// properties against the console API.
701    #[cfg(windows)]
702    mod windows_echo {
703        use super::super::{EchoGuard, disable_console_echo};
704
705        /// `ERROR_INVALID_HANDLE`. A regular file is not a console, and this is
706        /// the console API saying so — a spawned helper resolved through `PATH`
707        /// would report a child's exit status instead, and whatever `PATH`
708        /// happened to name would already have run.
709        const ERROR_INVALID_HANDLE: i32 = 6;
710
711        fn scratch_file(label: &str) -> Option<(std::path::PathBuf, std::fs::File)> {
712            let path = std::env::temp_dir().join(format!(
713                "afdata_{label}_{}_{}",
714                std::process::id(),
715                std::time::SystemTime::now()
716                    .duration_since(std::time::UNIX_EPOCH)
717                    .map(|d| d.as_nanos())
718                    .unwrap_or(0)
719            ));
720            let file = std::fs::File::options()
721                .create(true)
722                .truncate(true)
723                .read(true)
724                .write(true)
725                .open(&path)
726                .ok()?;
727            Some((path, file))
728        }
729
730        #[test]
731        fn echo_control_goes_to_the_console_not_to_a_program() {
732            let Some((path, file)) = scratch_file("not_a_console") else {
733                return;
734            };
735
736            let error = disable_console_echo(&file)
737                .err()
738                .and_then(|error| error.raw_os_error());
739
740            drop(file);
741            let _ = std::fs::remove_file(&path);
742            assert_eq!(
743                error,
744                Some(ERROR_INVALID_HANDLE),
745                "echo control must fail as a console call, not as a missing program"
746            );
747        }
748
749        #[test]
750        fn a_failed_restore_is_reported_rather_than_swallowed() {
751            let Some((path, file)) = scratch_file("console_echo_guard") else {
752                return;
753            };
754            let Ok(output) = file.try_clone() else {
755                let _ = std::fs::remove_file(&path);
756                return;
757            };
758
759            // Restoring over a non-console fails, which is the path under test:
760            // it must neither panic nor hang, and must leave the warning behind.
761            drop(EchoGuard {
762                console: file,
763                output,
764                original: 0,
765            });
766
767            let announced = std::fs::read_to_string(&path).unwrap_or_default();
768            let _ = std::fs::remove_file(&path);
769            assert!(
770                announced.contains("could not restore console echo"),
771                "a console left without echo must say so: {announced:?}"
772            );
773        }
774    }
775
776    use crate::cli_spec::SourceSet;
777    use std::path::PathBuf;
778
779    fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
780        let path = std::env::temp_dir().join(format!(
781            "afdata-value-source-{name}-{}.{extension}",
782            std::process::id()
783        ));
784        std::fs::write(&path, content).expect("write test config");
785        path
786    }
787
788    /// Every format this build can parse. The list is assembled by feature
789    /// because `scripts/test.sh unit` also runs `--no-default-features
790    /// --features cli`, where a `.toml` file is not a format this binary knows
791    /// — and a source that answers "no toml support" there is correct
792    /// behavior, not a failure to assert against.
793    fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
794        // In the `cli`-only test build none of the conditional pushes compile,
795        // while every normal build needs the mutability.
796        #[allow(unused_mut)]
797        let mut cases: Vec<(&str, &str, &str, &str)> =
798            vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
799        #[cfg(feature = "toml")]
800        cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
801        #[cfg(feature = "yaml")]
802        cases.push(("yaml", "yaml", "a:\n  b: ' v '\n", "a.b"));
803        #[cfg(feature = "dotenv")]
804        cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
805        cases
806    }
807
808    #[test]
809    fn a_file_source_reads_one_address_out_of_every_format() {
810        for (name, extension, content, dot_path) in readable_formats() {
811            let path = temp_config(name, extension, content);
812            let source = ValueSource::File {
813                path: path.clone(),
814                dot_path: dot_path.to_string(),
815                format: None,
816            };
817            let read = source.read();
818            let secret = source.read_secret();
819            std::fs::remove_file(&path).expect("remove test config");
820            // Verbatim: surrounding space is part of the value.
821            assert_eq!(read.as_deref(), Ok(" v "), "{name}");
822            assert_eq!(
823                secret.expect("secret read").expose_secret(),
824                " v ",
825                "{name}"
826            );
827        }
828    }
829
830    #[test]
831    fn an_empty_string_is_still_a_value() {
832        let path = temp_config("empty", "json", r#"{"empty":""}"#);
833        let source = ValueSource::File {
834            path: path.clone(),
835            dot_path: "empty".to_string(),
836            format: None,
837        };
838        assert_eq!(source.read().as_deref(), Ok(""));
839        let secret = source.read_secret().expect("empty secret remains explicit");
840        assert!(secret.is_empty());
841        std::fs::remove_file(&path).expect("remove test config");
842    }
843
844    /// A filename does not always say what a file is. `phoenix.conf`,
845    /// `/etc/*.conf`, an extensionless credential file — refusing those sent
846    /// callers back to `grep | cut`, which is the thing a source replaces.
847    ///
848    /// INI-gated: without that parser this build genuinely cannot read the
849    /// file, and refusing to is the correct answer rather than a failure.
850    #[cfg(feature = "ini")]
851    #[test]
852    fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
853        let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
854        let named = ValueSource::File {
855            path: path.clone(),
856            dot_path: "http-password".to_string(),
857            format: Some("ini".to_string()),
858        };
859        let unnamed = ValueSource::File {
860            path: path.clone(),
861            dot_path: "http-password".to_string(),
862            format: None,
863        };
864        let bad_name = ValueSource::File {
865            path: path.clone(),
866            dot_path: "http-password".to_string(),
867            format: Some("nonsense".to_string()),
868        };
869        let read = named.read_secret();
870        let without = unnamed.read();
871        let bad = bad_name.read();
872        std::fs::remove_file(&path).expect("remove test config");
873
874        assert_eq!(read.expect("named format").expose_secret(), "abc123");
875        // Without the name, the error says how to supply one rather than only
876        // that it could not guess.
877        let without = without.expect_err("no extension to detect");
878        assert!(without.message().contains("file+FORMAT:"), "{without}");
879        let bad = bad.expect_err("unknown format");
880        assert!(
881            bad.message().contains("not a format this build reads"),
882            "{bad}"
883        );
884    }
885
886    /// The policy difference, in one place: an ordinary value may be a port; a
887    /// secret may not.
888    #[test]
889    fn a_non_string_scalar_is_a_value_but_never_a_secret() {
890        let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
891        let port = ValueSource::File {
892            path: path.clone(),
893            dot_path: "port".to_string(),
894            format: None,
895        };
896        assert_eq!(port.read().as_deref(), Ok("5432"));
897        let error = port.read_secret().expect_err("a secret must be a string");
898        assert!(error.message().contains("must be a string"), "{error}");
899
900        let on = ValueSource::File {
901            path: path.clone(),
902            dot_path: "on".to_string(),
903            format: None,
904        };
905        assert_eq!(on.read().as_deref(), Ok("true"));
906        std::fs::remove_file(&path).expect("remove test config");
907    }
908
909    /// The other policy difference: a plain read explains itself, a secret read
910    /// refuses to quote anything it saw.
911    #[test]
912    fn a_secret_read_never_echoes_what_it_read() {
913        let canary = "AFDATA_SOURCE_CANARY";
914        // JSON so the assertion holds in every feature combination the gate
915        // builds; what is under test is the policy, not the parser.
916        let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
917        let source = ValueSource::File {
918            path: path.clone(),
919            dot_path: "a".to_string(),
920            format: None,
921        };
922        let plain = source.read().expect_err("malformed");
923        let secret = source.read_secret().expect_err("malformed");
924        std::fs::remove_file(&path).expect("remove test config");
925        assert!(
926            !secret.message().contains(canary),
927            "secret read leaked: {secret}"
928        );
929        // The plain read is allowed to be helpful; that is the difference.
930        assert!(plain.message().contains("cannot read"), "{plain}");
931    }
932
933    #[test]
934    fn a_collection_is_not_a_value() {
935        let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
936        for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
937            let source = ValueSource::File {
938                path: path.clone(),
939                dot_path: dot_path.to_string(),
940                format: None,
941            };
942            let error = source.read().expect_err(dot_path);
943            assert!(error.message().contains(expected), "{dot_path}: {error}");
944        }
945        std::fs::remove_file(&path).expect("remove test config");
946    }
947
948    /// Parsed by the core, read by nobody: a host scheme is the host's to read.
949    #[test]
950    fn a_host_scheme_is_not_this_crates_to_read() {
951        let error = SourceSet::config()
952            .host_scheme("container", "container:NAME")
953            .parse("container:x")
954            .expect("parses")
955            .read()
956            .expect_err("this crate cannot read it");
957        assert_eq!(error.code(), "value_source_unreadable");
958    }
959
960    #[test]
961    fn an_unset_environment_source_names_what_it_tried() {
962        const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
963        let error = ValueSource::Env(ABSENT.to_string())
964            .read()
965            .expect_err("unset");
966        assert_eq!(error.code(), "value_source_unreadable");
967        assert!(error.message().contains(ABSENT), "{error}");
968    }
969
970    /// Including through `{:?}`, which is how a secret reaches a log nobody
971    /// meant to write.
972    #[test]
973    fn a_secret_string_cannot_be_printed_by_accident() {
974        let secret = SecretString::new("s3cret");
975        assert_eq!(format!("{secret}"), "***");
976        assert_eq!(format!("{secret:?}"), "***");
977        assert!(!format!("{secret:?} {secret}").contains("s3cret"));
978        assert_eq!(secret.expose_secret(), "s3cret");
979        // Held inside something else, it stays redacted there too.
980        #[derive(Debug)]
981        struct Config {
982            #[allow(dead_code)]
983            token_secret: SecretString,
984        }
985        let printed = format!(
986            "{:?}",
987            Config {
988                token_secret: secret
989            }
990        );
991        assert!(!printed.contains("s3cret"), "{printed}");
992    }
993
994    #[test]
995    fn a_stream_is_read_verbatim_and_capped() {
996        assert_eq!(
997            read_stream(" v \n".as_bytes(), "test").as_deref(),
998            Ok(" v \n")
999        );
1000        let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
1001        let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
1002        assert!(error.message().contains("exceeds"), "{error}");
1003    }
1004
1005    #[test]
1006    fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
1007        let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
1008        assert_eq!(
1009            read_prompt_line(std::io::Cursor::new(exact))
1010                .expect("cap-sized line")
1011                .len(),
1012            MAX_STREAM_BYTES
1013        );
1014        let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
1015        let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
1016        assert!(error.message().contains("exceeds"), "{error}");
1017    }
1018
1019    #[cfg(unix)]
1020    #[test]
1021    fn an_fd_source_never_closes_the_callers_descriptor() {
1022        use std::io::{Read, Seek};
1023        use std::os::fd::AsRawFd;
1024
1025        let path = temp_config("fd", "txt", "descriptor value");
1026        let mut file = std::fs::File::open(&path).expect("open test descriptor");
1027        let source = ValueSource::Fd(file.as_raw_fd());
1028        assert_eq!(source.read().as_deref(), Ok("descriptor value"));
1029
1030        file.rewind()
1031            .expect("the caller still owns an open descriptor");
1032        let mut reread = String::new();
1033        file.read_to_string(&mut reread)
1034            .expect("read through caller-owned descriptor");
1035        assert_eq!(reread, "descriptor value");
1036        std::fs::remove_file(&path).expect("remove test config");
1037    }
1038}