manta_shared/common/config/
mod.rs

1//! Config-file loaders for `cli.toml` and `server.toml`.
2//!
3//! This module owns three concerns:
4//!
5//! 1. **Paths** — XDG-resolved defaults via [`get_default_config_path`],
6//!    with per-binary overrides from `MANTA_CLI_CONFIG` /
7//!    `MANTA_SERVER_CONFIG`. See [`get_cli_config_file_path`] and
8//!    [`get_server_config_file_path`].
9//! 2. **Loading** — [`get_cli_configuration`] and
10//!    [`get_server_configuration`] parse the TOML file and merge any
11//!    `MANTA_*`-prefixed environment variables on top, returning an
12//!    untyped `::config::Config`. The typed deserialisation targets
13//!    live with each binary (`CliConfiguration` in `manta-cli`,
14//!    `ServerConfiguration` in `manta-server`) so this module stays
15//!    agnostic of either schema.
16//! 3. **In-place editing** — [`read_config_toml`] / [`write_config_toml`]
17//!    expose a `toml_edit::DocumentMut` view for `manta config set`
18//!    and friends, preserving comments and formatting.
19//!
20//! Missing-file errors are intentionally rich: the
21//! [`MantaError::NotFound`] message includes a minimal example file
22//! and, when a legacy `~/.config/manta/config.toml` is detected, a
23//! field-by-field migration mapping.
24//!
25//! [`MantaError::NotFound`]: crate::common::error::MantaError::NotFound
26
27use std::{
28  fs::{self, File},
29  io::{Read, Write},
30  path::PathBuf,
31};
32
33use crate::common::error::MantaError as Error;
34use config::Config;
35use directories::ProjectDirs;
36use toml_edit::DocumentMut;
37
38/// Returns the XDG-compliant `ProjectDirs` for manta.
39///
40/// All path helpers in this module delegate to this function
41/// so the qualifier/organization/application triple is defined
42/// in exactly one place.
43fn get_project_dirs() -> Result<ProjectDirs, Error> {
44  ProjectDirs::from(
45    "local", /*qualifier*/
46    "cscs",  /*organization*/
47    "manta", /*application*/
48  )
49  .ok_or_else(|| {
50    Error::MissingField(
51      "Could not determine project directories \
52       (home directory may not be set)"
53        .to_string(),
54    )
55  })
56}
57
58/// Returns the default manta config directory path
59/// (e.g. `~/.config/manta/`).
60///
61/// # Errors
62///
63/// Returns [`MantaError::MissingField`] when the platform cannot resolve
64/// a project-dirs triple (typically because `$HOME` is unset).
65///
66/// [`MantaError::MissingField`]: crate::common::error::MantaError::MissingField
67pub fn get_default_config_path() -> Result<PathBuf, Error> {
68  Ok(PathBuf::from(get_project_dirs()?.config_dir()))
69}
70
71/// Appends `filename` to the default config directory path.
72fn default_config_file(filename: &str) -> Result<PathBuf, Error> {
73  let mut path = get_default_config_path()?;
74  path.push(filename);
75  Ok(path)
76}
77
78/// Returns the path of the *legacy* unified config file
79/// (e.g. `~/.config/manta/config.toml`). Used only by
80/// `missing_config_message` to detect when a user is migrating from
81/// the pre-split layout — neither binary ever reads from this path
82/// at startup.
83pub fn get_default_manta_config_file_path() -> Result<PathBuf, Error> {
84  default_config_file("config.toml")
85}
86
87/// Returns the default CLI config file path
88/// (e.g. `~/.config/manta/cli.toml`).
89pub fn get_default_manta_cli_config_file_path() -> Result<PathBuf, Error> {
90  default_config_file("cli.toml")
91}
92
93/// Returns the default server config file path
94/// (e.g. `~/.config/manta/server.toml`).
95pub fn get_default_manta_server_config_file_path() -> Result<PathBuf, Error> {
96  default_config_file("server.toml")
97}
98
99/// Returns the default manta cache directory path
100/// (e.g. `~/.cache/manta/`).
101pub fn get_default_cache_path() -> Result<PathBuf, Error> {
102  Ok(PathBuf::from(get_project_dirs()?.cache_dir()))
103}
104
105/// Reads the manta CLI configuration file (`cli.toml`) and parses it as
106/// TOML, honoring `MANTA_CLI_CONFIG`.
107///
108/// Returns both the file path (for later writing via
109/// [`write_config_toml`]) and the parsed `DocumentMut`, which
110/// preserves comments and formatting for in-place edits.
111///
112/// # Errors
113///
114/// - [`MantaError::IoError`] if the file cannot be read.
115/// - [`MantaError::TomlEditError`] if the contents are not valid TOML.
116///
117/// [`MantaError::IoError`]: crate::common::error::MantaError::IoError
118/// [`MantaError::TomlEditError`]: crate::common::error::MantaError::TomlEditError
119pub fn read_config_toml() -> Result<(PathBuf, DocumentMut), Error> {
120  let path = get_cli_config_file_path()?;
121
122  tracing::debug!(
123    "Reading manta CLI configuration from {}",
124    path.to_string_lossy()
125  );
126
127  let content = fs::read_to_string(&path)?;
128
129  let doc = content.parse::<DocumentMut>()?;
130
131  Ok((path, doc))
132}
133
134/// Writes a `DocumentMut` back to the manta configuration file.
135///
136/// Opens `path` with `write | truncate` and replaces its contents with
137/// the document's serialised form. Paired with [`read_config_toml`]
138/// for round-tripping `manta config set` edits.
139///
140/// # Errors
141///
142/// Returns [`MantaError::IoError`] if the file cannot be opened,
143/// written to, or flushed.
144///
145/// [`MantaError::IoError`]: crate::common::error::MantaError::IoError
146pub fn write_config_toml(
147  path: &std::path::Path,
148  doc: &DocumentMut,
149) -> Result<(), Error> {
150  let mut file = std::fs::OpenOptions::new()
151    .write(true)
152    .truncate(true)
153    .open(path)?;
154
155  file.write_all(doc.to_string().as_bytes())?;
156  file.flush()?;
157
158  Ok(())
159}
160
161/// Read the root CA certificate from `file_path`, falling
162/// back to the default config directory if the path is
163/// relative.
164///
165/// # Errors
166///
167/// - [`MantaError::NotFound`] if neither the literal nor the
168///   config-directory-relative path resolves to a readable file.
169/// - [`MantaError::IoError`] if a candidate file opens but cannot be
170///   read to completion.
171///
172/// [`MantaError::NotFound`]: crate::common::error::MantaError::NotFound
173/// [`MantaError::IoError`]: crate::common::error::MantaError::IoError
174pub fn get_csm_root_cert_content(file_path: &str) -> Result<Vec<u8>, Error> {
175  let mut buf = Vec::new();
176  let root_cert_file_rslt = File::open(file_path);
177
178  let file_rslt = if root_cert_file_rslt.is_err() {
179    let mut config_path = get_default_config_path()?;
180    config_path.push(file_path);
181    File::open(config_path)
182  } else {
183    root_cert_file_rslt
184  };
185
186  match file_rslt {
187    Ok(mut file) => {
188      file.read_to_end(&mut buf)?;
189      Ok(buf)
190    }
191    Err(_) => Err(Error::NotFound(
192      "CA public root file could not be found".to_string(),
193    )),
194  }
195}
196
197/// Returns the CLI config file path, honoring `MANTA_CLI_CONFIG` if set.
198///
199/// When the env var is present its value wins verbatim (no validation,
200/// no relative-path resolution); otherwise the XDG default
201/// (`~/.config/manta/cli.toml` on Linux) is returned.
202///
203/// # Errors
204///
205/// Returns [`MantaError::MissingField`] if `MANTA_CLI_CONFIG` is unset
206/// *and* the platform cannot resolve a project-dirs triple. The
207/// env-var branch is infallible.
208///
209/// # Examples
210///
211/// ```no_run
212/// use manta_shared::common::config::get_cli_config_file_path;
213///
214/// // SAFETY: doc-tests run single-threaded.
215/// unsafe { std::env::set_var("MANTA_CLI_CONFIG", "/etc/manta/cli.toml") };
216/// let path = get_cli_config_file_path().unwrap();
217/// assert_eq!(path, std::path::PathBuf::from("/etc/manta/cli.toml"));
218/// ```
219///
220/// [`MantaError::MissingField`]: crate::common::error::MantaError::MissingField
221pub fn get_cli_config_file_path() -> Result<PathBuf, Error> {
222  if let Ok(env_path) = std::env::var("MANTA_CLI_CONFIG") {
223    Ok(PathBuf::from(env_path))
224  } else {
225    get_default_manta_cli_config_file_path()
226  }
227}
228
229/// Returns the server config file path, honoring `MANTA_SERVER_CONFIG` if set.
230///
231/// Symmetric to [`get_cli_config_file_path`]: env-var wins verbatim,
232/// otherwise XDG default (`~/.config/manta/server.toml` on Linux).
233///
234/// # Errors
235///
236/// Returns [`MantaError::MissingField`] if `MANTA_SERVER_CONFIG` is
237/// unset *and* the platform cannot resolve a project-dirs triple.
238///
239/// # Examples
240///
241/// ```no_run
242/// use manta_shared::common::config::get_server_config_file_path;
243///
244/// // SAFETY: doc-tests run single-threaded.
245/// unsafe { std::env::set_var("MANTA_SERVER_CONFIG", "/etc/manta/server.toml") };
246/// let path = get_server_config_file_path().unwrap();
247/// assert_eq!(path, std::path::PathBuf::from("/etc/manta/server.toml"));
248/// ```
249///
250/// [`MantaError::MissingField`]: crate::common::error::MantaError::MissingField
251pub fn get_server_config_file_path() -> Result<PathBuf, Error> {
252  if let Ok(env_path) = std::env::var("MANTA_SERVER_CONFIG") {
253    Ok(PathBuf::from(env_path))
254  } else {
255    get_default_manta_server_config_file_path()
256  }
257}
258
259/// Minimal CLI config sample shown in the NotFound error.
260const CLI_CONFIG_SAMPLE: &str = r#"log = "info"
261# Optional: the active site (`X-Manta-Site` header). Omit it and pass
262# `--site <name>` per invocation instead; the server validates the name.
263site = "<site_name>"
264manta_server_url = "https://manta-server.example.com:8443"
265
266# Timeout knobs. Values shown are the built-in defaults — delete a
267# line to fall back to the default, or change the value to override.
268#
269# Per-request HTTP timeout reaching `manta_server_url` (seconds).
270# Default 300 caps REST calls. Streams (SSE log tail, WS console)
271# are unlimited when this is absent; setting it caps streams too.
272request_timeout_secs             = 300
273#
274# `manta power on/off/reset`: poll interval (seconds) and max
275# attempts before giving up. 300 × 3 s = 15 min total wait.
276power_poll_interval_secs         = 3
277power_max_poll_attempts          = 300
278#
279# `manta apply sat-file`: poll interval, overall hard cap, and
280# cap on consecutive "session not yet visible" responses (seconds).
281sat_file_poll_interval_secs      = 10
282sat_file_poll_budget_secs        = 14400   # 4 hours
283sat_file_not_visible_budget_secs = 300     # 5 minutes
284
285[sites.<site_name>]
286backend = "csm"                 # or "ochami"
287shasta_base_url = "https://api.example.com"
288root_ca_cert_file = "alps_root_cert.pem"
289"#;
290
291/// Migration mapping shown when a legacy `config.toml` is detected.
292const CLI_CONFIG_MIGRATION: &str = "\
293Migration from ~/.config/manta/config.toml:
294  copy these fields verbatim:        log, site, auditor, sites
295  add CLI-only (now required):       manta_server_url = \"https://...\"
296                                     (CLI talks only to the manta server)
297  drop (no longer recognised):       sites.<X>.manta_server_url, audit_file
298  do not copy (server-only fields):  the [server] section belongs in
299                                     server.toml, not cli.toml";
300
301/// Minimal server config sample shown in the NotFound error.
302const SERVER_CONFIG_SAMPLE: &str = r#"log = "info"
303
304[server]
305listen_address = "0.0.0.0"
306port = 8443
307cert = "/path/to/server.crt"
308key = "/path/to/server.key"
309console_inactivity_timeout_secs = 1800
310auth_rate_limit_per_minute      = 60     # per source IP for /auth/*; omit to disable
311# Values shown for the two timeout knobs are the built-in defaults —
312# delete a line to fall back to the default, or change to override.
313request_timeout_secs            = 300    # global per-route timeout; returns 408 on expiry
314shutdown_grace_period_secs      = 30     # drain window after SIGTERM / Ctrl+C; matches k8s terminationGracePeriodSeconds
315# allow_http = false                     # opt in to plain-HTTP listen when no cert/key is set
316                                         #   (e.g. TLS terminated upstream). Default fail-closed.
317# Filesystem root for POST /migrate/{backup,restore}. Required for those
318# endpoints to work — the server will reject migrate requests with 400
319# while this is unset. Must be an absolute path to an existing directory.
320# migrate_backup_root = "/var/lib/manta/migrate"
321
322# [auditor.kafka]                        # optional: enable Kafka audit emission
323# brokers            = ["kafka.example.com:9092"]
324# topic              = "manta-audit"
325# message_timeout_ms = 5000              # librdkafka per-message delivery deadline; default 5000
326# delivery_wait_secs = 0                 # how long produce_message blocks; 0 = fire-and-forget (default)
327
328[sites.<site_name>]
329backend = "csm"
330shasta_base_url = "https://api.example.com"
331root_ca_cert_file = "/path/to/alps_root_cert.pem"
332"#;
333
334/// Migration mapping shown when a legacy `config.toml` is detected.
335const SERVER_CONFIG_MIGRATION: &str = "\
336Migration from ~/.config/manta/config.toml:
337  copy these fields verbatim:        log, auditor, sites
338  add new [server] section:          listen_address, port, cert, key,
339                                     console_inactivity_timeout_secs
340  drop (CLI-only):                   site, hsm_group, manta_server_url
341  drop (no longer recognised):       sites.<X>.manta_server_url, audit_file";
342
343fn missing_config_message(
344  binary: &str,
345  expected_path: &std::path::Path,
346  sample: &str,
347  migration: &str,
348) -> String {
349  let legacy_exists = get_default_manta_config_file_path()
350    .map(|p| p.exists())
351    .unwrap_or(false);
352  let mut msg = format!(
353    "{binary} configuration file '{}' not found.\n\nMinimal example:\n\n{sample}",
354    expected_path.to_string_lossy()
355  );
356  if legacy_exists {
357    msg.push('\n');
358    msg.push_str(migration);
359  }
360  msg
361}
362
363/// Shared TOML + env-var loading logic.
364///
365/// Checks that `path` exists, converts it to a UTF-8 string, then
366/// runs the standard `Config::builder` chain (file + `MANTA_*` env
367/// vars). `label` is used only in error messages ("CLI" or "Server").
368fn load_config(
369  label: &str,
370  path: PathBuf,
371  sample: &str,
372  migration: &str,
373) -> Result<Config, Error> {
374  if !path.exists() {
375    return Err(Error::NotFound(missing_config_message(
376      label, &path, sample, migration,
377    )));
378  }
379  let path_str = path.to_str().ok_or_else(|| {
380    Error::MissingField(format!(
381      "{label} configuration file path contains invalid UTF-8"
382    ))
383  })?;
384  ::config::Config::builder()
385    .add_source(::config::File::new(path_str, ::config::FileFormat::Toml))
386    .add_source(
387      ::config::Environment::with_prefix("MANTA")
388        .try_parsing(true)
389        .prefix_separator("_"),
390    )
391    .build()
392    .map_err(Error::ConfigError)
393}
394
395/// Load `cli.toml`. Fails loudly if the file is missing; the error
396/// message includes a minimal example and (when a legacy config.toml is
397/// detected) a field-by-field migration mapping.
398///
399/// Reads the file at [`get_cli_config_file_path`] and layers
400/// `MANTA_*`-prefixed environment variables on top (env wins).
401///
402/// # Errors
403///
404/// - [`MantaError::NotFound`] when the resolved config file does not
405///   exist on disk; the message embeds [`CLI_CONFIG_SAMPLE`-equivalent]
406///   guidance.
407/// - [`MantaError::MissingField`] if the resolved path is not valid
408///   UTF-8 (required by the underlying `config` crate).
409/// - [`MantaError::ConfigError`] on TOML parse, env-var type-coercion,
410///   or builder failures.
411///
412/// [`CLI_CONFIG_SAMPLE`-equivalent]: self
413/// [`MantaError::NotFound`]: crate::common::error::MantaError::NotFound
414/// [`MantaError::MissingField`]: crate::common::error::MantaError::MissingField
415/// [`MantaError::ConfigError`]: crate::common::error::MantaError::ConfigError
416pub fn get_cli_configuration() -> Result<Config, Error> {
417  load_config(
418    "CLI",
419    get_cli_config_file_path()?,
420    CLI_CONFIG_SAMPLE,
421    CLI_CONFIG_MIGRATION,
422  )
423}
424
425/// Load `server.toml`. Fails loudly if the file is missing; the error
426/// message includes a minimal example and (when a legacy config.toml is
427/// detected) a field-by-field migration mapping.
428///
429/// Reads the file at [`get_server_config_file_path`] and layers
430/// `MANTA_*`-prefixed environment variables on top (env wins).
431///
432/// # Errors
433///
434/// - [`MantaError::NotFound`] when the resolved config file does not
435///   exist on disk; the message embeds a minimal `server.toml`
436///   example and (if applicable) a migration mapping.
437/// - [`MantaError::MissingField`] if the resolved path is not valid
438///   UTF-8.
439/// - [`MantaError::ConfigError`] on TOML parse, env-var type-coercion,
440///   or builder failures.
441///
442/// [`MantaError::NotFound`]: crate::common::error::MantaError::NotFound
443/// [`MantaError::MissingField`]: crate::common::error::MantaError::MissingField
444/// [`MantaError::ConfigError`]: crate::common::error::MantaError::ConfigError
445pub fn get_server_configuration() -> Result<Config, Error> {
446  load_config(
447    "Server",
448    get_server_config_file_path()?,
449    SERVER_CONFIG_SAMPLE,
450    SERVER_CONFIG_MIGRATION,
451  )
452}
453
454#[cfg(test)]
455mod tests {
456  use super::*;
457  use std::io::Write;
458  use std::sync::Mutex;
459  use tempfile::NamedTempFile;
460
461  // The MANTA_* env vars are process-global; tests that mutate them
462  // must serialise on this lock or they'll race each other under
463  // cargo's default parallel test runner.
464  static ENV_LOCK: Mutex<()> = Mutex::new(());
465
466  /// Guard that sets the named env var on construction and clears it on
467  /// drop. Use inside a test holding `ENV_LOCK` so concurrent tests
468  /// don't see the half-installed value.
469  struct EnvGuard(&'static str);
470  impl EnvGuard {
471    fn set(key: &'static str, value: &str) -> Self {
472      // SAFETY: serialised by `ENV_LOCK` above.
473      unsafe { std::env::set_var(key, value) };
474      Self(key)
475    }
476  }
477  impl Drop for EnvGuard {
478    fn drop(&mut self) {
479      unsafe { std::env::remove_var(self.0) };
480    }
481  }
482
483  fn write_tmp_toml(content: &str) -> NamedTempFile {
484    let mut f = NamedTempFile::new().expect("tempfile");
485    f.write_all(content.as_bytes()).expect("write tempfile");
486    f
487  }
488
489  #[test]
490  fn default_cli_config_path_ends_with_cli_toml() {
491    let path = get_default_manta_cli_config_file_path().unwrap();
492    assert_eq!(path.file_name().unwrap(), "cli.toml");
493  }
494
495  #[test]
496  fn default_server_config_path_ends_with_server_toml() {
497    let path = get_default_manta_server_config_file_path().unwrap();
498    assert_eq!(path.file_name().unwrap(), "server.toml");
499  }
500
501  #[test]
502  fn default_legacy_config_path_ends_with_config_toml() {
503    let path = get_default_manta_config_file_path().unwrap();
504    assert_eq!(path.file_name().unwrap(), "config.toml");
505  }
506
507  #[test]
508  fn cli_and_server_default_paths_share_parent() {
509    let cli = get_default_manta_cli_config_file_path().unwrap();
510    let server = get_default_manta_server_config_file_path().unwrap();
511    assert_eq!(cli.parent(), server.parent());
512  }
513
514  #[test]
515  fn cli_config_file_path_honors_env_var() {
516    let _g = ENV_LOCK.lock().unwrap();
517    let _e = EnvGuard::set("MANTA_CLI_CONFIG", "/tmp/custom-cli.toml");
518    let path = get_cli_config_file_path().unwrap();
519    assert_eq!(path, PathBuf::from("/tmp/custom-cli.toml"));
520  }
521
522  #[test]
523  fn server_config_file_path_honors_env_var() {
524    let _g = ENV_LOCK.lock().unwrap();
525    let _e = EnvGuard::set("MANTA_SERVER_CONFIG", "/tmp/custom-server.toml");
526    let path = get_server_config_file_path().unwrap();
527    assert_eq!(path, PathBuf::from("/tmp/custom-server.toml"));
528  }
529
530  #[test]
531  fn cli_configuration_with_missing_file_returns_notfound() {
532    let _g = ENV_LOCK.lock().unwrap();
533    let _e = EnvGuard::set(
534      "MANTA_CLI_CONFIG",
535      "/nonexistent-dir/definitely-not-here.toml",
536    );
537    let err = get_cli_configuration().unwrap_err();
538    match err {
539      Error::NotFound(msg) => {
540        assert!(
541          msg.contains("CLI configuration file"),
542          "expected helpful NotFound message, got: {msg}"
543        );
544        assert!(
545          msg.contains("Minimal example"),
546          "expected sample TOML in message"
547        );
548      }
549      other => panic!("expected NotFound, got {other:?}"),
550    }
551  }
552
553  #[test]
554  fn cli_configuration_with_malformed_toml_returns_config_error() {
555    let _g = ENV_LOCK.lock().unwrap();
556    let bad = write_tmp_toml("this is = not [valid toml");
557    let _e = EnvGuard::set("MANTA_CLI_CONFIG", bad.path().to_str().unwrap());
558    let err = get_cli_configuration().unwrap_err();
559    assert!(
560      matches!(err, Error::ConfigError(_)),
561      "expected ConfigError variant, got {err:?}"
562    );
563  }
564
565  #[test]
566  fn cli_configuration_loads_valid_toml_and_env_var_overrides_file() {
567    let _g = ENV_LOCK.lock().unwrap();
568    let good = write_tmp_toml(
569      r#"log = "info"
570site = "alps"
571manta_server_url = "https://example:8443"
572"#,
573    );
574    let _path =
575      EnvGuard::set("MANTA_CLI_CONFIG", good.path().to_str().unwrap());
576
577    let cfg = get_cli_configuration().unwrap();
578    assert_eq!(cfg.get_string("log").unwrap(), "info");
579    assert_eq!(cfg.get_string("site").unwrap(), "alps");
580    drop(cfg);
581
582    // Set a MANTA_*-prefixed env var; the `Environment` source should
583    // merge over the file value.
584    let _override = EnvGuard::set("MANTA_LOG", "trace");
585    let cfg = get_cli_configuration().unwrap();
586    assert_eq!(
587      cfg.get_string("log").unwrap(),
588      "trace",
589      "env var should override file value"
590    );
591  }
592
593  #[test]
594  fn server_configuration_with_missing_file_returns_notfound() {
595    let _g = ENV_LOCK.lock().unwrap();
596    let _e = EnvGuard::set(
597      "MANTA_SERVER_CONFIG",
598      "/nonexistent-dir/missing-server.toml",
599    );
600    let err = get_server_configuration().unwrap_err();
601    match err {
602      Error::NotFound(msg) => {
603        assert!(
604          msg.contains("Server configuration file"),
605          "expected helpful NotFound message, got: {msg}"
606        );
607      }
608      other => panic!("expected NotFound, got {other:?}"),
609    }
610  }
611
612  #[test]
613  fn server_configuration_loads_valid_toml() {
614    let _g = ENV_LOCK.lock().unwrap();
615    let good = write_tmp_toml(
616      r#"log = "info"
617
618[server]
619listen_address = "0.0.0.0"
620port = 8443
621cert = "/etc/manta/cert.pem"
622key = "/etc/manta/key.pem"
623"#,
624    );
625    let _e =
626      EnvGuard::set("MANTA_SERVER_CONFIG", good.path().to_str().unwrap());
627    let cfg = get_server_configuration().unwrap();
628    assert_eq!(cfg.get_string("server.listen_address").unwrap(), "0.0.0.0");
629    assert_eq!(cfg.get_int("server.port").unwrap(), 8443);
630  }
631}