Skip to main content

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: also produce the audit trail to Kafka
323# brokers            = ["kafka.example.com:9092"]
324# topic              = "manta-audit"
325# message_timeout_ms = 5000              # how long a queued event retries delivery; default 5000
326# [auditor.kafka.properties]             # optional librdkafka passthrough (TLS/SASL settings)
327# "security.protocol" = "SASL_SSL"
328
329[sites.<site_name>]
330backend = "csm"
331shasta_base_url = "https://api.example.com"
332root_ca_cert_file = "/path/to/alps_root_cert.pem"
333"#;
334
335/// Migration mapping shown when a legacy `config.toml` is detected.
336const SERVER_CONFIG_MIGRATION: &str = "\
337Migration from ~/.config/manta/config.toml:
338  copy these fields verbatim:        log, auditor, sites
339  add new [server] section:          listen_address, port, cert, key,
340                                     console_inactivity_timeout_secs
341  drop (CLI-only):                   site, hsm_group, manta_server_url
342  drop (no longer recognised):       sites.<X>.manta_server_url, audit_file";
343
344fn missing_config_message(
345  binary: &str,
346  expected_path: &std::path::Path,
347  sample: &str,
348  migration: &str,
349) -> String {
350  let legacy_exists = get_default_manta_config_file_path()
351    .map(|p| p.exists())
352    .unwrap_or(false);
353  let mut msg = format!(
354    "{binary} configuration file '{}' not found.\n\nMinimal example:\n\n{sample}",
355    expected_path.to_string_lossy()
356  );
357  if legacy_exists {
358    msg.push('\n');
359    msg.push_str(migration);
360  }
361  msg
362}
363
364/// Shared TOML + env-var loading logic.
365///
366/// Checks that `path` exists, converts it to a UTF-8 string, then
367/// runs the standard `Config::builder` chain (file + `MANTA_*` env
368/// vars). `label` is used only in error messages ("CLI" or "Server").
369fn load_config(
370  label: &str,
371  path: PathBuf,
372  sample: &str,
373  migration: &str,
374) -> Result<Config, Error> {
375  if !path.exists() {
376    return Err(Error::NotFound(missing_config_message(
377      label, &path, sample, migration,
378    )));
379  }
380  let path_str = path.to_str().ok_or_else(|| {
381    Error::MissingField(format!(
382      "{label} configuration file path contains invalid UTF-8"
383    ))
384  })?;
385  ::config::Config::builder()
386    .add_source(::config::File::new(path_str, ::config::FileFormat::Toml))
387    .add_source(
388      ::config::Environment::with_prefix("MANTA")
389        .try_parsing(true)
390        .prefix_separator("_"),
391    )
392    .build()
393    .map_err(Error::ConfigError)
394}
395
396/// Load `cli.toml`. Fails loudly if the file is missing; the error
397/// message includes a minimal example and (when a legacy config.toml is
398/// detected) a field-by-field migration mapping.
399///
400/// Reads the file at [`get_cli_config_file_path`] and layers
401/// `MANTA_*`-prefixed environment variables on top (env wins).
402///
403/// # Errors
404///
405/// - [`MantaError::NotFound`] when the resolved config file does not
406///   exist on disk; the message embeds [`CLI_CONFIG_SAMPLE`-equivalent]
407///   guidance.
408/// - [`MantaError::MissingField`] if the resolved path is not valid
409///   UTF-8 (required by the underlying `config` crate).
410/// - [`MantaError::ConfigError`] on TOML parse, env-var type-coercion,
411///   or builder failures.
412///
413/// [`CLI_CONFIG_SAMPLE`-equivalent]: self
414/// [`MantaError::NotFound`]: crate::common::error::MantaError::NotFound
415/// [`MantaError::MissingField`]: crate::common::error::MantaError::MissingField
416/// [`MantaError::ConfigError`]: crate::common::error::MantaError::ConfigError
417pub fn get_cli_configuration() -> Result<Config, Error> {
418  load_config(
419    "CLI",
420    get_cli_config_file_path()?,
421    CLI_CONFIG_SAMPLE,
422    CLI_CONFIG_MIGRATION,
423  )
424}
425
426/// Load `server.toml`. Fails loudly if the file is missing; the error
427/// message includes a minimal example and (when a legacy config.toml is
428/// detected) a field-by-field migration mapping.
429///
430/// Reads the file at [`get_server_config_file_path`] and layers
431/// `MANTA_*`-prefixed environment variables on top (env wins).
432///
433/// # Errors
434///
435/// - [`MantaError::NotFound`] when the resolved config file does not
436///   exist on disk; the message embeds a minimal `server.toml`
437///   example and (if applicable) a migration mapping.
438/// - [`MantaError::MissingField`] if the resolved path is not valid
439///   UTF-8.
440/// - [`MantaError::ConfigError`] on TOML parse, env-var type-coercion,
441///   or builder failures.
442///
443/// [`MantaError::NotFound`]: crate::common::error::MantaError::NotFound
444/// [`MantaError::MissingField`]: crate::common::error::MantaError::MissingField
445/// [`MantaError::ConfigError`]: crate::common::error::MantaError::ConfigError
446pub fn get_server_configuration() -> Result<Config, Error> {
447  load_config(
448    "Server",
449    get_server_config_file_path()?,
450    SERVER_CONFIG_SAMPLE,
451    SERVER_CONFIG_MIGRATION,
452  )
453}
454
455#[cfg(test)]
456mod tests {
457  use super::*;
458  use std::io::Write;
459  use std::sync::Mutex;
460  use tempfile::NamedTempFile;
461
462  // The MANTA_* env vars are process-global; tests that mutate them
463  // must serialise on this lock or they'll race each other under
464  // cargo's default parallel test runner.
465  static ENV_LOCK: Mutex<()> = Mutex::new(());
466
467  /// Guard that sets the named env var on construction and clears it on
468  /// drop. Use inside a test holding `ENV_LOCK` so concurrent tests
469  /// don't see the half-installed value.
470  struct EnvGuard(&'static str);
471  impl EnvGuard {
472    fn set(key: &'static str, value: &str) -> Self {
473      // SAFETY: serialised by `ENV_LOCK` above.
474      unsafe { std::env::set_var(key, value) };
475      Self(key)
476    }
477  }
478  impl Drop for EnvGuard {
479    fn drop(&mut self) {
480      unsafe { std::env::remove_var(self.0) };
481    }
482  }
483
484  fn write_tmp_toml(content: &str) -> NamedTempFile {
485    let mut f = NamedTempFile::new().expect("tempfile");
486    f.write_all(content.as_bytes()).expect("write tempfile");
487    f
488  }
489
490  #[test]
491  fn default_cli_config_path_ends_with_cli_toml() {
492    let path = get_default_manta_cli_config_file_path().unwrap();
493    assert_eq!(path.file_name().unwrap(), "cli.toml");
494  }
495
496  #[test]
497  fn default_server_config_path_ends_with_server_toml() {
498    let path = get_default_manta_server_config_file_path().unwrap();
499    assert_eq!(path.file_name().unwrap(), "server.toml");
500  }
501
502  #[test]
503  fn default_legacy_config_path_ends_with_config_toml() {
504    let path = get_default_manta_config_file_path().unwrap();
505    assert_eq!(path.file_name().unwrap(), "config.toml");
506  }
507
508  #[test]
509  fn cli_and_server_default_paths_share_parent() {
510    let cli = get_default_manta_cli_config_file_path().unwrap();
511    let server = get_default_manta_server_config_file_path().unwrap();
512    assert_eq!(cli.parent(), server.parent());
513  }
514
515  #[test]
516  fn cli_config_file_path_honors_env_var() {
517    let _g = ENV_LOCK.lock().unwrap();
518    let _e = EnvGuard::set("MANTA_CLI_CONFIG", "/tmp/custom-cli.toml");
519    let path = get_cli_config_file_path().unwrap();
520    assert_eq!(path, PathBuf::from("/tmp/custom-cli.toml"));
521  }
522
523  #[test]
524  fn server_config_file_path_honors_env_var() {
525    let _g = ENV_LOCK.lock().unwrap();
526    let _e = EnvGuard::set("MANTA_SERVER_CONFIG", "/tmp/custom-server.toml");
527    let path = get_server_config_file_path().unwrap();
528    assert_eq!(path, PathBuf::from("/tmp/custom-server.toml"));
529  }
530
531  #[test]
532  fn cli_configuration_with_missing_file_returns_notfound() {
533    let _g = ENV_LOCK.lock().unwrap();
534    let _e = EnvGuard::set(
535      "MANTA_CLI_CONFIG",
536      "/nonexistent-dir/definitely-not-here.toml",
537    );
538    let err = get_cli_configuration().unwrap_err();
539    match err {
540      Error::NotFound(msg) => {
541        assert!(
542          msg.contains("CLI configuration file"),
543          "expected helpful NotFound message, got: {msg}"
544        );
545        assert!(
546          msg.contains("Minimal example"),
547          "expected sample TOML in message"
548        );
549      }
550      other => panic!("expected NotFound, got {other:?}"),
551    }
552  }
553
554  #[test]
555  fn cli_configuration_with_malformed_toml_returns_config_error() {
556    let _g = ENV_LOCK.lock().unwrap();
557    let bad = write_tmp_toml("this is = not [valid toml");
558    let _e = EnvGuard::set("MANTA_CLI_CONFIG", bad.path().to_str().unwrap());
559    let err = get_cli_configuration().unwrap_err();
560    assert!(
561      matches!(err, Error::ConfigError(_)),
562      "expected ConfigError variant, got {err:?}"
563    );
564  }
565
566  #[test]
567  fn cli_configuration_loads_valid_toml_and_env_var_overrides_file() {
568    let _g = ENV_LOCK.lock().unwrap();
569    let good = write_tmp_toml(
570      r#"log = "info"
571site = "alps"
572manta_server_url = "https://example:8443"
573"#,
574    );
575    let _path =
576      EnvGuard::set("MANTA_CLI_CONFIG", good.path().to_str().unwrap());
577
578    let cfg = get_cli_configuration().unwrap();
579    assert_eq!(cfg.get_string("log").unwrap(), "info");
580    assert_eq!(cfg.get_string("site").unwrap(), "alps");
581    drop(cfg);
582
583    // Set a MANTA_*-prefixed env var; the `Environment` source should
584    // merge over the file value.
585    let _override = EnvGuard::set("MANTA_LOG", "trace");
586    let cfg = get_cli_configuration().unwrap();
587    assert_eq!(
588      cfg.get_string("log").unwrap(),
589      "trace",
590      "env var should override file value"
591    );
592  }
593
594  #[test]
595  fn server_configuration_with_missing_file_returns_notfound() {
596    let _g = ENV_LOCK.lock().unwrap();
597    let _e = EnvGuard::set(
598      "MANTA_SERVER_CONFIG",
599      "/nonexistent-dir/missing-server.toml",
600    );
601    let err = get_server_configuration().unwrap_err();
602    match err {
603      Error::NotFound(msg) => {
604        assert!(
605          msg.contains("Server configuration file"),
606          "expected helpful NotFound message, got: {msg}"
607        );
608      }
609      other => panic!("expected NotFound, got {other:?}"),
610    }
611  }
612
613  #[test]
614  fn server_configuration_loads_valid_toml() {
615    let _g = ENV_LOCK.lock().unwrap();
616    let good = write_tmp_toml(
617      r#"log = "info"
618
619[server]
620listen_address = "0.0.0.0"
621port = 8443
622cert = "/etc/manta/cert.pem"
623key = "/etc/manta/key.pem"
624"#,
625    );
626    let _e =
627      EnvGuard::set("MANTA_SERVER_CONFIG", good.path().to_str().unwrap());
628    let cfg = get_server_configuration().unwrap();
629    assert_eq!(cfg.get_string("server.listen_address").unwrap(), "0.0.0.0");
630    assert_eq!(cfg.get_int("server.port").unwrap(), 8443);
631  }
632}