Skip to main content

manta_shared/common/config/
mod.rs

1//! Config-file loaders for `cli.toml` and `server.toml`.
2//!
3//! The typed schema lives in [`types`]; this module owns the
4//! file-system paths, env-var overrides
5//! (`MANTA_CLI_CONFIG`, `MANTA_SERVER_CONFIG`), and the loader
6//! functions that parse a config and merge `MANTA_*`-prefixed
7//! environment variables. See [`get_cli_configuration`] and
8//! [`get_server_configuration`] for the canonical entry points.
9
10pub mod types;
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"
164audit_file = "/tmp/manta-cli-audit.log"
165site = "<site_name>"
166parent_hsm_group = ""
167manta_server_url = "https://manta-server.example.com:8443"
168
169[sites.<site_name>]
170backend = "csm"                 # or "ochami"
171shasta_base_url = "https://api.example.com"
172root_ca_cert_file = "alps_root_cert.pem"
173"#;
174
175/// Migration mapping shown when a legacy `config.toml` is detected.
176const CLI_CONFIG_MIGRATION: &str = "\
177Migration from ~/.config/manta/config.toml:
178  copy these fields verbatim:        log, audit_file, site, parent_hsm_group,
179                                     auditor, sites
180  add CLI-only (now required):       manta_server_url = \"https://...\"
181                                     (CLI talks only to the manta server)
182  drop (no longer recognised):       sites.<X>.manta_server_url
183  do not copy (server-only fields):  the [server] section belongs in
184                                     server.toml, not cli.toml";
185
186/// Minimal server config sample shown in the NotFound error.
187const SERVER_CONFIG_SAMPLE: &str = r#"log = "info"
188audit_file = "/var/log/manta/server-audit.log"
189
190[server]
191listen_address = "0.0.0.0"
192port = 8443
193cert = "/path/to/server.crt"
194key = "/path/to/server.key"
195console_inactivity_timeout_secs = 1800
196auth_rate_limit_per_minute = 60       # per source IP for /auth/*; omit to disable
197
198[sites.<site_name>]
199backend = "csm"
200shasta_base_url = "https://api.example.com"
201root_ca_cert_file = "/path/to/alps_root_cert.pem"
202"#;
203
204/// Migration mapping shown when a legacy `config.toml` is detected.
205const SERVER_CONFIG_MIGRATION: &str = "\
206Migration from ~/.config/manta/config.toml:
207  copy these fields verbatim:        log, audit_file, auditor, sites
208  add new [server] section:          listen_address, port, cert, key,
209                                     console_inactivity_timeout_secs
210  drop (CLI-only):                   site, parent_hsm_group, hsm_group,
211                                     manta_server_url
212  drop (no longer recognised):       sites.<X>.manta_server_url";
213
214fn missing_config_message(
215  binary: &str,
216  expected_path: &std::path::Path,
217  sample: &str,
218  migration: &str,
219) -> String {
220  let legacy_exists = get_default_manta_config_file_path()
221    .map(|p| p.exists())
222    .unwrap_or(false);
223  let mut msg = format!(
224    "{binary} configuration file '{}' not found.\n\nMinimal example:\n\n{sample}",
225    expected_path.to_string_lossy()
226  );
227  if legacy_exists {
228    msg.push('\n');
229    msg.push_str(migration);
230  }
231  msg
232}
233
234/// Load `cli.toml`. Fails loudly if the file is missing; the error
235/// message includes a minimal example and (when a legacy config.toml is
236/// detected) a field-by-field migration mapping.
237pub fn get_cli_configuration() -> Result<Config, Error> {
238  let path = get_cli_config_file_path()?;
239  if !path.exists() {
240    return Err(Error::NotFound(missing_config_message(
241      "CLI",
242      &path,
243      CLI_CONFIG_SAMPLE,
244      CLI_CONFIG_MIGRATION,
245    )));
246  }
247  let path_str = path.to_str().ok_or_else(|| {
248    Error::MissingField(
249      "CLI configuration file path contains invalid UTF-8".to_string(),
250    )
251  })?;
252  ::config::Config::builder()
253    .add_source(::config::File::new(path_str, ::config::FileFormat::Toml))
254    .add_source(
255      ::config::Environment::with_prefix("MANTA")
256        .try_parsing(true)
257        .prefix_separator("_"),
258    )
259    .build()
260    .map_err(Error::ConfigError)
261}
262
263/// Load `server.toml`. Fails loudly if the file is missing; the error
264/// message includes a minimal example and (when a legacy config.toml is
265/// detected) a field-by-field migration mapping.
266pub fn get_server_configuration() -> Result<Config, Error> {
267  let path = get_server_config_file_path()?;
268  if !path.exists() {
269    return Err(Error::NotFound(missing_config_message(
270      "Server",
271      &path,
272      SERVER_CONFIG_SAMPLE,
273      SERVER_CONFIG_MIGRATION,
274    )));
275  }
276  let path_str = path.to_str().ok_or_else(|| {
277    Error::MissingField(
278      "Server configuration file path contains invalid UTF-8".to_string(),
279    )
280  })?;
281  ::config::Config::builder()
282    .add_source(::config::File::new(path_str, ::config::FileFormat::Toml))
283    .add_source(
284      ::config::Environment::with_prefix("MANTA")
285        .try_parsing(true)
286        .prefix_separator("_"),
287    )
288    .build()
289    .map_err(Error::ConfigError)
290}
291
292#[cfg(test)]
293mod tests {
294  use super::*;
295  use std::io::Write;
296  use std::sync::Mutex;
297  use tempfile::NamedTempFile;
298
299  // The MANTA_* env vars are process-global; tests that mutate them
300  // must serialise on this lock or they'll race each other under
301  // cargo's default parallel test runner.
302  static ENV_LOCK: Mutex<()> = Mutex::new(());
303
304  /// Guard that sets the named env var on construction and clears it on
305  /// drop. Use inside a test holding `ENV_LOCK` so concurrent tests
306  /// don't see the half-installed value.
307  struct EnvGuard(&'static str);
308  impl EnvGuard {
309    fn set(key: &'static str, value: &str) -> Self {
310      // SAFETY: serialised by `ENV_LOCK` above.
311      unsafe { std::env::set_var(key, value) };
312      Self(key)
313    }
314  }
315  impl Drop for EnvGuard {
316    fn drop(&mut self) {
317      unsafe { std::env::remove_var(self.0) };
318    }
319  }
320
321  fn write_tmp_toml(content: &str) -> NamedTempFile {
322    let mut f = NamedTempFile::new().expect("tempfile");
323    f.write_all(content.as_bytes()).expect("write tempfile");
324    f
325  }
326
327  #[test]
328  fn default_cli_config_path_ends_with_cli_toml() {
329    let path = get_default_manta_cli_config_file_path().unwrap();
330    assert_eq!(path.file_name().unwrap(), "cli.toml");
331  }
332
333  #[test]
334  fn default_server_config_path_ends_with_server_toml() {
335    let path = get_default_manta_server_config_file_path().unwrap();
336    assert_eq!(path.file_name().unwrap(), "server.toml");
337  }
338
339  #[test]
340  fn default_legacy_config_path_ends_with_config_toml() {
341    let path = get_default_manta_config_file_path().unwrap();
342    assert_eq!(path.file_name().unwrap(), "config.toml");
343  }
344
345  #[test]
346  fn cli_and_server_default_paths_share_parent() {
347    let cli = get_default_manta_cli_config_file_path().unwrap();
348    let server = get_default_manta_server_config_file_path().unwrap();
349    assert_eq!(cli.parent(), server.parent());
350  }
351
352  #[test]
353  fn cli_config_file_path_honors_env_var() {
354    let _g = ENV_LOCK.lock().unwrap();
355    let _e = EnvGuard::set("MANTA_CLI_CONFIG", "/tmp/custom-cli.toml");
356    let path = get_cli_config_file_path().unwrap();
357    assert_eq!(path, PathBuf::from("/tmp/custom-cli.toml"));
358  }
359
360  #[test]
361  fn server_config_file_path_honors_env_var() {
362    let _g = ENV_LOCK.lock().unwrap();
363    let _e = EnvGuard::set("MANTA_SERVER_CONFIG", "/tmp/custom-server.toml");
364    let path = get_server_config_file_path().unwrap();
365    assert_eq!(path, PathBuf::from("/tmp/custom-server.toml"));
366  }
367
368  #[test]
369  fn cli_configuration_with_missing_file_returns_notfound() {
370    let _g = ENV_LOCK.lock().unwrap();
371    let _e = EnvGuard::set(
372      "MANTA_CLI_CONFIG",
373      "/nonexistent-dir/definitely-not-here.toml",
374    );
375    let err = get_cli_configuration().unwrap_err();
376    match err {
377      Error::NotFound(msg) => {
378        assert!(
379          msg.contains("CLI configuration file"),
380          "expected helpful NotFound message, got: {msg}"
381        );
382        assert!(
383          msg.contains("Minimal example"),
384          "expected sample TOML in message"
385        );
386      }
387      other => panic!("expected NotFound, got {other:?}"),
388    }
389  }
390
391  #[test]
392  fn cli_configuration_with_malformed_toml_returns_config_error() {
393    let _g = ENV_LOCK.lock().unwrap();
394    let bad = write_tmp_toml("this is = not [valid toml");
395    let _e = EnvGuard::set("MANTA_CLI_CONFIG", bad.path().to_str().unwrap());
396    let err = get_cli_configuration().unwrap_err();
397    assert!(
398      matches!(err, Error::ConfigError(_)),
399      "expected ConfigError variant, got {err:?}"
400    );
401  }
402
403  #[test]
404  fn cli_configuration_loads_valid_toml_and_env_var_overrides_file() {
405    let _g = ENV_LOCK.lock().unwrap();
406    let good = write_tmp_toml(
407      r#"log = "info"
408audit_file = "/tmp/x.log"
409site = "alps"
410parent_hsm_group = ""
411manta_server_url = "https://example:8443"
412"#,
413    );
414    let _path =
415      EnvGuard::set("MANTA_CLI_CONFIG", good.path().to_str().unwrap());
416
417    let cfg = get_cli_configuration().unwrap();
418    assert_eq!(cfg.get_string("log").unwrap(), "info");
419    assert_eq!(cfg.get_string("site").unwrap(), "alps");
420    drop(cfg);
421
422    // Set a MANTA_*-prefixed env var; the `Environment` source should
423    // merge over the file value.
424    let _override = EnvGuard::set("MANTA_LOG", "trace");
425    let cfg = get_cli_configuration().unwrap();
426    assert_eq!(
427      cfg.get_string("log").unwrap(),
428      "trace",
429      "env var should override file value"
430    );
431  }
432
433  #[test]
434  fn server_configuration_with_missing_file_returns_notfound() {
435    let _g = ENV_LOCK.lock().unwrap();
436    let _e = EnvGuard::set(
437      "MANTA_SERVER_CONFIG",
438      "/nonexistent-dir/missing-server.toml",
439    );
440    let err = get_server_configuration().unwrap_err();
441    match err {
442      Error::NotFound(msg) => {
443        assert!(
444          msg.contains("Server configuration file"),
445          "expected helpful NotFound message, got: {msg}"
446        );
447      }
448      other => panic!("expected NotFound, got {other:?}"),
449    }
450  }
451
452  #[test]
453  fn server_configuration_loads_valid_toml() {
454    let _g = ENV_LOCK.lock().unwrap();
455    let good = write_tmp_toml(
456      r#"log = "info"
457audit_file = "/tmp/y.log"
458
459[server]
460listen_address = "0.0.0.0"
461port = 8443
462cert = "/etc/manta/cert.pem"
463key = "/etc/manta/key.pem"
464"#,
465    );
466    let _e =
467      EnvGuard::set("MANTA_SERVER_CONFIG", good.path().to_str().unwrap());
468    let cfg = get_server_configuration().unwrap();
469    assert_eq!(cfg.get_string("server.listen_address").unwrap(), "0.0.0.0");
470    assert_eq!(cfg.get_int("server.port").unwrap(), 8443);
471  }
472}