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 the file-system paths, env-var overrides
4//! (`MANTA_CLI_CONFIG`, `MANTA_SERVER_CONFIG`), and the loader
5//! functions that parse a config and merge `MANTA_*`-prefixed
6//! environment variables. See [`get_cli_configuration`] and
7//! [`get_server_configuration`] for the canonical entry points.
8//! The typed deserialisation targets live with each binary:
9//! `CliConfiguration` in `manta-cli`, `ServerConfiguration` in
10//! `manta-server`.
11
12use std::{
13  fs::{self, File},
14  io::{Read, Write},
15  path::PathBuf,
16};
17
18use crate::common::error::MantaError as Error;
19use config::Config;
20use directories::ProjectDirs;
21use toml_edit::DocumentMut;
22
23/// Returns the XDG-compliant `ProjectDirs` for manta.
24///
25/// All path helpers in this module delegate to this function
26/// so the qualifier/organization/application triple is defined
27/// in exactly one place.
28fn get_project_dirs() -> Result<ProjectDirs, Error> {
29  ProjectDirs::from(
30    "local", /*qualifier*/
31    "cscs",  /*organization*/
32    "manta", /*application*/
33  )
34  .ok_or_else(|| {
35    Error::MissingField(
36      "Could not determine project directories \
37       (home directory may not be set)"
38        .to_string(),
39    )
40  })
41}
42
43/// Returns the default manta config directory path
44/// (e.g. `~/.config/manta/`).
45pub fn get_default_config_path() -> Result<PathBuf, Error> {
46  Ok(PathBuf::from(get_project_dirs()?.config_dir()))
47}
48
49/// Returns the path of the *legacy* unified config file
50/// (e.g. `~/.config/manta/config.toml`). Used only by
51/// `missing_config_message` to detect when a user is migrating from
52/// the pre-split layout — neither binary ever reads from this path
53/// at startup.
54pub fn get_default_manta_config_file_path() -> Result<PathBuf, Error> {
55  let mut path = get_default_config_path()?;
56  path.push("config.toml");
57  Ok(path)
58}
59
60/// Returns the default CLI config file path
61/// (e.g. `~/.config/manta/cli.toml`).
62pub fn get_default_manta_cli_config_file_path() -> Result<PathBuf, Error> {
63  let mut path = get_default_config_path()?;
64  path.push("cli.toml");
65  Ok(path)
66}
67
68/// Returns the default server config file path
69/// (e.g. `~/.config/manta/server.toml`).
70pub fn get_default_manta_server_config_file_path() -> Result<PathBuf, Error> {
71  let mut path = get_default_config_path()?;
72  path.push("server.toml");
73  Ok(path)
74}
75
76/// Returns the default manta cache directory path
77/// (e.g. `~/.cache/manta/`).
78pub fn get_default_cache_path() -> Result<PathBuf, Error> {
79  Ok(PathBuf::from(get_project_dirs()?.cache_dir()))
80}
81
82/// Reads the manta CLI configuration file (`cli.toml`) and parses it as
83/// TOML, honoring `MANTA_CLI_CONFIG`.
84///
85/// Returns both the file path (for later writing) and the
86/// parsed `DocumentMut`.
87pub fn read_config_toml() -> Result<(PathBuf, DocumentMut), Error> {
88  let path = get_cli_config_file_path()?;
89
90  tracing::debug!(
91    "Reading manta CLI configuration from {}",
92    path.to_string_lossy()
93  );
94
95  let content = fs::read_to_string(&path)?;
96
97  let doc = content.parse::<DocumentMut>()?;
98
99  Ok((path, doc))
100}
101
102/// Writes a `DocumentMut` back to the manta configuration file.
103pub fn write_config_toml(
104  path: &std::path::Path,
105  doc: &DocumentMut,
106) -> Result<(), Error> {
107  let mut file = std::fs::OpenOptions::new()
108    .write(true)
109    .truncate(true)
110    .open(path)?;
111
112  file.write_all(doc.to_string().as_bytes())?;
113  file.flush()?;
114
115  Ok(())
116}
117
118/// Read the root CA certificate from `file_path`, falling
119/// back to the default config directory if the path is
120/// relative.
121pub fn get_csm_root_cert_content(file_path: &str) -> Result<Vec<u8>, Error> {
122  let mut buf = Vec::new();
123  let root_cert_file_rslt = File::open(file_path);
124
125  let file_rslt = if root_cert_file_rslt.is_err() {
126    let mut config_path = get_default_config_path()?;
127    config_path.push(file_path);
128    File::open(config_path)
129  } else {
130    root_cert_file_rslt
131  };
132
133  match file_rslt {
134    Ok(mut file) => {
135      file.read_to_end(&mut buf)?;
136      Ok(buf)
137    }
138    Err(_) => Err(Error::NotFound(
139      "CA public root file could not be found".to_string(),
140    )),
141  }
142}
143
144/// Returns the CLI config file path, honoring `MANTA_CLI_CONFIG` if set.
145pub fn get_cli_config_file_path() -> Result<PathBuf, Error> {
146  if let Ok(env_path) = std::env::var("MANTA_CLI_CONFIG") {
147    Ok(PathBuf::from(env_path))
148  } else {
149    get_default_manta_cli_config_file_path()
150  }
151}
152
153/// Returns the server config file path, honoring `MANTA_SERVER_CONFIG` if set.
154pub fn get_server_config_file_path() -> Result<PathBuf, Error> {
155  if let Ok(env_path) = std::env::var("MANTA_SERVER_CONFIG") {
156    Ok(PathBuf::from(env_path))
157  } else {
158    get_default_manta_server_config_file_path()
159  }
160}
161
162/// Minimal CLI config sample shown in the NotFound error.
163const CLI_CONFIG_SAMPLE: &str = r#"log = "info"
164site = "<site_name>"
165parent_hsm_group = ""
166manta_server_url = "https://manta-server.example.com:8443"
167
168[sites.<site_name>]
169backend = "csm"                 # or "ochami"
170shasta_base_url = "https://api.example.com"
171root_ca_cert_file = "alps_root_cert.pem"
172"#;
173
174/// Migration mapping shown when a legacy `config.toml` is detected.
175const CLI_CONFIG_MIGRATION: &str = "\
176Migration from ~/.config/manta/config.toml:
177  copy these fields verbatim:        log, site, parent_hsm_group,
178                                     auditor, sites
179  add CLI-only (now required):       manta_server_url = \"https://...\"
180                                     (CLI talks only to the manta server)
181  drop (no longer recognised):       sites.<X>.manta_server_url, audit_file
182  do not copy (server-only fields):  the [server] section belongs in
183                                     server.toml, not cli.toml";
184
185/// Minimal server config sample shown in the NotFound error.
186const SERVER_CONFIG_SAMPLE: &str = r#"log = "info"
187
188[server]
189listen_address = "0.0.0.0"
190port = 8443
191cert = "/path/to/server.crt"
192key = "/path/to/server.key"
193console_inactivity_timeout_secs = 1800
194auth_rate_limit_per_minute = 60       # per source IP for /auth/*; omit to disable
195
196[sites.<site_name>]
197backend = "csm"
198shasta_base_url = "https://api.example.com"
199root_ca_cert_file = "/path/to/alps_root_cert.pem"
200"#;
201
202/// Migration mapping shown when a legacy `config.toml` is detected.
203const SERVER_CONFIG_MIGRATION: &str = "\
204Migration from ~/.config/manta/config.toml:
205  copy these fields verbatim:        log, auditor, sites
206  add new [server] section:          listen_address, port, cert, key,
207                                     console_inactivity_timeout_secs
208  drop (CLI-only):                   site, parent_hsm_group, hsm_group,
209                                     manta_server_url
210  drop (no longer recognised):       sites.<X>.manta_server_url, audit_file";
211
212fn missing_config_message(
213  binary: &str,
214  expected_path: &std::path::Path,
215  sample: &str,
216  migration: &str,
217) -> String {
218  let legacy_exists = get_default_manta_config_file_path()
219    .map(|p| p.exists())
220    .unwrap_or(false);
221  let mut msg = format!(
222    "{binary} configuration file '{}' not found.\n\nMinimal example:\n\n{sample}",
223    expected_path.to_string_lossy()
224  );
225  if legacy_exists {
226    msg.push('\n');
227    msg.push_str(migration);
228  }
229  msg
230}
231
232/// Load `cli.toml`. Fails loudly if the file is missing; the error
233/// message includes a minimal example and (when a legacy config.toml is
234/// detected) a field-by-field migration mapping.
235pub fn get_cli_configuration() -> Result<Config, Error> {
236  let path = get_cli_config_file_path()?;
237  if !path.exists() {
238    return Err(Error::NotFound(missing_config_message(
239      "CLI",
240      &path,
241      CLI_CONFIG_SAMPLE,
242      CLI_CONFIG_MIGRATION,
243    )));
244  }
245  let path_str = path.to_str().ok_or_else(|| {
246    Error::MissingField(
247      "CLI configuration file path contains invalid UTF-8".to_string(),
248    )
249  })?;
250  ::config::Config::builder()
251    .add_source(::config::File::new(path_str, ::config::FileFormat::Toml))
252    .add_source(
253      ::config::Environment::with_prefix("MANTA")
254        .try_parsing(true)
255        .prefix_separator("_"),
256    )
257    .build()
258    .map_err(Error::ConfigError)
259}
260
261/// Load `server.toml`. Fails loudly if the file is missing; the error
262/// message includes a minimal example and (when a legacy config.toml is
263/// detected) a field-by-field migration mapping.
264pub fn get_server_configuration() -> Result<Config, Error> {
265  let path = get_server_config_file_path()?;
266  if !path.exists() {
267    return Err(Error::NotFound(missing_config_message(
268      "Server",
269      &path,
270      SERVER_CONFIG_SAMPLE,
271      SERVER_CONFIG_MIGRATION,
272    )));
273  }
274  let path_str = path.to_str().ok_or_else(|| {
275    Error::MissingField(
276      "Server configuration file path contains invalid UTF-8".to_string(),
277    )
278  })?;
279  ::config::Config::builder()
280    .add_source(::config::File::new(path_str, ::config::FileFormat::Toml))
281    .add_source(
282      ::config::Environment::with_prefix("MANTA")
283        .try_parsing(true)
284        .prefix_separator("_"),
285    )
286    .build()
287    .map_err(Error::ConfigError)
288}
289
290#[cfg(test)]
291mod tests {
292  use super::*;
293  use std::io::Write;
294  use std::sync::Mutex;
295  use tempfile::NamedTempFile;
296
297  // The MANTA_* env vars are process-global; tests that mutate them
298  // must serialise on this lock or they'll race each other under
299  // cargo's default parallel test runner.
300  static ENV_LOCK: Mutex<()> = Mutex::new(());
301
302  /// Guard that sets the named env var on construction and clears it on
303  /// drop. Use inside a test holding `ENV_LOCK` so concurrent tests
304  /// don't see the half-installed value.
305  struct EnvGuard(&'static str);
306  impl EnvGuard {
307    fn set(key: &'static str, value: &str) -> Self {
308      // SAFETY: serialised by `ENV_LOCK` above.
309      unsafe { std::env::set_var(key, value) };
310      Self(key)
311    }
312  }
313  impl Drop for EnvGuard {
314    fn drop(&mut self) {
315      unsafe { std::env::remove_var(self.0) };
316    }
317  }
318
319  fn write_tmp_toml(content: &str) -> NamedTempFile {
320    let mut f = NamedTempFile::new().expect("tempfile");
321    f.write_all(content.as_bytes()).expect("write tempfile");
322    f
323  }
324
325  #[test]
326  fn default_cli_config_path_ends_with_cli_toml() {
327    let path = get_default_manta_cli_config_file_path().unwrap();
328    assert_eq!(path.file_name().unwrap(), "cli.toml");
329  }
330
331  #[test]
332  fn default_server_config_path_ends_with_server_toml() {
333    let path = get_default_manta_server_config_file_path().unwrap();
334    assert_eq!(path.file_name().unwrap(), "server.toml");
335  }
336
337  #[test]
338  fn default_legacy_config_path_ends_with_config_toml() {
339    let path = get_default_manta_config_file_path().unwrap();
340    assert_eq!(path.file_name().unwrap(), "config.toml");
341  }
342
343  #[test]
344  fn cli_and_server_default_paths_share_parent() {
345    let cli = get_default_manta_cli_config_file_path().unwrap();
346    let server = get_default_manta_server_config_file_path().unwrap();
347    assert_eq!(cli.parent(), server.parent());
348  }
349
350  #[test]
351  fn cli_config_file_path_honors_env_var() {
352    let _g = ENV_LOCK.lock().unwrap();
353    let _e = EnvGuard::set("MANTA_CLI_CONFIG", "/tmp/custom-cli.toml");
354    let path = get_cli_config_file_path().unwrap();
355    assert_eq!(path, PathBuf::from("/tmp/custom-cli.toml"));
356  }
357
358  #[test]
359  fn server_config_file_path_honors_env_var() {
360    let _g = ENV_LOCK.lock().unwrap();
361    let _e = EnvGuard::set("MANTA_SERVER_CONFIG", "/tmp/custom-server.toml");
362    let path = get_server_config_file_path().unwrap();
363    assert_eq!(path, PathBuf::from("/tmp/custom-server.toml"));
364  }
365
366  #[test]
367  fn cli_configuration_with_missing_file_returns_notfound() {
368    let _g = ENV_LOCK.lock().unwrap();
369    let _e = EnvGuard::set(
370      "MANTA_CLI_CONFIG",
371      "/nonexistent-dir/definitely-not-here.toml",
372    );
373    let err = get_cli_configuration().unwrap_err();
374    match err {
375      Error::NotFound(msg) => {
376        assert!(
377          msg.contains("CLI configuration file"),
378          "expected helpful NotFound message, got: {msg}"
379        );
380        assert!(
381          msg.contains("Minimal example"),
382          "expected sample TOML in message"
383        );
384      }
385      other => panic!("expected NotFound, got {other:?}"),
386    }
387  }
388
389  #[test]
390  fn cli_configuration_with_malformed_toml_returns_config_error() {
391    let _g = ENV_LOCK.lock().unwrap();
392    let bad = write_tmp_toml("this is = not [valid toml");
393    let _e = EnvGuard::set("MANTA_CLI_CONFIG", bad.path().to_str().unwrap());
394    let err = get_cli_configuration().unwrap_err();
395    assert!(
396      matches!(err, Error::ConfigError(_)),
397      "expected ConfigError variant, got {err:?}"
398    );
399  }
400
401  #[test]
402  fn cli_configuration_loads_valid_toml_and_env_var_overrides_file() {
403    let _g = ENV_LOCK.lock().unwrap();
404    let good = write_tmp_toml(
405      r#"log = "info"
406site = "alps"
407parent_hsm_group = ""
408manta_server_url = "https://example:8443"
409"#,
410    );
411    let _path =
412      EnvGuard::set("MANTA_CLI_CONFIG", good.path().to_str().unwrap());
413
414    let cfg = get_cli_configuration().unwrap();
415    assert_eq!(cfg.get_string("log").unwrap(), "info");
416    assert_eq!(cfg.get_string("site").unwrap(), "alps");
417    drop(cfg);
418
419    // Set a MANTA_*-prefixed env var; the `Environment` source should
420    // merge over the file value.
421    let _override = EnvGuard::set("MANTA_LOG", "trace");
422    let cfg = get_cli_configuration().unwrap();
423    assert_eq!(
424      cfg.get_string("log").unwrap(),
425      "trace",
426      "env var should override file value"
427    );
428  }
429
430  #[test]
431  fn server_configuration_with_missing_file_returns_notfound() {
432    let _g = ENV_LOCK.lock().unwrap();
433    let _e = EnvGuard::set(
434      "MANTA_SERVER_CONFIG",
435      "/nonexistent-dir/missing-server.toml",
436    );
437    let err = get_server_configuration().unwrap_err();
438    match err {
439      Error::NotFound(msg) => {
440        assert!(
441          msg.contains("Server configuration file"),
442          "expected helpful NotFound message, got: {msg}"
443        );
444      }
445      other => panic!("expected NotFound, got {other:?}"),
446    }
447  }
448
449  #[test]
450  fn server_configuration_loads_valid_toml() {
451    let _g = ENV_LOCK.lock().unwrap();
452    let good = write_tmp_toml(
453      r#"log = "info"
454
455[server]
456listen_address = "0.0.0.0"
457port = 8443
458cert = "/etc/manta/cert.pem"
459key = "/etc/manta/key.pem"
460"#,
461    );
462    let _e =
463      EnvGuard::set("MANTA_SERVER_CONFIG", good.path().to_str().unwrap());
464    let cfg = get_server_configuration().unwrap();
465    assert_eq!(cfg.get_string("server.listen_address").unwrap(), "0.0.0.0");
466    assert_eq!(cfg.get_int("server.port").unwrap(), 8443);
467  }
468}