Skip to main content

rc_core/admin/
configuration.rs

1//! Safe contracts and planning helpers for RustFS server configuration.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6
7use crate::error::{Error, Result};
8
9/// A server configuration document. Its contents are intentionally omitted from debug output.
10#[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/// RustFS Admin API configuration operations.
98#[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
265/// Validate the syntax accepted by the RustFS config mutation endpoints.
266pub fn validate_config_directive(input: &str, allow_bare_keys: bool) -> Result<()> {
267    parse_directives(input, allow_bare_keys).map(|_| ())
268}
269
270/// Return subsystem/key pairs without exposing configuration values.
271pub 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
286/// Reject redacted placeholders before a full replacement can overwrite live secrets.
287pub 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
300/// Redact secret-bearing fields in a RustFS line-oriented configuration document.
301pub 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
378/// Build a client-side preflight diff for a set or delete mutation.
379pub 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
420/// Build a client-side preflight diff for a full configuration replacement.
421pub 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}