wekan_cli/credentials/
source.rs1use 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 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}