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