Skip to main content

wekan_cli/credentials/
source.rs

1use std::{future::Future, path::PathBuf, pin::Pin};
2
3use secrecy::SecretString;
4use tokio::io::{AsyncRead, AsyncReadExt};
5
6use crate::{
7    error::{AppError, ErrorCode},
8    exit_code::StableExitCode,
9};
10
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub enum SecretSource {
13    File(PathBuf),
14    Stdin,
15}
16
17pub type SecretInputFuture<'a> =
18    Pin<Box<dyn Future<Output = Result<SecretString, AppError>> + Send + 'a>>;
19
20pub(super) async fn read_storage_secret(
21    source: SecretSource,
22    limit: usize,
23) -> Result<SecretString, AppError> {
24    match source {
25        SecretSource::Stdin => read_bounded(tokio::io::stdin(), limit).await,
26        SecretSource::File(path) => {
27            // Reject FIFOs before opening, then verify the opened descriptor too.
28            if !tokio::fs::metadata(&path)
29                .await
30                .map_err(io_error)?
31                .is_file()
32            {
33                return Err(not_regular_file());
34            }
35            let file = tokio::fs::File::open(&path).await.map_err(io_error)?;
36            if !file.metadata().await.map_err(io_error)?.is_file() {
37                return Err(not_regular_file());
38            }
39            read_bounded(file, limit).await
40        }
41    }
42}
43
44async fn read_bounded(
45    reader: impl AsyncRead + Unpin,
46    limit: usize,
47) -> Result<SecretString, AppError> {
48    let mut bytes = Vec::new();
49    reader
50        .take((limit as u64).saturating_add(1))
51        .read_to_end(&mut bytes)
52        .await
53        .map_err(io_error)?;
54    if bytes.len() > limit {
55        return Err(AppError::invalid_input(format!(
56            "storage credential input exceeds the {limit}-byte limit; no request was sent"
57        )));
58    }
59    let mut value = String::from_utf8(bytes).map_err(|_| {
60        AppError::invalid_input("storage credential input must be UTF-8; no request was sent")
61    })?;
62    if value.ends_with('\n') {
63        value.pop();
64        if value.ends_with('\r') {
65            value.pop();
66        }
67    }
68    Ok(SecretString::from(value))
69}
70
71fn not_regular_file() -> AppError {
72    AppError::invalid_input(
73        "storage credential file input must be a regular file; no request was sent",
74    )
75}
76
77fn io_error(_: std::io::Error) -> AppError {
78    AppError::new(
79        ErrorCode::FileIoError,
80        "could not read storage credential input; no request was sent",
81        StableExitCode::Transport,
82    )
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use secrecy::ExposeSecret;
89
90    #[tokio::test]
91    async fn bounded_secret_input_preserves_whitespace_and_removes_only_one_line_ending() {
92        for (input, expected) in [
93            ("", ""),
94            (" secret \r\n", " secret "),
95            ("secret\n\n", "secret\n"),
96            ("secret\r", "secret\r"),
97            ("secret", "secret"),
98        ] {
99            let secret = read_bounded(input.as_bytes(), input.len()).await.unwrap();
100            assert_eq!(secret.expose_secret(), expected);
101        }
102        for input in [&b"oversized"[..], &b"\xff"[..]] {
103            let error = read_bounded(input, 1).await.unwrap_err();
104            assert_eq!(error.code(), ErrorCode::InvalidInput);
105            assert!(!error.message().contains("oversized"));
106        }
107    }
108
109    #[tokio::test]
110    async fn credential_files_are_bounded_regular_files_with_value_free_errors() {
111        let dir = tempfile::tempdir().unwrap();
112        let path = dir.path().join("secret.txt");
113        std::fs::write(&path, "secret\r\n").unwrap();
114        let secret = read_storage_secret(SecretSource::File(path.clone()), 8)
115            .await
116            .unwrap();
117        assert_eq!(secret.expose_secret(), "secret");
118        let oversized = read_storage_secret(SecretSource::File(path), 7)
119            .await
120            .unwrap_err();
121        assert_eq!(oversized.code(), ErrorCode::InvalidInput);
122        let directory = read_storage_secret(SecretSource::File(dir.path().into()), 8)
123            .await
124            .unwrap_err();
125        assert_eq!(directory.code(), ErrorCode::InvalidInput);
126        let missing = read_storage_secret(SecretSource::File(dir.path().join("missing-secret")), 8)
127            .await
128            .unwrap_err();
129        assert_eq!(missing.code(), ErrorCode::FileIoError);
130        assert!(!missing.message().contains("missing-secret"));
131    }
132}