From 66c74c1df177227064f469b3465fdf720798ea02 Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:18:31 +0200 Subject: [PATCH 1/3] fix: keep database and redis passwords out of the config debug output `Config` derives `Debug`, and neither of the two things it holds redacts its password. `AnyConnectOptions` stores the connection url in a public `Url` field, and `RedisConnectionInfo` has a plain `password: Option`, so the derived output prints both in full. That reaches two places. `--dump-config` is the natural thing to attach to a bug report, and the `log::trace!` in `run()` means an instance started at trace level writes both passwords into the journal, where they stay. Redact the database url through `to_url_lossy` and the redis password behind a marker, keeping the host, user and database name that make the dump useful. Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> --- src/config.rs | 125 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 5b07b168..c7d73131 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,7 @@ use nextcloud_config_parser::{ }; use redis::{ConnectionAddr, ConnectionInfo}; use sqlx::any::AnyConnectOptions; +use sqlx::ConnectOptions; use std::convert::{TryFrom, TryInto}; use std::env::var; use std::fmt::{Debug, Display, Formatter}; @@ -24,6 +25,8 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener}; use std::path::{Path, PathBuf}; use std::str::FromStr; +const REDACTED: &str = ""; + fn styles() -> Styles { Styles::styled() .header(AnsiColor::Yellow.on_default() | Effects::BOLD) @@ -118,7 +121,6 @@ pub struct Opt { pub max_connection_time: Option, } -#[derive(Debug)] pub struct Config { pub database: AnyConnectOptions, pub database_prefix: String, @@ -135,6 +137,65 @@ pub struct Config { pub max_connection_time: usize, } +/// Formats a database url without its password +struct RedactedDatabase<'a>(&'a AnyConnectOptions); + +impl Debug for RedactedDatabase<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let mut url = self.0.to_url_lossy(); + if url.password().is_some() { + url.set_password(Some(REDACTED)).ok(); + } + Display::fmt(&url, f) + } +} + +/// Formats a redis config without its password +struct RedactedRedis<'a>(&'a RedisConfig); + +impl Debug for RedactedRedis<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self.0 { + RedisConfig::Single(single) => f + .debug_struct("Single") + .field("addr", &single.addr) + .field("db", &single.db) + .field("username", &single.username) + .field("password", &single.password.as_ref().map(|_| REDACTED)) + .field("tls_params", &single.tls_params) + .finish(), + RedisConfig::Cluster(cluster) => f + .debug_struct("Cluster") + .field("addr", &cluster.addr) + .field("db", &cluster.db) + .field("username", &cluster.username) + .field("password", &cluster.password.as_ref().map(|_| REDACTED)) + .field("tls_params", &cluster.tls_params) + .finish(), + } + } +} + +impl Debug for Config { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Config") + .field("database", &RedactedDatabase(&self.database)) + .field("database_prefix", &self.database_prefix) + .field("redis", &RedactedRedis(&self.redis)) + .field("nextcloud_url", &self.nextcloud_url) + .field("metrics_bind", &self.metrics_bind) + .field("log_level", &self.log_level) + .field("bind", &self.bind) + .field("allow_self_signed", &self.allow_self_signed) + .field("user_agent", &self.user_agent) + .field("no_ansi", &self.no_ansi) + .field("tls", &self.tls) + .field("max_debounce_time", &self.max_debounce_time) + .field("max_connection_time", &self.max_connection_time) + .finish() + } +} + #[derive(Debug, Clone)] pub struct TlsConfig { pub key: PathBuf, @@ -502,3 +563,65 @@ fn map_redis_addr(addr: ConnectionAddr) -> RedisConnectionAddr { _ => unreachable!("unknown redis address"), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config(database: &str, redis_password: Option<&str>) -> Config { + sqlx::any::install_default_drivers(); + Config { + database: database.parse().unwrap(), + database_prefix: "oc_".into(), + redis: RedisConfig::Single(RedisConnectionInfo { + addr: RedisConnectionAddr::Tcp { + host: "127.0.0.1".into(), + port: 6379, + tls: false, + }, + db: 0, + username: Some("redis_user".into()), + password: redis_password.map(String::from), + tls_params: None, + }), + nextcloud_url: "https://cloud.example.com/".into(), + metrics_bind: None, + log_level: "warn".into(), + bind: Bind::Tcp(([127, 0, 0, 1], 7867).into()), + allow_self_signed: false, + user_agent: None, + no_ansi: false, + tls: None, + max_debounce_time: 15, + max_connection_time: 0, + } + } + + #[test] + fn test_config_debug_does_not_leak_passwords() { + // `--dump-config` and `log::trace!` both format the whole config + let config = test_config( + "postgres://nextcloud:db-hunter2@localhost/nextcloud", + Some("redis-hunter2"), + ); + for formatted in [format!("{config:?}"), format!("{config:#?}")] { + assert!( + !formatted.contains("db-hunter2"), + "database password leaked into config debug output: {formatted}" + ); + assert!( + !formatted.contains("redis-hunter2"), + "redis password leaked into config debug output: {formatted}" + ); + } + } + + #[test] + fn test_config_debug_keeps_non_secret_details() { + let config = test_config("postgres://nextcloud@localhost/nextcloud", None); + let formatted = format!("{config:#?}"); + assert!(formatted.contains("localhost"), "{formatted}"); + assert!(formatted.contains("cloud.example.com"), "{formatted}"); + assert!(formatted.contains("redis_user"), "{formatted}"); + } +} From 0f262de3a23b91bd9a38b67fdba8698dcf8304c3 Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:40:20 +0200 Subject: [PATCH 2/3] fix: don't let a malformed connection url reach clap's error output Redacting `Config`'s `Debug` closes `--dump-config` and the startup trace, but not the earliest place a password can escape. Clap puts the raw argument into its own message, so a typo an admin can easily make prints the whole url: $ notify_push --database-url "postgres://user:hunter2@host:not_a_port/db" error: invalid value 'postgres://user:hunter2@host:not_a_port/db' for '--database-url ': error with configuration: invalid port number For a systemd unit that lands in the journal. Take both url options as an opaque `ConnectionUrl` whose `FromStr` cannot fail, so clap never formats them, and validate in `PartialConfig::from_opt` where the message is ours. The error names the option and carries the underlying parse error, neither of which repeats the value. `ConnectionUrl` redacts its own `Debug`, and `PartialConfig` now gets the same treatment `Config` did, so the pattern is not left sitting one struct away for the next person who adds a debug print. The redaction marker is plain `REDACTED` rather than ``, which `Url::set_password` percent-encoded into `%3Credacted%3E`, and a failed `set_password` no longer falls through to printing the url it could not redact. Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> --- src/config.rs | 174 +++++++++++++++++++++++++++++++++++++++++++++----- src/error.rs | 5 ++ 2 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src/config.rs b/src/config.rs index c7d73131..52b25a69 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,14 +18,58 @@ use nextcloud_config_parser::{ use redis::{ConnectionAddr, ConnectionInfo}; use sqlx::any::AnyConnectOptions; use sqlx::ConnectOptions; -use std::convert::{TryFrom, TryInto}; +use std::convert::{Infallible, TryFrom, TryInto}; use std::env::var; use std::fmt::{Debug, Display, Formatter}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener}; use std::path::{Path, PathBuf}; use std::str::FromStr; +use url::Url; -const REDACTED: &str = ""; +const REDACTED: &str = "REDACTED"; + +/// A connection url given on the command line. +/// +/// Parsing is deliberately deferred. Clap puts the raw argument into its own +/// "invalid value ..." message, so a url that fails to parse at the clap layer +/// prints its password to stderr before any of our own error handling runs. +#[derive(Clone)] +pub struct ConnectionUrl(String); + +impl FromStr for ConnectionUrl { + type Err = Infallible; + + fn from_str(url: &str) -> Result { + Ok(ConnectionUrl(url.into())) + } +} + +impl ConnectionUrl { + fn database(&self, option: &'static str) -> Result { + AnyConnectOptions::from_str(&self.0) + .map_err(|e| ConfigError::UrlOption(option, Box::new(e))) + } + + fn redis(&self, option: &'static str) -> Result { + ConnectionInfo::from_str(&self.0).map_err(|e| ConfigError::UrlOption(option, Box::new(e))) + } +} + +impl Debug for ConnectionUrl { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match Url::parse(&self.0) { + Ok(mut url) if url.password().is_some() => { + if url.set_password(Some(REDACTED)).is_err() { + return f.write_str(REDACTED); + } + Display::fmt(&url, f) + } + Ok(url) => Display::fmt(&url, f), + // a url we cannot parse can still hold a password + Err(_) => f.write_str(REDACTED), + } + } +} fn styles() -> Styles { Styles::styled() @@ -40,10 +84,10 @@ fn styles() -> Styles { pub struct Opt { /// The database connect url #[clap(long)] - pub database_url: Option, + pub database_url: Option, /// The redis connect url #[clap(long)] - pub redis_url: Vec, + pub redis_url: Vec, /// The client certificate to use when connecting to redis over TLS #[clap(long)] pub redis_tls_cert: Option, @@ -143,8 +187,9 @@ struct RedactedDatabase<'a>(&'a AnyConnectOptions); impl Debug for RedactedDatabase<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let mut url = self.0.to_url_lossy(); - if url.password().is_some() { - url.set_password(Some(REDACTED)).ok(); + if url.password().is_some() && url.set_password(Some(REDACTED)).is_err() { + // never fall through to printing the url we failed to redact + return f.write_str(REDACTED); } Display::fmt(&url, f) } @@ -308,13 +353,13 @@ impl Config { .transpose()? .unwrap_or_default(); let from_env = PartialConfig::from_env()?; - let from_opt = PartialConfig::from_opt(opt); + let from_opt = PartialConfig::from_opt(opt)?; from_opt.merge(from_env).merge(from_config).try_into() } } -#[derive(Debug, Default)] +#[derive(Default)] struct PartialConfig { pub database: Option, pub database_prefix: Option, @@ -335,6 +380,30 @@ struct PartialConfig { pub max_connection_time: Option, } +impl Debug for PartialConfig { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PartialConfig") + .field("database", &self.database.as_ref().map(RedactedDatabase)) + .field("database_prefix", &self.database_prefix) + .field("redis", &self.redis.as_ref().map(RedactedRedis)) + .field("nextcloud_url", &self.nextcloud_url) + .field("port", &self.port) + .field("metrics_port", &self.metrics_port) + .field("metrics_socket", &self.metrics_socket) + .field("log_level", &self.log_level) + .field("bind", &self.bind) + .field("socket", &self.socket) + .field("socket_permissions", &self.socket_permissions) + .field("allow_self_signed", &self.allow_self_signed) + .field("user_agent", &self.user_agent) + .field("no_ansi", &self.no_ansi) + .field("tls", &self.tls) + .field("max_debounce_time", &self.max_debounce_time) + .field("max_connection_time", &self.max_connection_time) + .finish() + } +} + impl PartialConfig { fn from_env() -> Result { let database = parse_var("DATABASE_URL")?; @@ -420,17 +489,23 @@ impl PartialConfig { Ok(parse_config_file(file, glob)?) } - fn from_opt(opt: Opt) -> Self { + fn from_opt(opt: Opt) -> Result { let tls = if let (Some(cert), Some(key)) = (opt.tls_cert, opt.tls_key) { Some(TlsConfig { cert, key }) } else { None }; - let redis = match opt.redis_url.len() { + let redis_url = opt + .redis_url + .iter() + .map(|url| url.redis("redis-url")) + .collect::, _>>()?; + + let redis = match redis_url.len() { 0 => None, 1 => { - let redis = opt.redis_url.into_iter().next().unwrap(); + let redis = redis_url.into_iter().next().unwrap(); let addr = map_redis_addr(redis.addr().clone()); let redis_tls = matches!(addr, RedisConnectionAddr::Tcp { tls: true, .. }); @@ -452,8 +527,7 @@ impl PartialConfig { })) } _ => { - let addr: Vec<_> = opt - .redis_url + let addr: Vec<_> = redis_url .iter() .map(|redis| map_redis_addr(redis.addr().clone())) .collect(); @@ -471,7 +545,7 @@ impl PartialConfig { insecure: opt.redis_tls_insecure, }); - let redis = opt.redis_url.into_iter().next().unwrap(); + let redis = redis_url.into_iter().next().unwrap(); let redis = redis.redis_settings(); Some(RedisConfig::Cluster(RedisClusterConnectionInfo { addr, @@ -483,8 +557,12 @@ impl PartialConfig { } }; - PartialConfig { - database: opt.database_url, + Ok(PartialConfig { + database: opt + .database_url + .as_ref() + .map(|url| url.database("database-url")) + .transpose()?, database_prefix: opt.database_prefix, redis, nextcloud_url: opt.nextcloud_url, @@ -505,7 +583,7 @@ impl PartialConfig { tls, max_debounce_time: opt.max_debounce_time, max_connection_time: opt.max_connection_time, - } + }) } fn merge(self, fallback: Self) -> Self { @@ -597,6 +675,68 @@ mod tests { } } + /// Renders an error together with its whole source chain + fn full_error(err: &dyn std::error::Error) -> String { + let mut rendered = err.to_string(); + let mut source = err.source(); + while let Some(inner) = source { + rendered.push_str(" / "); + rendered.push_str(&inner.to_string()); + source = inner.source(); + } + rendered + } + + #[test] + fn test_malformed_urls_are_rejected_without_echoing_them() { + // clap puts the raw argument into its own "invalid value ..." message, so a + // url has to survive the clap layer and be rejected where we control the text + let opt = Opt::try_parse_from([ + "notify_push", + "--database-url", + "postgres://ncuser:db-hunter2@host:not_a_port/nextcloud", + "--redis-url", + "redis://redisuser:redis-hunter2@host:not_a_port/0", + ]) + .expect("clap has to accept the raw value and leave validation to us"); + + let err = Config::from_opt(opt).expect_err("a malformed url still has to be rejected"); + let rendered = full_error(&err); + assert!(!rendered.contains("db-hunter2"), "{rendered}"); + assert!(!rendered.contains("redis-hunter2"), "{rendered}"); + } + + #[test] + fn test_connection_url_debug_is_redacted() { + let url = ConnectionUrl::from_str("postgres://ncuser:db-hunter2@localhost/nextcloud") + .expect("parsing a connection url never fails"); + let rendered = format!("{url:?}"); + assert!(!rendered.contains("db-hunter2"), "{rendered}"); + assert!(rendered.contains("localhost"), "{rendered}"); + + // something we cannot parse could hold a password anywhere, say nothing about it + let broken = ConnectionUrl::from_str("not a url at all: hunter2").unwrap(); + assert!(!format!("{broken:?}").contains("hunter2")); + } + + #[test] + fn test_partial_config_debug_does_not_leak_passwords() { + sqlx::any::install_default_drivers(); + let opt = Opt::try_parse_from([ + "notify_push", + "--database-url", + "postgres://ncuser:db-hunter2@localhost/nextcloud", + "--redis-url", + "redis://someuser:redis-hunter2@localhost", + ]) + .unwrap(); + let partial = PartialConfig::from_opt(opt).unwrap(); + let rendered = format!("{partial:#?}"); + assert!(!rendered.contains("db-hunter2"), "{rendered}"); + assert!(!rendered.contains("redis-hunter2"), "{rendered}"); + assert!(rendered.contains("someuser"), "{rendered}"); + } + #[test] fn test_config_debug_does_not_leak_passwords() { // `--dump-config` and `log::trace!` both format the whole config diff --git a/src/error.rs b/src/error.rs index b4364e45..937e2e09 100644 --- a/src/error.rs +++ b/src/error.rs @@ -100,6 +100,11 @@ pub enum ConfigError { #[error("Error while parsing nextcloud config.php")] #[diagnostic(transparent)] Parse(#[from] nextcloud_config_parser::Error), + #[error("Invalid --{0} value")] + UrlOption( + &'static str, + #[source] Box, + ), #[error("Invalid {0} environment variable")] Env( &'static str, From dcbda99d2a51d4e5d5dec88c364771069c54c61d Mon Sep 17 00:00:00 2001 From: Git'Fellow <12234510+solracsf@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:33:41 +0200 Subject: [PATCH 3/3] refactor: redact where the config is actually printed, and nowhere else `Config` is the only thing ever formatted: `--dump-config` and the startup trace both print it, and its `Debug` has to keep showing host, user and database name. That impl stays. `Opt` and `PartialConfig` are never printed at all, so redacting them was guarding nothing. Not deriving `Debug` for them is both smaller and stronger: adding a debug print now fails to compile instead of quietly depending on the redaction being right. That removes the reason `ConnectionUrl` existed. It only wrapped a `String` to give `Opt` a safe `Debug`; a plain `String` already has the infallible `FromStr` that keeps clap from ever formatting the raw argument. Its two parse methods collapse into one generic helper. 103 lines out, 15 in. The binary behaves identically: a malformed url still reports without echoing itself, and `--dump-config` still redacts. Signed-off-by: Git'Fellow <12234510+solracsf@users.noreply.github.com> --- src/config.rs | 120 +++++++------------------------------------------- 1 file changed, 15 insertions(+), 105 deletions(-) diff --git a/src/config.rs b/src/config.rs index 52b25a69..32792487 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,57 +18,24 @@ use nextcloud_config_parser::{ use redis::{ConnectionAddr, ConnectionInfo}; use sqlx::any::AnyConnectOptions; use sqlx::ConnectOptions; -use std::convert::{Infallible, TryFrom, TryInto}; +use std::convert::{TryFrom, TryInto}; use std::env::var; use std::fmt::{Debug, Display, Formatter}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener}; use std::path::{Path, PathBuf}; use std::str::FromStr; -use url::Url; const REDACTED: &str = "REDACTED"; -/// A connection url given on the command line. +/// Parses a url given on the command line. /// -/// Parsing is deliberately deferred. Clap puts the raw argument into its own -/// "invalid value ..." message, so a url that fails to parse at the clap layer -/// prints its password to stderr before any of our own error handling runs. -#[derive(Clone)] -pub struct ConnectionUrl(String); - -impl FromStr for ConnectionUrl { - type Err = Infallible; - - fn from_str(url: &str) -> Result { - Ok(ConnectionUrl(url.into())) - } -} - -impl ConnectionUrl { - fn database(&self, option: &'static str) -> Result { - AnyConnectOptions::from_str(&self.0) - .map_err(|e| ConfigError::UrlOption(option, Box::new(e))) - } - - fn redis(&self, option: &'static str) -> Result { - ConnectionInfo::from_str(&self.0).map_err(|e| ConfigError::UrlOption(option, Box::new(e))) - } -} - -impl Debug for ConnectionUrl { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match Url::parse(&self.0) { - Ok(mut url) if url.password().is_some() => { - if url.set_password(Some(REDACTED)).is_err() { - return f.write_str(REDACTED); - } - Display::fmt(&url, f) - } - Ok(url) => Display::fmt(&url, f), - // a url we cannot parse can still hold a password - Err(_) => f.write_str(REDACTED), - } - } +/// Both url options are taken as plain strings so that clap never has to format +/// a parse error: it puts the raw argument into its own message, password and all. +fn parse_url_option(option: &'static str, url: &str) -> Result +where + T::Err: std::error::Error + Send + Sync + 'static, +{ + T::from_str(url).map_err(|e| ConfigError::UrlOption(option, Box::new(e))) } fn styles() -> Styles { @@ -79,15 +46,15 @@ fn styles() -> Styles { .placeholder(AnsiColor::Green.on_default()) } -#[derive(Parser, Debug)] +#[derive(Parser)] #[command(name = "notify_push", styles = styles())] pub struct Opt { /// The database connect url #[clap(long)] - pub database_url: Option, + pub database_url: Option, /// The redis connect url #[clap(long)] - pub redis_url: Vec, + pub redis_url: Vec, /// The client certificate to use when connecting to redis over TLS #[clap(long)] pub redis_tls_cert: Option, @@ -380,30 +347,6 @@ struct PartialConfig { pub max_connection_time: Option, } -impl Debug for PartialConfig { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PartialConfig") - .field("database", &self.database.as_ref().map(RedactedDatabase)) - .field("database_prefix", &self.database_prefix) - .field("redis", &self.redis.as_ref().map(RedactedRedis)) - .field("nextcloud_url", &self.nextcloud_url) - .field("port", &self.port) - .field("metrics_port", &self.metrics_port) - .field("metrics_socket", &self.metrics_socket) - .field("log_level", &self.log_level) - .field("bind", &self.bind) - .field("socket", &self.socket) - .field("socket_permissions", &self.socket_permissions) - .field("allow_self_signed", &self.allow_self_signed) - .field("user_agent", &self.user_agent) - .field("no_ansi", &self.no_ansi) - .field("tls", &self.tls) - .field("max_debounce_time", &self.max_debounce_time) - .field("max_connection_time", &self.max_connection_time) - .finish() - } -} - impl PartialConfig { fn from_env() -> Result { let database = parse_var("DATABASE_URL")?; @@ -499,7 +442,7 @@ impl PartialConfig { let redis_url = opt .redis_url .iter() - .map(|url| url.redis("redis-url")) + .map(|url| parse_url_option::("redis-url", url)) .collect::, _>>()?; let redis = match redis_url.len() { @@ -560,8 +503,8 @@ impl PartialConfig { Ok(PartialConfig { database: opt .database_url - .as_ref() - .map(|url| url.database("database-url")) + .as_deref() + .map(|url| parse_url_option("database-url", url)) .transpose()?, database_prefix: opt.database_prefix, redis, @@ -689,8 +632,6 @@ mod tests { #[test] fn test_malformed_urls_are_rejected_without_echoing_them() { - // clap puts the raw argument into its own "invalid value ..." message, so a - // url has to survive the clap layer and be rejected where we control the text let opt = Opt::try_parse_from([ "notify_push", "--database-url", @@ -706,37 +647,6 @@ mod tests { assert!(!rendered.contains("redis-hunter2"), "{rendered}"); } - #[test] - fn test_connection_url_debug_is_redacted() { - let url = ConnectionUrl::from_str("postgres://ncuser:db-hunter2@localhost/nextcloud") - .expect("parsing a connection url never fails"); - let rendered = format!("{url:?}"); - assert!(!rendered.contains("db-hunter2"), "{rendered}"); - assert!(rendered.contains("localhost"), "{rendered}"); - - // something we cannot parse could hold a password anywhere, say nothing about it - let broken = ConnectionUrl::from_str("not a url at all: hunter2").unwrap(); - assert!(!format!("{broken:?}").contains("hunter2")); - } - - #[test] - fn test_partial_config_debug_does_not_leak_passwords() { - sqlx::any::install_default_drivers(); - let opt = Opt::try_parse_from([ - "notify_push", - "--database-url", - "postgres://ncuser:db-hunter2@localhost/nextcloud", - "--redis-url", - "redis://someuser:redis-hunter2@localhost", - ]) - .unwrap(); - let partial = PartialConfig::from_opt(opt).unwrap(); - let rendered = format!("{partial:#?}"); - assert!(!rendered.contains("db-hunter2"), "{rendered}"); - assert!(!rendered.contains("redis-hunter2"), "{rendered}"); - assert!(rendered.contains("someuser"), "{rendered}"); - } - #[test] fn test_config_debug_does_not_leak_passwords() { // `--dump-config` and `log::trace!` both format the whole config