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(unix)]
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 disabled = set_terminal_echo(&tty, false)
291        .map_err(|error| SourceError::unreadable(format!("disable terminal echo: {error}")))?;
292    if !disabled {
293        return Err(SourceError::unreadable("disabling terminal echo failed"));
294    }
295    let _echo = EchoGuard { tty: restore_tty };
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#[cfg(not(unix))]
306fn read_prompt() -> Result<String> {
307    Err(SourceError::unreadable(
308        "the `prompt` source is unsupported on this platform",
309    ))
310}
311
312/// Restores terminal echo however the read ended, including on a panic.
313#[cfg(unix)]
314fn set_terminal_echo(tty: &std::fs::File, enabled: bool) -> std::io::Result<bool> {
315    use std::process::Stdio;
316
317    let input = tty.try_clone()?;
318    std::process::Command::new("stty")
319        .arg(if enabled { "echo" } else { "-echo" })
320        // `stty` operates on stdin. Point it at the controlling terminal that
321        // carries the prompt, not the process stdin (which may be a pipe).
322        .stdin(Stdio::from(input))
323        // A library helper must not let a child write unstructured diagnostics
324        // around the caller's own output protocol.
325        .stdout(Stdio::null())
326        .stderr(Stdio::null())
327        .status()
328        .map(|status| status.success())
329}
330
331#[cfg(any(unix, test))]
332fn read_prompt_line<R: std::io::BufRead>(reader: R) -> Result<String> {
333    use std::io::BufRead;
334
335    // Leave room for CRLF beyond the value cap. `Take` bounds `read_line`'s
336    // allocation even when the terminal never sends a newline.
337    let mut limited = reader.take((MAX_STREAM_BYTES + 2) as u64);
338    let mut value = String::new();
339    limited
340        .read_line(&mut value)
341        .map_err(|error| SourceError::unreadable(format!("read from the terminal: {error}")))?;
342    let value = value.trim_end_matches(['\r', '\n']);
343    if value.len() > MAX_STREAM_BYTES {
344        return Err(SourceError::unreadable(format!(
345            "prompt exceeds {MAX_STREAM_BYTES} bytes"
346        )));
347    }
348    Ok(value.to_string())
349}
350
351#[cfg(unix)]
352struct EchoGuard {
353    tty: std::fs::File,
354}
355
356#[cfg(unix)]
357impl Drop for EchoGuard {
358    fn drop(&mut self) {
359        let _ = set_terminal_echo(&self.tty, true);
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::cli_spec::SourceSet;
367    use std::path::PathBuf;
368
369    fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
370        let path = std::env::temp_dir().join(format!(
371            "afdata-value-source-{name}-{}.{extension}",
372            std::process::id()
373        ));
374        std::fs::write(&path, content).expect("write test config");
375        path
376    }
377
378    /// Every format this build can parse. The list is assembled by feature
379    /// because `scripts/test.sh unit` also runs `--no-default-features
380    /// --features cli`, where a `.toml` file is not a format this binary knows
381    /// — and a source that answers "no toml support" there is correct
382    /// behavior, not a failure to assert against.
383    fn readable_formats() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
384        // In the `cli`-only test build none of the conditional pushes compile,
385        // while every normal build needs the mutability.
386        #[allow(unused_mut)]
387        let mut cases: Vec<(&str, &str, &str, &str)> =
388            vec![("json", "json", r#"{"a":{"b":" v "}}"#, "a.b")];
389        #[cfg(feature = "toml")]
390        cases.push(("toml", "toml", "[a]\nb = ' v '\n", "a.b"));
391        #[cfg(feature = "yaml")]
392        cases.push(("yaml", "yaml", "a:\n  b: ' v '\n", "a.b"));
393        #[cfg(feature = "dotenv")]
394        cases.push(("dotenv", "env", "A_B=' v '\n", "A_B"));
395        cases
396    }
397
398    #[test]
399    fn a_file_source_reads_one_address_out_of_every_format() {
400        for (name, extension, content, dot_path) in readable_formats() {
401            let path = temp_config(name, extension, content);
402            let source = ValueSource::File {
403                path: path.clone(),
404                dot_path: dot_path.to_string(),
405                format: None,
406            };
407            let read = source.read();
408            let secret = source.read_secret();
409            std::fs::remove_file(&path).expect("remove test config");
410            // Verbatim: surrounding space is part of the value.
411            assert_eq!(read.as_deref(), Ok(" v "), "{name}");
412            assert_eq!(
413                secret.expect("secret read").expose_secret(),
414                " v ",
415                "{name}"
416            );
417        }
418    }
419
420    #[test]
421    fn an_empty_string_is_still_a_value() {
422        let path = temp_config("empty", "json", r#"{"empty":""}"#);
423        let source = ValueSource::File {
424            path: path.clone(),
425            dot_path: "empty".to_string(),
426            format: None,
427        };
428        assert_eq!(source.read().as_deref(), Ok(""));
429        let secret = source.read_secret().expect("empty secret remains explicit");
430        assert!(secret.is_empty());
431        std::fs::remove_file(&path).expect("remove test config");
432    }
433
434    /// A filename does not always say what a file is. `phoenix.conf`,
435    /// `/etc/*.conf`, an extensionless credential file — refusing those sent
436    /// callers back to `grep | cut`, which is the thing a source replaces.
437    ///
438    /// INI-gated: without that parser this build genuinely cannot read the
439    /// file, and refusing to is the correct answer rather than a failure.
440    #[cfg(feature = "ini")]
441    #[test]
442    fn a_named_format_reads_a_file_whose_name_cannot_say_what_it_is() {
443        let path = temp_config("named", "conf", "http-password=abc123\nauto-liquidity=2m\n");
444        let named = ValueSource::File {
445            path: path.clone(),
446            dot_path: "http-password".to_string(),
447            format: Some("ini".to_string()),
448        };
449        let unnamed = ValueSource::File {
450            path: path.clone(),
451            dot_path: "http-password".to_string(),
452            format: None,
453        };
454        let bad_name = ValueSource::File {
455            path: path.clone(),
456            dot_path: "http-password".to_string(),
457            format: Some("nonsense".to_string()),
458        };
459        let read = named.read_secret();
460        let without = unnamed.read();
461        let bad = bad_name.read();
462        std::fs::remove_file(&path).expect("remove test config");
463
464        assert_eq!(read.expect("named format").expose_secret(), "abc123");
465        // Without the name, the error says how to supply one rather than only
466        // that it could not guess.
467        let without = without.expect_err("no extension to detect");
468        assert!(without.message().contains("file+FORMAT:"), "{without}");
469        let bad = bad.expect_err("unknown format");
470        assert!(
471            bad.message().contains("not a format this build reads"),
472            "{bad}"
473        );
474    }
475
476    /// The policy difference, in one place: an ordinary value may be a port; a
477    /// secret may not.
478    #[test]
479    fn a_non_string_scalar_is_a_value_but_never_a_secret() {
480        let path = temp_config("scalar", "json", r#"{"port":5432,"on":true}"#);
481        let port = ValueSource::File {
482            path: path.clone(),
483            dot_path: "port".to_string(),
484            format: None,
485        };
486        assert_eq!(port.read().as_deref(), Ok("5432"));
487        let error = port.read_secret().expect_err("a secret must be a string");
488        assert!(error.message().contains("must be a string"), "{error}");
489
490        let on = ValueSource::File {
491            path: path.clone(),
492            dot_path: "on".to_string(),
493            format: None,
494        };
495        assert_eq!(on.read().as_deref(), Ok("true"));
496        std::fs::remove_file(&path).expect("remove test config");
497    }
498
499    /// The other policy difference: a plain read explains itself, a secret read
500    /// refuses to quote anything it saw.
501    #[test]
502    fn a_secret_read_never_echoes_what_it_read() {
503        let canary = "AFDATA_SOURCE_CANARY";
504        // JSON so the assertion holds in every feature combination the gate
505        // builds; what is under test is the policy, not the parser.
506        let path = temp_config("malformed", "json", &format!(r#"{{"a": [ {canary}"#));
507        let source = ValueSource::File {
508            path: path.clone(),
509            dot_path: "a".to_string(),
510            format: None,
511        };
512        let plain = source.read().expect_err("malformed");
513        let secret = source.read_secret().expect_err("malformed");
514        std::fs::remove_file(&path).expect("remove test config");
515        assert!(
516            !secret.message().contains(canary),
517            "secret read leaked: {secret}"
518        );
519        // The plain read is allowed to be helpful; that is the difference.
520        assert!(plain.message().contains("cannot read"), "{plain}");
521    }
522
523    #[test]
524    fn a_collection_is_not_a_value() {
525        let path = temp_config("collection", "json", r#"{"a":{"b":1},"c":[1],"d":null}"#);
526        for (dot_path, expected) in [("a", "an object"), ("c", "an array"), ("d", "null")] {
527            let source = ValueSource::File {
528                path: path.clone(),
529                dot_path: dot_path.to_string(),
530                format: None,
531            };
532            let error = source.read().expect_err(dot_path);
533            assert!(error.message().contains(expected), "{dot_path}: {error}");
534        }
535        std::fs::remove_file(&path).expect("remove test config");
536    }
537
538    /// Parsed by the core, read by nobody: a host scheme is the host's to read.
539    #[test]
540    fn a_host_scheme_is_not_this_crates_to_read() {
541        let error = SourceSet::config()
542            .host_scheme("container", "container:NAME")
543            .parse("container:x")
544            .expect("parses")
545            .read()
546            .expect_err("this crate cannot read it");
547        assert_eq!(error.code(), "value_source_unreadable");
548    }
549
550    #[test]
551    fn an_unset_environment_source_names_what_it_tried() {
552        const ABSENT: &str = "AFDATA_TEST_ABSENT_VALUE_SOURCE";
553        let error = ValueSource::Env(ABSENT.to_string())
554            .read()
555            .expect_err("unset");
556        assert_eq!(error.code(), "value_source_unreadable");
557        assert!(error.message().contains(ABSENT), "{error}");
558    }
559
560    /// Including through `{:?}`, which is how a secret reaches a log nobody
561    /// meant to write.
562    #[test]
563    fn a_secret_string_cannot_be_printed_by_accident() {
564        let secret = SecretString::new("s3cret");
565        assert_eq!(format!("{secret}"), "***");
566        assert_eq!(format!("{secret:?}"), "***");
567        assert!(!format!("{secret:?} {secret}").contains("s3cret"));
568        assert_eq!(secret.expose_secret(), "s3cret");
569        // Held inside something else, it stays redacted there too.
570        #[derive(Debug)]
571        struct Config {
572            #[allow(dead_code)]
573            token_secret: SecretString,
574        }
575        let printed = format!(
576            "{:?}",
577            Config {
578                token_secret: secret
579            }
580        );
581        assert!(!printed.contains("s3cret"), "{printed}");
582    }
583
584    #[test]
585    fn a_stream_is_read_verbatim_and_capped() {
586        assert_eq!(
587            read_stream(" v \n".as_bytes(), "test").as_deref(),
588            Ok(" v \n")
589        );
590        let oversized = vec![b'x'; MAX_STREAM_BYTES + 1];
591        let error = read_stream(oversized.as_slice(), "test").expect_err("over the cap");
592        assert!(error.message().contains("exceeds"), "{error}");
593    }
594
595    #[test]
596    fn a_prompt_line_is_bounded_before_allocation_can_grow_without_limit() {
597        let exact = format!("{}\r\n", "x".repeat(MAX_STREAM_BYTES));
598        assert_eq!(
599            read_prompt_line(std::io::Cursor::new(exact))
600                .expect("cap-sized line")
601                .len(),
602            MAX_STREAM_BYTES
603        );
604        let oversized = format!("{}\n", "x".repeat(MAX_STREAM_BYTES + 1));
605        let error = read_prompt_line(std::io::Cursor::new(oversized)).expect_err("over the cap");
606        assert!(error.message().contains("exceeds"), "{error}");
607    }
608
609    #[cfg(unix)]
610    #[test]
611    fn an_fd_source_never_closes_the_callers_descriptor() {
612        use std::io::{Read, Seek};
613        use std::os::fd::AsRawFd;
614
615        let path = temp_config("fd", "txt", "descriptor value");
616        let mut file = std::fs::File::open(&path).expect("open test descriptor");
617        let source = ValueSource::Fd(file.as_raw_fd());
618        assert_eq!(source.read().as_deref(), Ok("descriptor value"));
619
620        file.rewind()
621            .expect("the caller still owns an open descriptor");
622        let mut reread = String::new();
623        file.read_to_string(&mut reread)
624            .expect("read through caller-owned descriptor");
625        assert_eq!(reread, "descriptor value");
626        std::fs::remove_file(&path).expect("remove test config");
627    }
628}