1use crate::secret::REDACTED;
4use figment::error::Kind;
5use std::path::PathBuf;
6use thiserror::Error;
7
8#[derive(Debug, Error)]
13#[non_exhaustive]
14pub enum ConfigError {
15 #[error("{0}")]
17 Extract(String),
18 #[error("required configuration file not found: {}", .0.display())]
20 MissingFile(PathBuf),
21 #[error("invalid tracing filter: {0}")]
23 InvalidFilter(String),
24 #[error("tracing subscriber could not be initialized: {0}")]
26 Tracing(String),
27}
28
29impl From<figment::Error> for ConfigError {
30 fn from(err: figment::Error) -> Self {
31 let message = err
32 .into_iter()
33 .map(format_figment_error)
34 .collect::<Vec<_>>()
35 .join("; ");
36 Self::Extract(crate::redact::redact_text(&message))
38 }
39}
40
41fn format_figment_error(err: figment::Error) -> String {
42 let path = err.path.join(".");
43 let kind = format_kind(&err.kind, path_is_secret(&err.path));
44 if path.is_empty() {
45 kind
46 } else {
47 format!("{kind} at `{path}`")
48 }
49}
50
51fn path_is_secret(path: &[String]) -> bool {
52 path.iter().any(|segment| is_secret_key(segment))
53}
54
55pub fn is_secret_key(key: &str) -> bool {
68 let key = key.to_ascii_lowercase().replace('-', "_");
69 const CONTAINS: [&str; 11] = [
70 "secret",
71 "passw",
72 "pwd",
73 "signature",
74 "passphrase",
75 "token",
76 "credential",
77 "authorization",
78 "cookie",
79 "apikey",
80 "privatekey",
81 ];
82 CONTAINS.iter().any(|needle| key.contains(needle))
83 || key == "url"
84 || key.ends_with("_url")
85 || key.ends_with("_uri")
86 || key == "pass"
87 || key.ends_with("_pass")
88 || key == "dsn"
89 || key.ends_with("_dsn")
90 || key == "key"
91 || key.ends_with("_key")
92 || key == "sig"
93 || key.ends_with("_sig")
94}
95
96fn format_kind(kind: &Kind, redact: bool) -> String {
97 if !redact {
98 return kind.to_string();
99 }
100 match kind {
101 Kind::InvalidType(_, expected) => {
102 format!("invalid type: expected {expected}, value {REDACTED}")
103 }
104 Kind::InvalidValue(_, expected) => {
105 format!("invalid value: expected {expected}, value {REDACTED}")
106 }
107 Kind::Message(_) => format!("invalid value {REDACTED}"),
108 Kind::Unsupported(_) => format!("unsupported value {REDACTED}"),
109 Kind::UnsupportedKey(_, expected) => {
110 format!("unsupported key, expected {expected}")
111 }
112 Kind::InvalidLength(len, expected) => {
113 format!("invalid length {len}, expected {expected}")
114 }
115 Kind::UnknownVariant(_, expected) => {
116 format!("unknown variant, expected one of {expected:?}")
117 }
118 Kind::UnknownField(field, expected) => {
119 format!("unknown field `{field}`, expected one of {expected:?}")
120 }
121 Kind::MissingField(field) => format!("missing field `{field}`"),
122 Kind::DuplicateField(field) => format!("duplicate field `{field}`"),
123 Kind::ISizeOutOfRange(_) | Kind::USizeOutOfRange(_) => {
124 format!("integer out of range, value {REDACTED}")
125 }
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use figment::error::Actual;
133
134 #[test]
135 fn secret_paths_are_detected() {
136 assert!(path_is_secret(&[
137 "databases".into(),
138 "default".into(),
139 "url".into()
140 ]));
141 assert!(path_is_secret(&["secret_key".into()]));
142 assert!(path_is_secret(&["cache".into(), "url".into()]));
143 assert!(!path_is_secret(&["server".into(), "addr".into()]));
144 assert!(!path_is_secret(&["app".into(), "name".into()]));
145 }
146
147 #[test]
148 fn secret_key_variants_are_detected() {
149 for key in [
150 "database_url",
151 "DATABASE_URL",
152 "redis_uri",
153 "access_token",
154 "api_token",
155 "refresh_tokens",
156 "private-key",
157 "signing_key",
158 "passphrase",
159 "db_password",
160 "client_secret",
161 "secrets",
162 "sentry_dsn",
163 "pass",
164 "smtp_pass",
165 "pwd",
166 "db_pwd",
167 "sig",
168 "signature",
169 ] {
170 assert!(is_secret_key(key), "{key} should be secret");
171 }
172 for key in [
173 "addr",
174 "port",
175 "name",
176 "level",
177 "workers",
178 "bypass",
179 "passthrough",
180 ] {
181 assert!(!is_secret_key(key), "{key} should not be secret");
182 }
183 }
184
185 #[test]
186 fn redacted_out_of_range_omits_value() {
187 let rendered = format_kind(&Kind::ISizeOutOfRange(-987_654_321), true);
188 assert!(rendered.contains(REDACTED));
189 assert!(!rendered.contains("987654321"));
190 }
191
192 #[test]
193 fn redacted_kind_omits_string_payload() {
194 let kind = Kind::InvalidType(
195 Actual::Str("postgres://user:hunter2@db/app".into()),
196 "u32".into(),
197 );
198 let rendered = format_kind(&kind, true);
199 assert!(rendered.contains(REDACTED));
200 assert!(!rendered.contains("hunter2"));
201 assert!(!rendered.contains("postgres://"));
202 }
203}