Skip to main content

siderite_config/
error.rs

1//! Configuration errors. Messages never include secret values.
2
3use crate::secret::REDACTED;
4use figment::error::Kind;
5use std::path::PathBuf;
6use thiserror::Error;
7
8/// Failure while loading settings or installing tracing.
9///
10/// [`std::fmt::Display`] and [`std::fmt::Debug`] never include values of secret fields (`url`,
11/// `secret_key`, passwords, tokens, and similar keys).
12#[derive(Debug, Error)]
13#[non_exhaustive]
14pub enum ConfigError {
15    /// Figment could not merge or extract configuration.
16    #[error("{0}")]
17    Extract(String),
18    /// A file passed to [`crate::ConfigBuilder::file`] does not exist.
19    #[error("required configuration file not found: {}", .0.display())]
20    MissingFile(PathBuf),
21    /// `log.level` or `RUST_LOG` is not a valid tracing filter.
22    #[error("invalid tracing filter: {0}")]
23    InvalidFilter(String),
24    /// Installing the global tracing subscriber failed.
25    #[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        // Parse errors can quote the file; hide any credential in them.
37        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
55/// Whether a config key may hold a secret. Errs toward redacting: a false
56/// positive only hides a value in an error message, a false negative leaks it.
57///
58/// Configuration errors and the CLI reports share this rule, so it may
59/// only ever widen: removing a key pattern would start printing values
60/// that earlier releases hid.
61///
62/// Args:
63///   key: A configuration key, query parameter or field name.
64///
65/// Returns:
66///   `true` when values stored under `key` must be redacted.
67pub 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}