Skip to main content

fedimint_core/util/
secret_file.rs

1//! Explicit, bounded file/stdin inputs for command-line secrets.
2
3use std::io::Read;
4use std::path::Path;
5
6/// A secret input failure without secret-bearing diagnostic context.
7#[derive(Debug, thiserror::Error)]
8pub enum SecretInputError {
9    /// The input file could not be opened.
10    #[error("Could not open secret input file")]
11    Open,
12    /// Reading the input failed.
13    #[error("Could not read secret input")]
14    Read,
15    /// The configured size limit cannot be represented.
16    #[error("Invalid secret input size limit")]
17    InvalidLimit,
18    /// The input exceeded its configured limit.
19    #[error("Secret input exceeds size limit")]
20    TooLarge,
21    /// The input was not UTF-8.
22    #[error("Secret input is not valid UTF-8")]
23    InvalidUtf8,
24    /// More than one input would consume standard input.
25    #[error("Only one secret input may read standard input")]
26    MultipleStdin,
27}
28
29/// Read UTF-8 secret material from a file, or standard input when `path` is
30/// `-`.
31///
32/// The limit includes the optional final line ending. Exactly one final LF,
33/// optionally preceded by CR, is removed; all other whitespace is preserved.
34/// Errors deliberately omit paths, contents, and underlying error messages.
35///
36/// Callers must check all input sources for conflicts before reading, including
37/// [`ensure_single_stdin`]. Noninteractive daemons must reject `-` themselves.
38/// Parsing the returned value must not expose its contents in diagnostics.
39pub fn read_secret_file(path: &Path, max_bytes: usize) -> Result<String, SecretInputError> {
40    if path == Path::new("-") {
41        read_secret(std::io::stdin().lock(), max_bytes)
42    } else {
43        let file = std::fs::File::open(path).map_err(|_| SecretInputError::Open)?;
44        read_secret(file, max_bytes)
45    }
46}
47
48/// Reject multiple consumers of standard input before any secret is read.
49pub fn ensure_single_stdin<'a>(
50    paths: impl IntoIterator<Item = &'a Path>,
51) -> Result<(), SecretInputError> {
52    if paths
53        .into_iter()
54        .filter(|path| *path == Path::new("-"))
55        .take(2)
56        .count()
57        > 1
58    {
59        return Err(SecretInputError::MultipleStdin);
60    }
61    Ok(())
62}
63
64fn read_secret(reader: impl Read, max_bytes: usize) -> Result<String, SecretInputError> {
65    let limit = u64::try_from(max_bytes)
66        .ok()
67        .and_then(|limit| limit.checked_add(1))
68        .ok_or(SecretInputError::InvalidLimit)?;
69    let mut bytes = Vec::new();
70    reader
71        .take(limit)
72        .read_to_end(&mut bytes)
73        .map_err(|_| SecretInputError::Read)?;
74    if bytes.len() > max_bytes {
75        return Err(SecretInputError::TooLarge);
76    }
77    let mut value = String::from_utf8(bytes).map_err(|_| SecretInputError::InvalidUtf8)?;
78    if value.ends_with('\n') {
79        value.pop();
80        if value.ends_with('\r') {
81            value.pop();
82        }
83    }
84    Ok(value)
85}
86
87#[cfg(test)]
88mod tests;