1use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6
7use crate::error::{Error, Result};
8
9#[derive(Clone, PartialEq, Eq, Deserialize)]
11pub struct ConfigDocument {
12 pub content: String,
13}
14
15impl std::fmt::Debug for ConfigDocument {
16 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 formatter
18 .debug_struct("ConfigDocument")
19 .field("content", &"<redacted>")
20 .finish()
21 }
22}
23
24#[derive(Clone, PartialEq, Eq, Deserialize)]
25pub struct ConfigHistoryEntry {
26 #[serde(rename = "RestoreID")]
27 pub restore_id: String,
28 #[serde(rename = "CreateTime")]
29 pub create_time: String,
30 #[serde(rename = "Data", default)]
31 pub data: Option<String>,
32}
33
34impl std::fmt::Debug for ConfigHistoryEntry {
35 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 formatter
37 .debug_struct("ConfigHistoryEntry")
38 .field("restore_id", &self.restore_id)
39 .field("create_time", &self.create_time)
40 .field("data", &self.data.as_ref().map(|_| "<redacted>"))
41 .finish()
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ConfigHelp {
47 #[serde(rename = "subSys")]
48 pub subsystem: String,
49 pub description: String,
50 #[serde(rename = "multipleTargets")]
51 pub multiple_targets: bool,
52 #[serde(rename = "keysHelp")]
53 pub keys: Vec<ConfigHelpEntry>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ConfigHelpEntry {
58 pub key: String,
59 #[serde(rename = "type")]
60 pub value_type: String,
61 pub description: String,
62 pub optional: bool,
63 #[serde(rename = "multipleTargets")]
64 pub multiple_targets: bool,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68pub struct ConfigMutationResult {
69 pub applied_dynamically: bool,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct ModuleSwitches {
74 pub notify_enabled: bool,
75 pub audit_enabled: bool,
76 pub persisted_notify_enabled: bool,
77 pub persisted_audit_enabled: bool,
78 pub notify_source: String,
79 pub audit_source: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct ConfigChange {
84 pub scope: String,
85 pub key: String,
86 pub before: Option<String>,
87 pub after: Option<String>,
88 pub sensitive: bool,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct ConfigDiff {
93 pub changes: Vec<ConfigChange>,
94 pub replaces_full_config: bool,
95}
96
97#[async_trait]
99pub trait ConfigApi: Send + Sync {
100 async fn get_config(&self, selector: &str) -> Result<ConfigDocument>;
101 async fn get_full_config(&self) -> Result<ConfigDocument>;
102 async fn set_config(&self, directive: &str) -> Result<ConfigMutationResult>;
103 async fn delete_config(&self, directive: &str) -> Result<ConfigMutationResult>;
104 async fn config_help(
105 &self,
106 subsystem: Option<&str>,
107 key: Option<&str>,
108 env_only: bool,
109 ) -> Result<ConfigHelp>;
110 async fn config_history(&self, count: usize) -> Result<Vec<ConfigHistoryEntry>>;
111 async fn restore_config(&self, restore_id: &str) -> Result<()>;
112 async fn import_config(&self, document: &ConfigDocument) -> Result<()>;
113 async fn get_module_switches(&self) -> Result<ModuleSwitches>;
114 async fn set_module_switches(&self, switches: &ModuleSwitches) -> Result<ModuleSwitches>;
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118struct ParsedDirective {
119 scope: String,
120 entries: Vec<(String, Option<String>)>,
121}
122
123fn tokenize(line: &str) -> Result<Vec<String>> {
124 let mut tokens = Vec::new();
125 let mut current = String::new();
126 let mut quote = None;
127 let mut escaped = false;
128
129 for character in line.chars() {
130 if escaped {
131 current.push(character);
132 escaped = false;
133 } else if character == '\\' {
134 escaped = true;
135 } else if let Some(active_quote) = quote {
136 if character == active_quote {
137 quote = None;
138 } else {
139 current.push(character);
140 }
141 } else {
142 match character {
143 '\'' | '"' => quote = Some(character),
144 value if value.is_whitespace() => {
145 if !current.is_empty() {
146 tokens.push(std::mem::take(&mut current));
147 }
148 }
149 _ => current.push(character),
150 }
151 }
152 }
153
154 if quote.is_some() {
155 return Err(Error::Config(
156 "Configuration contains an unterminated quoted value".to_string(),
157 ));
158 }
159 if escaped {
160 current.push('\\');
161 }
162 if !current.is_empty() {
163 tokens.push(current);
164 }
165 Ok(tokens)
166}
167
168fn valid_name(value: &str) -> bool {
169 !value.is_empty()
170 && value
171 .chars()
172 .all(|character| character.is_ascii_alphanumeric() || "_-.:".contains(character))
173}
174
175fn parse_directives(input: &str, allow_bare_keys: bool) -> Result<Vec<ParsedDirective>> {
176 let mut directives = Vec::new();
177 for line in input.lines().map(str::trim) {
178 if line.is_empty() || line.starts_with('#') {
179 continue;
180 }
181 let tokens = tokenize(line)?;
182 let Some(scope) = tokens.first() else {
183 continue;
184 };
185 if !valid_name(scope) || scope.starts_with(':') || scope.ends_with(':') {
186 return Err(Error::Config("Invalid configuration scope".to_string()));
187 }
188
189 let mut entries = Vec::new();
190 for token in tokens.iter().skip(1) {
191 let (key, value) = match token.split_once('=') {
192 Some((key, value)) => (key, Some(value.to_string())),
193 None if allow_bare_keys => (token.as_str(), None),
194 None => {
195 return Err(Error::Config(
196 "Configuration assignments must use key=value syntax".to_string(),
197 ));
198 }
199 };
200 if !valid_name(key) || key.contains(':') {
201 return Err(Error::Config("Invalid configuration key".to_string()));
202 }
203 entries.push((key.to_string(), value));
204 }
205 directives.push(ParsedDirective {
206 scope: scope.to_string(),
207 entries,
208 });
209 }
210 if directives.is_empty() {
211 return Err(Error::Config(
212 "Configuration document must include at least one directive".to_string(),
213 ));
214 }
215 Ok(directives)
216}
217
218fn is_sensitive_key(key: &str) -> bool {
219 let key = key.trim().to_ascii_lowercase().replace(['-', '.'], "_");
220 key.contains("secret")
221 || key.contains("password")
222 || key.contains("passphrase")
223 || key.split('_').any(|part| part == "token")
224 || key.split('_').any(|part| part == "credential")
225 || matches!(
226 key.as_str(),
227 "access_key"
228 | "account_key"
229 | "api_key"
230 | "client_key"
231 | "private_key"
232 | "master_key"
233 | "signing_key"
234 | "encryption_key"
235 | "session_key"
236 | "connection_string"
237 | "conn_string"
238 | "dsn"
239 | "authorization"
240 | "auth_header"
241 | "cookie"
242 )
243}
244
245fn redacted_value(key: &str, value: &str) -> String {
246 if is_sensitive_key(key) && !value.is_empty() {
247 "*redacted*".to_string()
248 } else {
249 value.to_string()
250 }
251}
252
253fn document_map(input: &str, allow_bare_keys: bool) -> Result<BTreeMap<(String, String), String>> {
254 let mut values = BTreeMap::new();
255 for directive in parse_directives(input, allow_bare_keys)? {
256 for (key, value) in directive.entries {
257 if let Some(value) = value {
258 values.insert((directive.scope.clone(), key), value);
259 }
260 }
261 }
262 Ok(values)
263}
264
265pub fn validate_config_directive(input: &str, allow_bare_keys: bool) -> Result<()> {
267 parse_directives(input, allow_bare_keys).map(|_| ())
268}
269
270pub fn config_document_fields(input: &str) -> Result<Vec<(String, String)>> {
272 let mut fields = Vec::new();
273 for directive in parse_directives(input, false)? {
274 let subsystem = directive
275 .scope
276 .split_once(':')
277 .map_or(directive.scope.as_str(), |(value, _)| value)
278 .to_string();
279 for (key, _) in directive.entries {
280 fields.push((subsystem.clone(), key));
281 }
282 }
283 Ok(fields)
284}
285
286pub fn validate_config_import(input: &str) -> Result<()> {
288 for directive in parse_directives(input, false)? {
289 for (key, value) in directive.entries {
290 if is_sensitive_key(&key) && value.as_deref() == Some("*redacted*") {
291 return Err(Error::Config(
292 "Redacted secret placeholders cannot be imported".to_string(),
293 ));
294 }
295 }
296 }
297 Ok(())
298}
299
300pub fn redact_config_document(input: &str) -> String {
302 input
303 .lines()
304 .filter_map(redact_config_line)
305 .collect::<Vec<_>>()
306 .join("\n")
307}
308
309fn redact_config_line(line: &str) -> Option<String> {
310 let trimmed = line.trim();
311 if trimmed.is_empty() || trimmed.starts_with('#') {
312 return None;
313 }
314 match parse_directives(trimmed, true) {
315 Ok(mut directives) if directives.len() == 1 => {
316 let directive = directives
317 .pop()
318 .expect("one parsed directive was checked above");
319 let entries = directive
320 .entries
321 .into_iter()
322 .map(|(key, value)| match value {
323 Some(value) => format!(
324 "{key}=\"{}\"",
325 escape_config_value(&redacted_value(&key, &value))
326 ),
327 None => key,
328 })
329 .collect::<Vec<_>>();
330 Some(if entries.is_empty() {
331 directive.scope
332 } else {
333 format!("{} {}", directive.scope, entries.join(" "))
334 })
335 }
336 _ => Some(redact_unparseable_line(trimmed)),
337 }
338}
339
340fn redact_unparseable_line(line: &str) -> String {
341 for (delimiter_index, _) in line.match_indices('=') {
342 let prefix = &line[..delimiter_index];
343 let end = prefix.trim_end().len();
344 let start = prefix[..end]
345 .rfind(|character: char| {
346 !(character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.'))
347 })
348 .map_or(0, |index| index + 1);
349 if is_sensitive_key(&prefix[start..end]) {
350 return format!("{}=\"*redacted*\"", &line[..delimiter_index]);
351 }
352 }
353 line.to_string()
354}
355
356fn escape_config_value(value: &str) -> String {
357 value.replace('\\', "\\\\").replace('"', "\\\"")
358}
359
360fn change(
361 scope: String,
362 key: String,
363 before: Option<String>,
364 after: Option<String>,
365) -> ConfigChange {
366 let sensitive = is_sensitive_key(&key);
367 let before = before.map(|value| redacted_value(&key, &value));
368 let after = after.map(|value| redacted_value(&key, &value));
369 ConfigChange {
370 scope,
371 key,
372 before,
373 after,
374 sensitive,
375 }
376}
377
378pub fn config_mutation_diff(current: &str, directive: &str, delete: bool) -> Result<ConfigDiff> {
380 let current_values = document_map(current, false)?;
381 let directives = parse_directives(directive, delete)?;
382 let mut changes = Vec::new();
383
384 for directive in directives {
385 if delete && directive.entries.is_empty() {
386 for ((scope, key), before) in current_values
387 .iter()
388 .filter(|((scope, _), _)| scope == &directive.scope)
389 {
390 changes.push(change(
391 scope.clone(),
392 key.clone(),
393 Some(before.clone()),
394 None,
395 ));
396 }
397 continue;
398 }
399
400 for (key, after) in directive.entries {
401 let before = current_values
402 .get(&(directive.scope.clone(), key.clone()))
403 .cloned();
404 if delete {
405 if before.is_some() {
406 changes.push(change(directive.scope.clone(), key, before, None));
407 }
408 } else if before != after {
409 changes.push(change(directive.scope.clone(), key, before, after));
410 }
411 }
412 }
413
414 Ok(ConfigDiff {
415 changes,
416 replaces_full_config: false,
417 })
418}
419
420pub fn config_import_diff(current: &str, replacement: &str) -> Result<ConfigDiff> {
422 let current_values = document_map(current, false)?;
423 let replacement_values = document_map(replacement, false)?;
424 let keys = current_values
425 .keys()
426 .chain(replacement_values.keys())
427 .cloned()
428 .collect::<BTreeSet<_>>();
429 let mut changes = Vec::new();
430 for (scope, key) in keys {
431 let before = current_values.get(&(scope.clone(), key.clone())).cloned();
432 let after = replacement_values
433 .get(&(scope.clone(), key.clone()))
434 .cloned();
435 if before != after {
436 changes.push(change(scope, key, before, after));
437 }
438 }
439 Ok(ConfigDiff {
440 changes,
441 replaces_full_config: true,
442 })
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn validates_assignments_without_echoing_secret_values() {
451 assert!(validate_config_directive("scanner speed=fast", false).is_ok());
452 let error = validate_config_directive("identity_openid client_secret", false)
453 .expect_err("missing assignment must fail");
454 assert!(!error.to_string().contains("super-secret"));
455 }
456
457 #[test]
458 fn redacts_secret_fields_and_preserves_non_secret_values() {
459 let redacted = redact_config_document(
460 "identity_openid client_id=console client_secret=super-secret\nscanner speed=fast",
461 );
462 assert!(redacted.contains("client_id=\"console\""));
463 assert!(redacted.contains("client_secret=\"*redacted*\""));
464 assert!(redacted.contains("speed=\"fast\""));
465 assert!(!redacted.contains("super-secret"));
466 }
467
468 #[test]
469 fn redacts_connection_and_key_credentials_conservatively() {
470 let redacted = redact_config_document(
471 "notify_postgres connection_string=postgres://user:pass@db dsn=postgres://db \
472 api_key=api-secret account_key=account-secret access_key=access-secret",
473 );
474
475 for secret in [
476 "postgres://user:pass@db",
477 "postgres://db",
478 "api-secret",
479 "account-secret",
480 "access-secret",
481 ] {
482 assert!(!redacted.contains(secret));
483 }
484 assert_eq!(redacted.matches("*redacted*").count(), 5);
485 }
486
487 #[test]
488 fn malformed_lines_are_preserved_unless_they_contain_secrets() {
489 let redacted = redact_config_document(
490 "scanner speed=\"unfinished\nidentity_openid client_secret=must-not-leak \"unterminated",
491 );
492
493 assert!(redacted.contains("scanner speed=\"unfinished"));
494 assert!(redacted.contains("client_secret=\"*redacted*\""));
495 assert!(!redacted.contains("must-not-leak"));
496 assert!(!redacted.contains("unterminated"));
497 assert!(!redacted.contains("<redacted invalid configuration>"));
498 }
499
500 #[test]
501 fn set_diff_redacts_both_secret_sides() {
502 let diff = config_mutation_diff(
503 "identity_openid client_secret=old client_id=console",
504 "identity_openid client_secret=new",
505 false,
506 )
507 .expect("build set diff");
508 assert_eq!(diff.changes.len(), 1);
509 assert_eq!(diff.changes[0].before.as_deref(), Some("*redacted*"));
510 assert_eq!(diff.changes[0].after.as_deref(), Some("*redacted*"));
511 }
512
513 #[test]
514 fn delete_diff_reports_only_present_keys() {
515 let diff =
516 config_mutation_diff("scanner speed=fast cycle=10", "scanner speed missing", true)
517 .expect("build delete diff");
518 assert_eq!(diff.changes.len(), 1);
519 assert_eq!(diff.changes[0].key, "speed");
520 assert_eq!(diff.changes[0].after, None);
521 }
522
523 #[test]
524 fn full_target_delete_reports_every_present_key() {
525 let diff = config_mutation_diff("scanner speed=fast cycle=10", "scanner", true)
526 .expect("build full target delete diff");
527
528 assert_eq!(diff.changes.len(), 2);
529 assert!(diff.changes.iter().all(|change| change.after.is_none()));
530 }
531
532 #[test]
533 fn import_diff_reports_additions_changes_and_removals() {
534 let diff = config_import_diff("scanner speed=fast cycle=10", "scanner speed=slow delay=1")
535 .expect("build import diff");
536 assert!(diff.replaces_full_config);
537 assert_eq!(diff.changes.len(), 3);
538 }
539
540 #[test]
541 fn redaction_preserves_quoted_value_semantics() {
542 let redacted = redact_config_document(
543 r#"identity_openid client_id="console app" client_secret="s3cr\"et""#,
544 );
545 assert!(redacted.contains(r#"client_id="console app""#));
546 assert!(redacted.contains(r#"client_secret="*redacted*""#));
547 assert!(validate_config_directive(&redacted, false).is_ok());
548 }
549
550 #[test]
551 fn import_rejects_redacted_secret_placeholders() {
552 let error = validate_config_import(
553 r#"identity_openid client_id="console" client_secret="*redacted*""#,
554 )
555 .expect_err("redacted secrets must not be imported");
556 assert_eq!(error.exit_code(), 2);
557 }
558}