taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

config.rs (49476B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025-2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::{
     18     borrow::Cow, fs::Permissions, os::unix::fs::PermissionsExt, path::PathBuf, str::FromStr,
     19     sync::Arc, time::Duration,
     20 };
     21 
     22 use compact_str::{CompactString, ToCompactString};
     23 use indexmap::IndexMap;
     24 use jiff::{SignedDuration, Span};
     25 use url::Url;
     26 
     27 use crate::types::{
     28     amount::{Amount, Currency},
     29     payto::PaytoURI,
     30     validate_base_url,
     31 };
     32 
     33 pub mod parser {
     34     use std::{
     35         borrow::Cow,
     36         fmt::Display,
     37         io::{BufRead, BufReader},
     38         path::PathBuf,
     39         str::FromStr,
     40         sync::Arc,
     41     };
     42 
     43     use indexmap::IndexMap;
     44     use tracing::{trace, warn};
     45 
     46     use super::Config;
     47     use crate::config::{CfgErr, Inner, Line, Location, make_lowercase};
     48 
     49     fn io_err(
     50         action: impl Into<Cow<'static, str>>,
     51         path: impl Into<PathBuf>,
     52         err: std::io::Error,
     53     ) -> CfgErr {
     54         CfgErr::IO(action.into(), path.into(), err)
     55     }
     56     fn line_err(
     57         msg: impl Into<Cow<'static, str>>,
     58         path: impl Into<PathBuf>,
     59         line: usize,
     60     ) -> CfgErr {
     61         CfgErr::Line(msg.into(), path.into(), line, None)
     62     }
     63     fn line_cause_err(
     64         msg: impl Into<Cow<'static, str>>,
     65         cause: impl Display,
     66         path: impl Into<PathBuf>,
     67         line: usize,
     68     ) -> CfgErr {
     69         CfgErr::Line(msg.into(), path.into(), line, Some(cause.to_string()))
     70     }
     71     pub struct Parser {
     72         sections: IndexMap<String, IndexMap<String, Line>>,
     73         files: Vec<PathBuf>,
     74         install_path: PathBuf,
     75         buf: String,
     76     }
     77 
     78     impl Parser {
     79         pub fn empty() -> Self {
     80             Self {
     81                 sections: IndexMap::new(),
     82                 files: Vec::new(),
     83                 install_path: PathBuf::new(),
     84                 buf: String::new(),
     85             }
     86         }
     87 
     88         pub fn load_env(&mut self, src: ConfigSource) -> Result<(), CfgErr> {
     89             let ConfigSource { project_name, .. } = src;
     90 
     91             // Load default path
     92             let dir = src
     93                 .install_path()
     94                 .map_err(|(p, e)| io_err("find installation path", p, e))?;
     95             self.install_path = dir.clone();
     96 
     97             let paths = IndexMap::from_iter(
     98                 [
     99                     ("PREFIX", dir.join("")),
    100                     ("BINDIR", dir.join("bin")),
    101                     ("LIBEXECDIR", dir.join(project_name).join("libexec")),
    102                     ("DOCDIR", dir.join("share").join("doc").join(project_name)),
    103                     ("ICONDIR", dir.join("bin").join("share").join("icons")),
    104                     ("LOCALEDIR", dir.join("share").join("locale")),
    105                     ("LIBDIR", dir.join("lib").join(project_name)),
    106                     ("DATADIR", dir.join("share").join(project_name)),
    107                 ]
    108                 .map(|(a, b)| {
    109                     (
    110                         a.to_owned(),
    111                         Line {
    112                             content: b.to_string_lossy().into_owned(),
    113                             loc: None,
    114                         },
    115                     )
    116                 }),
    117             );
    118             self.sections.insert("paths".to_owned(), paths);
    119 
    120             // Load default configs
    121             let cfg_dir = dir.join("share").join(project_name).join("config.d");
    122             match std::fs::read_dir(&cfg_dir) {
    123                 Ok(entries) => {
    124                     for entry in entries {
    125                         match entry {
    126                             Ok(entry) => self.parse_file(entry.path(), 0)?,
    127                             Err(err) => {
    128                                 warn!(target: "config", "{}", io_err("read base config directory", &cfg_dir, err));
    129                             }
    130                         }
    131                     }
    132                 }
    133                 Err(err) => {
    134                     warn!(target: "config", "{}", io_err("read base config directory", &cfg_dir, err))
    135                 }
    136             }
    137 
    138             Ok(())
    139         }
    140 
    141         pub fn parse_str(&mut self, str: &str) -> Result<(), CfgErr> {
    142             self.parse(
    143                 std::io::Cursor::new(str),
    144                 PathBuf::from_str("mem").unwrap(),
    145                 0,
    146             )
    147         }
    148 
    149         pub fn parse_file(&mut self, src: PathBuf, depth: u8) -> Result<(), CfgErr> {
    150             trace!(target: "config", "load file at '{}'", src.to_string_lossy());
    151             match std::fs::File::open(&src) {
    152                 Ok(file) => self.parse(BufReader::new(file), src, depth + 1),
    153                 Err(e) => Err(io_err("read config", src, e)),
    154             }
    155         }
    156 
    157         fn parse<B: BufRead>(
    158             &mut self,
    159             mut reader: B,
    160             src: PathBuf,
    161             depth: u8,
    162         ) -> Result<(), CfgErr> {
    163             let file = self.files.len();
    164             self.files.push(src.clone());
    165             let src = &src;
    166 
    167             let mut current_section: Option<&mut IndexMap<String, Line>> = None;
    168             let mut line = 0;
    169 
    170             loop {
    171                 // Read a new line
    172                 line += 1;
    173                 self.buf.clear();
    174                 match reader.read_line(&mut self.buf) {
    175                     Ok(0) => break,
    176                     Ok(_) => {}
    177                     Err(e) => return Err(io_err("read config", src, e)),
    178                 }
    179                 // Trim whitespace
    180                 let l = self.buf.trim_ascii();
    181 
    182                 if l.is_empty() || l.starts_with(['#', '%']) {
    183                     // Skip empty lines and comments
    184                     continue;
    185                 } else if let Some(directive) = l.strip_prefix("@") {
    186                     // Parse directive
    187                     let Some((name, arg)) = directive.split_once('@') else {
    188                         return Err(line_err(format!("invalid directive line '{l}'"), src, line));
    189                     };
    190                     let arg = arg.trim_ascii_start();
    191                     // Exit current section
    192                     current_section = None;
    193                     // Check current file has a parent
    194                     let Some(parent) = src.parent() else {
    195                         return Err(line_err("no parent", src, line));
    196                     };
    197                     // Check recursion depth
    198                     if depth > 128 {
    199                         return Err(line_err("recursion limit in config inlining", src, line));
    200                     }
    201 
    202                     match make_lowercase(name).as_ref() {
    203                         "inline" => self.parse_file(parent.join(arg), depth)?,
    204                         "inline-matching" => {
    205                             let paths =
    206                                 glob::glob(&parent.join(arg).to_string_lossy()).map_err(|e| {
    207                                     line_cause_err("malformed glob regex", e, src, line)
    208                                 })?;
    209                             for path in paths {
    210                                 let path =
    211                                     path.map_err(|e| line_cause_err("Glob error", e, src, line))?;
    212                                 self.parse_file(path, depth)?;
    213                             }
    214                         }
    215                         "inline-secret" => {
    216                             let (section, secret_file) = arg.split_once(" ").ok_or_else(||
    217                                 line_err(
    218                                     "invalid configuration, @inline-secret@ directive requires exactly two arguments",
    219                                     src,
    220                                     line
    221                                 )
    222                             )?;
    223 
    224                             let section = section.to_lowercase();
    225                             let mut secret_cfg = Parser::empty();
    226 
    227                             if let Err(e) = secret_cfg.parse_file(parent.join(secret_file), depth) {
    228                                 if let CfgErr::IO(_, path, err) = e {
    229                                     warn!(target: "config", "{}", io_err(format!("read secret section [{section}]"), &path, err))
    230                                 } else {
    231                                     return Err(e);
    232                                 }
    233                             } else if let Some(secret_section) =
    234                                 secret_cfg.sections.swap_remove(&section)
    235                             {
    236                                 self.sections
    237                                     .entry(section)
    238                                     .or_default()
    239                                     .extend(secret_section);
    240                             } else {
    241                                 warn!(target: "config", "{}", line_err(format!("configuration file at '{secret_file}' loaded with @inline-secret@ does not contain section [{section}]"), src, line));
    242                             }
    243                         }
    244                         unknown => {
    245                             return Err(line_err(
    246                                 format!("invalid directive '{unknown}'"),
    247                                 src,
    248                                 line,
    249                             ));
    250                         }
    251                     }
    252                 } else if let Some(section) = l.strip_prefix('[').and_then(|l| l.strip_suffix(']'))
    253                 {
    254                     current_section =
    255                         Some(self.sections.entry(section.to_lowercase()).or_default());
    256                 } else if let Some((name, value)) = l.split_once('=') {
    257                     if let Some(current_section) = &mut current_section {
    258                         // Trim whitespace
    259                         let name = name.trim_ascii_end().to_uppercase();
    260                         let value = value.trim_ascii_start();
    261                         // Escape value
    262                         let value =
    263                             if value.len() > 1 && value.starts_with('"') && value.ends_with('"') {
    264                                 &value[1..value.len() - 1]
    265                             } else {
    266                                 value
    267                             };
    268                         current_section.insert(
    269                             name,
    270                             Line {
    271                                 content: value.to_owned(),
    272                                 loc: Some(Location { file, line }),
    273                             },
    274                         );
    275                     } else {
    276                         return Err(line_err("expected section header or directive", src, line));
    277                     }
    278                 } else {
    279                     return Err(line_err(
    280                         "expected section header, option assignment or directive",
    281                         src,
    282                         line,
    283                     ));
    284                 }
    285             }
    286             Ok(())
    287         }
    288 
    289         /// Get a read-only shareable Config from the parser
    290         pub fn finish(self) -> Config {
    291             // Convert to a read-only config struct without location info
    292             Config(Arc::new(Inner {
    293                 sections: self.sections,
    294                 files: self.files,
    295                 install_path: self.install_path,
    296             }))
    297         }
    298     }
    299 
    300     /** Information about how the configuration is loaded */
    301     #[derive(Debug, Clone, Copy)]
    302     pub struct ConfigSource {
    303         /** Name of the high-level project */
    304         pub project_name: &'static str,
    305         /** Name of the component within the package */
    306         pub component_name: &'static str,
    307         /**
    308          * Executable name that will be located on $PATH to
    309          * find the installation path of the package
    310          */
    311         pub exec_name: &'static str,
    312     }
    313 
    314     impl ConfigSource {
    315         /// Create a new config source
    316         pub const fn new(
    317             project_name: &'static str,
    318             component_name: &'static str,
    319             exec_name: &'static str,
    320         ) -> Self {
    321             Self {
    322                 project_name,
    323                 component_name,
    324                 exec_name,
    325             }
    326         }
    327 
    328         /// Create a config source where the project, component and exec names are the same
    329         pub const fn simple(name: &'static str) -> Self {
    330             Self::new(name, name, name)
    331         }
    332 
    333         /**
    334          * Search the default configuration file path
    335          *
    336          * I will be the first existing file from this list:
    337          * - $XDG_CONFIG_HOME/$componentName.conf
    338          * - $HOME/.config/$componentName.conf
    339          * - /etc/$componentName.conf
    340          * - /etc/$projectName/$componentName.conf
    341          * */
    342         fn default_config_path(&self) -> Result<Option<PathBuf>, (PathBuf, std::io::Error)> {
    343             // TODO use a generator
    344             let conf_name = format!("{}.conf", self.component_name);
    345 
    346             if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
    347                 let path = PathBuf::from(xdg).join(&conf_name);
    348                 match path.try_exists() {
    349                     Ok(false) => {}
    350                     Ok(true) => return Ok(Some(path)),
    351                     Err(e) => return Err((path, e)),
    352                 }
    353             }
    354 
    355             if let Some(home) = std::env::var_os("HOME") {
    356                 let path = PathBuf::from(home).join(".config").join(&conf_name);
    357                 match path.try_exists() {
    358                     Ok(false) => {}
    359                     Ok(true) => return Ok(Some(path)),
    360                     Err(e) => return Err((path, e)),
    361                 }
    362             }
    363 
    364             let path = PathBuf::from("/etc").join(&conf_name);
    365             match path.try_exists() {
    366                 Ok(false) => {}
    367                 Ok(true) => return Ok(Some(path)),
    368                 Err(e) => return Err((path, e)),
    369             }
    370 
    371             let path = PathBuf::from("/etc")
    372                 .join(self.project_name)
    373                 .join(&conf_name);
    374             match path.try_exists() {
    375                 Ok(false) => {}
    376                 Ok(true) => return Ok(Some(path)),
    377                 Err(e) => return Err((path, e)),
    378             }
    379 
    380             Ok(None)
    381         }
    382 
    383         /** Search for the binary installation path in PATH */
    384         fn install_path(&self) -> Result<PathBuf, (PathBuf, std::io::Error)> {
    385             let path_env = std::env::var("PATH").unwrap_or_default();
    386             for path_dir in path_env.split(':') {
    387                 let path_dir = PathBuf::from(path_dir);
    388                 let bin_path = path_dir.join(self.exec_name);
    389                 if bin_path.exists()
    390                     && let Some(parent) = path_dir.parent()
    391                 {
    392                     return parent.canonicalize().map_err(|e| (parent.to_path_buf(), e));
    393                 }
    394             }
    395             Ok(PathBuf::from("/usr"))
    396         }
    397     }
    398 
    399     impl Config {
    400         /// Load a config for a Taler component, optionally also load from a file.
    401         /// This is the standard way to load a Taler component config
    402         pub fn load(src: ConfigSource, path: Option<impl Into<PathBuf>>) -> Result<Config, CfgErr> {
    403             let mut parser = Parser::empty();
    404             parser.load_env(src)?;
    405             match path {
    406                 Some(path) => parser.parse_file(path.into(), 0)?,
    407                 None => {
    408                     if let Some(default) = src
    409                         .default_config_path()
    410                         .map_err(|(p, e)| io_err("find default config path", p, e))?
    411                     {
    412                         parser.parse_file(default, 0)?;
    413                     }
    414                 }
    415             }
    416             Ok(parser.finish())
    417         }
    418 
    419         /// Load config from an in memory string for testing
    420         pub fn from_mem(str: &str) -> Result<Config, CfgErr> {
    421             let mut parser = Parser::empty();
    422             parser.parse_str(str)?;
    423             Ok(parser.finish())
    424         }
    425 
    426         /// Load config from an in memory string with env from a Taler component for testing
    427         pub fn from_mem_with_env(src: ConfigSource, str: &str) -> Result<Config, CfgErr> {
    428             let mut parser = Parser::empty();
    429             parser.load_env(src)?;
    430             parser.parse_str(str)?;
    431             Ok(parser.finish())
    432         }
    433 
    434         /// Load a config for a Taler component, optionally also load from a file and an in memory string, for testing
    435         pub fn from_file_override(
    436             src: ConfigSource,
    437             path: Option<impl Into<PathBuf>>,
    438             str: &str,
    439         ) -> Result<Config, CfgErr> {
    440             let mut parser = Parser::empty();
    441             parser.load_env(src)?;
    442             match path {
    443                 Some(path) => {
    444                     parser.parse_file(path.into(), 0)?;
    445                 }
    446                 None => {
    447                     if let Some(default) = src
    448                         .default_config_path()
    449                         .map_err(|(p, e)| io_err("find default config path", p, e))?
    450                     {
    451                         parser.parse_file(default, 0)?;
    452                     }
    453                 }
    454             }
    455             parser.parse_str(str)?;
    456             Ok(parser.finish())
    457         }
    458     }
    459 }
    460 
    461 #[macro_export]
    462 macro_rules! config_bail {
    463     ($msg:literal $(,)?) => {
    464         return Err($crate::config::CfgErr::Custom(format!($msg)))
    465     };
    466     ($err:expr $(,)?) => {
    467         return Err($crate::config::CfgErr::Custom(format!($err)))
    468     };
    469     ($fmt:expr, $($arg:tt)*) => {
    470         return Err($crate::config::CfgErr::Custom(format!($fmt, $($arg)*)))
    471     };
    472 }
    473 
    474 /// DD102: will fail with exit code 6 and will not be restarted by systemd
    475 #[derive(Debug)]
    476 pub enum CfgErr {
    477     IO(Cow<'static, str>, PathBuf, std::io::Error),
    478     Line(Cow<'static, str>, PathBuf, usize, Option<String>),
    479     Missing {
    480         ty: CompactString,
    481         section: CompactString,
    482         option: CompactString,
    483     },
    484     Invalid {
    485         ty: Option<CompactString>,
    486         section: CompactString,
    487         option: CompactString,
    488         err: String,
    489     },
    490     Custom(String),
    491 }
    492 
    493 impl std::error::Error for CfgErr {}
    494 
    495 impl std::fmt::Display for CfgErr {
    496     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    497         match self {
    498             CfgErr::IO(action, path, err) => write!(
    499                 f,
    500                 "could not {action} at '{}': {}",
    501                 path.to_string_lossy(),
    502                 err.kind()
    503             ),
    504             CfgErr::Line(msg, path, line, cause) => {
    505                 if let Some(cause) = cause {
    506                     write!(f, "{msg} at '{}:{line}': {cause}", path.to_string_lossy())
    507                 } else {
    508                     write!(f, "{msg} at '{}:{line}'", path.to_string_lossy())
    509                 }
    510             }
    511             CfgErr::Missing {
    512                 ty,
    513                 section,
    514                 option,
    515             } => write!(f, "missing {ty} option {option} in section [{section}]"),
    516             CfgErr::Invalid {
    517                 ty,
    518                 section,
    519                 option,
    520                 err,
    521             } => {
    522                 f.write_str("invalid ")?;
    523                 if let Some(ty) = ty {
    524                     write!(f, "{ty} ")?;
    525                 }
    526                 write!(f, "option {option} in section [{section}]: {err}")
    527             }
    528             CfgErr::Custom(e) => e.fmt(f),
    529         }
    530     }
    531 }
    532 
    533 impl CfgErr {
    534     pub fn custom(cause: impl std::fmt::Display) -> Self {
    535         Self::Custom(cause.to_string())
    536     }
    537 }
    538 
    539 #[derive(Debug, thiserror::Error)]
    540 pub enum PathsubErr {
    541     #[error("recursion limit in path substitution exceeded for '{0}'")]
    542     Recursion(String),
    543     #[error("unbalanced variable expression '{0}'")]
    544     Unbalanced(String),
    545     #[error("bad substitution '{0}'")]
    546     Substitution(String),
    547     #[error("unbound variable '{0}'")]
    548     Unbound(String),
    549 }
    550 
    551 #[derive(Debug, Clone)]
    552 struct Location {
    553     file: usize,
    554     line: usize,
    555 }
    556 
    557 #[derive(Debug, Clone)]
    558 struct Line {
    559     content: String,
    560     loc: Option<Location>,
    561 }
    562 
    563 #[derive(Debug)]
    564 struct Inner {
    565     sections: IndexMap<String, IndexMap<String, Line>>,
    566     files: Vec<PathBuf>,
    567     install_path: PathBuf,
    568 }
    569 
    570 #[derive(Clone)]
    571 pub struct Config(Arc<Inner>);
    572 
    573 impl std::fmt::Debug for Config {
    574     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    575         self.0.fmt(f)
    576     }
    577 }
    578 
    579 fn make_lowercase<'a>(s: &'a str) -> Cow<'a, str> {
    580     if s.chars().all(|c| c.is_ascii_lowercase()) {
    581         Cow::Borrowed(s)
    582     } else {
    583         Cow::Owned(s.to_ascii_lowercase())
    584     }
    585 }
    586 
    587 fn make_uppercase<'a>(s: &'a str) -> Cow<'a, str> {
    588     if s.chars().all(|c| c.is_ascii_uppercase()) {
    589         Cow::Borrowed(s)
    590     } else {
    591         Cow::Owned(s.to_ascii_uppercase())
    592     }
    593 }
    594 
    595 impl Config {
    596     /// Get a config section from its name
    597     pub fn section<'cfg, 'arg>(&'cfg self, name: &'arg str) -> Section<'cfg, 'arg> {
    598         Section {
    599             name,
    600             config: self,
    601             values: self.0.sections.get(make_lowercase(name).as_ref()),
    602         }
    603     }
    604 
    605     /// List all config sections
    606     pub fn sections<'cfg>(&'cfg self) -> impl Iterator<Item = Section<'cfg, 'cfg>> {
    607         self.0.sections.iter().map(|(name, values)| Section {
    608             name,
    609             config: self,
    610             values: Some(values),
    611         })
    612     }
    613 
    614     /**
    615      * Substitute ${...} and $... placeholders in a string
    616      * with values from the PATHS section in the
    617      * configuration and environment variables
    618      *
    619      * This substitution is typically only done for paths.
    620      */
    621     pub fn pathsub(&self, str: &str, depth: u8) -> Result<String, PathsubErr> {
    622         if depth > 128 {
    623             return Err(PathsubErr::Recursion(str.to_owned()));
    624         } else if !str.contains('$') {
    625             return Ok(str.to_owned());
    626         }
    627 
    628         /** Lookup for variable value from PATHS section in the configuration and environment variables */
    629         fn lookup(cfg: &Config, name: &str, depth: u8) -> Option<Result<String, PathsubErr>> {
    630             if let Some(path_res) = cfg
    631                 .0
    632                 .sections
    633                 .get("paths")
    634                 .and_then(|section| section.get(make_uppercase(name).as_ref()))
    635             {
    636                 return Some(cfg.pathsub(&path_res.content, depth + 1));
    637             }
    638 
    639             if let Ok(val) = std::env::var(name) {
    640                 return Some(Ok(val));
    641             }
    642             None
    643         }
    644 
    645         let mut result = String::new();
    646         let mut remaining = str;
    647         loop {
    648             // Look for the next variable
    649             let Some((normal, value)) = remaining.split_once('$') else {
    650                 result.push_str(remaining);
    651                 return Ok(result);
    652             };
    653 
    654             // Append normal character
    655             result.push_str(normal);
    656             remaining = value;
    657 
    658             // Check if variable is enclosed
    659             let is_enclosed = if let Some(enclosed) = remaining.strip_prefix('{') {
    660                 // ${var
    661                 remaining = enclosed;
    662                 true
    663             } else {
    664                 false // $var
    665             };
    666 
    667             // Extract variable name
    668             let name_end = remaining
    669                 .find(|c: char| !c.is_alphanumeric() && c != '_')
    670                 .unwrap_or(remaining.len());
    671             let (name, after_name) = remaining.split_at(name_end);
    672 
    673             // Extract variable default if enclosed
    674             let default = if !is_enclosed {
    675                 remaining = after_name;
    676                 None
    677             } else if let Some(after_enclosed) = after_name.strip_prefix('}') {
    678                 // ${var}
    679                 remaining = after_enclosed;
    680                 None
    681             } else if let Some(default) = after_name.strip_prefix(":-") {
    682                 // ${var:-default}
    683                 let mut depth = 1;
    684                 let Some((default, after_default)) = default.split_once(|c| {
    685                     if c == '{' {
    686                         depth += 1;
    687                         false
    688                     } else if c == '}' {
    689                         depth -= 1;
    690                         depth == 0
    691                     } else {
    692                         false
    693                     }
    694                 }) else {
    695                     return Err(PathsubErr::Unbalanced(default.to_owned()));
    696                 };
    697                 remaining = after_default;
    698                 Some(default)
    699             } else {
    700                 return Err(PathsubErr::Substitution(after_name.to_owned()));
    701             };
    702             if let Some(resolved) = lookup(self, name, depth + 1) {
    703                 result.push_str(&resolved?);
    704                 continue;
    705             } else if let Some(default) = default {
    706                 let resolved = self.pathsub(default, depth + 1)?;
    707                 result.push_str(&resolved);
    708                 continue;
    709             }
    710             return Err(PathsubErr::Unbound(name.to_owned()));
    711         }
    712     }
    713 
    714     /// Print config in a human format, optionally with diagnostics information
    715     pub fn print(&self, mut f: impl std::io::Write, diagnostics: bool) -> std::io::Result<()> {
    716         let Inner {
    717             sections,
    718             files,
    719             install_path,
    720         } = self.0.as_ref();
    721         if diagnostics {
    722             writeln!(f, "#")?;
    723             writeln!(f, "# Configuration file diagnostics")?;
    724             writeln!(f, "#")?;
    725             writeln!(f, "# File Loaded:")?;
    726             for path in files {
    727                 writeln!(f, "# {}", path.to_string_lossy())?;
    728             }
    729             writeln!(f, "#")?;
    730             writeln!(f, "# Installation path: {}", install_path.to_string_lossy())?;
    731             writeln!(f, "#")?;
    732             writeln!(f)?;
    733         }
    734         for (sect, values) in sections {
    735             writeln!(f, "[{sect}]")?;
    736             if diagnostics {
    737                 writeln!(f)?;
    738             }
    739             for (key, Line { content, loc }) in values {
    740                 if diagnostics {
    741                     match loc {
    742                         Some(Location { file, line }) => {
    743                             let path = &files[*file];
    744                             writeln!(f, "# {}:{line}", path.to_string_lossy())?;
    745                         }
    746                         None => writeln!(f, "# default")?,
    747                     }
    748                 }
    749                 writeln!(f, "{key} = {content}")?;
    750                 if diagnostics {
    751                     writeln!(f)?;
    752                 }
    753             }
    754             writeln!(f)?;
    755         }
    756         Ok(())
    757     }
    758 }
    759 
    760 /** Accessor/Converter for Taler-like configuration sections */
    761 pub struct Section<'cfg, 'arg> {
    762     pub name: &'arg str,
    763     config: &'cfg Config,
    764     values: Option<&'cfg IndexMap<String, Line>>,
    765 }
    766 
    767 #[macro_export]
    768 macro_rules! map_config {
    769     ($self:expr, $ty:expr, $option:expr, $($key:expr => $parse:block),*$(,)?) => {
    770         {
    771             let keys = &[$($key,)*];
    772             $self.map($ty, $option, |value| {
    773                 match value {
    774                     $($key => {
    775                         (||Ok($parse))().map_err(|e| $crate::config::MapErr::Err(e))
    776                     })*,
    777                     _ => Err($crate::config::MapErr::Invalid(keys))
    778                 }
    779             })
    780         }
    781     }
    782 }
    783 
    784 pub use map_config;
    785 
    786 #[doc(hidden)]
    787 pub enum MapErr {
    788     Invalid(&'static [&'static str]),
    789     Err(CfgErr),
    790 }
    791 
    792 impl<'cfg, 'arg> Section<'cfg, 'arg> {
    793     /// Report a logical validation error for an option in this section.
    794     pub fn invalid(&self, option: &str, err: impl std::fmt::Display) -> CfgErr {
    795         CfgErr::Invalid {
    796             ty: None,
    797             section: self.name.to_compact_string().to_lowercase(),
    798             option: option.to_compact_string().to_uppercase(),
    799             err: err.to_string(),
    800         }
    801     }
    802 
    803     #[doc(hidden)]
    804     fn inner<T>(
    805         &self,
    806         ty: &'arg str,
    807         option: &'arg str,
    808         transform: impl FnOnce(&'cfg str) -> Result<T, CfgErr>,
    809     ) -> Value<'arg, T> {
    810         let value = self
    811             .values
    812             .and_then(|m| m.get(make_uppercase(option).as_ref()))
    813             .filter(|it| !it.content.is_empty())
    814             .map(|raw| transform(&raw.content))
    815             .transpose();
    816         Value {
    817             value,
    818             option,
    819             ty,
    820             section: self.name,
    821         }
    822     }
    823 
    824     #[doc(hidden)]
    825     pub fn map<T>(
    826         &self,
    827         ty: &'arg str,
    828         option: &'arg str,
    829         transform: impl FnOnce(&'cfg str) -> Result<T, MapErr>,
    830     ) -> Value<'arg, T> {
    831         self.inner(ty, option, |v| {
    832             transform(v).map_err(|e| match e {
    833                 MapErr::Invalid(keys) => {
    834                     let mut buf = "expected '".to_owned();
    835                     match keys {
    836                         [] => unreachable!("you must provide at least one mapping"),
    837                         [unique] => buf.push_str(unique),
    838                         [first, other @ .., last] => {
    839                             buf.push_str(first);
    840                             for k in other {
    841                                 buf.push_str("', '");
    842                                 buf.push_str(k);
    843                             }
    844                             buf.push_str("' or '");
    845                             buf.push_str(last);
    846                         }
    847                     }
    848                     buf.push_str("' got '");
    849                     buf.push_str(v);
    850                     buf.push('\'');
    851                     CfgErr::Invalid {
    852                         ty: Some(ty.to_compact_string()),
    853                         section: self.name.to_compact_string().to_lowercase(),
    854                         option: option.to_compact_string().to_uppercase(),
    855                         err: buf,
    856                     }
    857                 }
    858                 MapErr::Err(e) => e,
    859             })
    860         })
    861     }
    862 
    863     /** Setup an accessor/converted for a [type] at [option] using [transform] */
    864     pub fn value<T, E: std::fmt::Display>(
    865         &self,
    866         ty: &'arg str,
    867         option: &'arg str,
    868         transform: impl FnOnce(&'cfg str) -> Result<T, E>,
    869     ) -> Value<'arg, T> {
    870         self.inner(ty, option, |v| {
    871             transform(v).map_err(|e| CfgErr::Invalid {
    872                 ty: Some(ty.to_compact_string()),
    873                 section: self.name.to_compact_string().to_lowercase(),
    874                 option: option.to_compact_string().to_uppercase(),
    875                 err: e.to_string(),
    876             })
    877         })
    878     }
    879 
    880     /** Access [option] as a parsable type */
    881     pub fn parse<E: std::fmt::Display, T: FromStr<Err = E>>(
    882         &self,
    883         ty: &'arg str,
    884         option: &'arg str,
    885     ) -> Value<'arg, T> {
    886         self.value(ty, option, |it| it.parse::<T>().map_err(|e| e.to_string()))
    887     }
    888 
    889     /** Access [option] as str */
    890     pub fn str(&self, option: &'arg str) -> Value<'arg, String> {
    891         self.value("string", option, |it| Ok::<_, &str>(it.to_owned()))
    892     }
    893 
    894     /** Access [option] as compact str */
    895     pub fn cstr(&self, option: &'arg str) -> Value<'arg, CompactString> {
    896         self.value("string", option, |it| Ok::<_, CompactString>(it.into()))
    897     }
    898 
    899     /** Access [option] as hex encode bytes */
    900     pub fn hex(&self, option: &'arg str) -> Value<'arg, Vec<u8>> {
    901         self.value("hex", option, |it| {
    902             crate::encoding::hex::decode(it.as_bytes())
    903         })
    904     }
    905 
    906     /** Access [option] as base32 encode bytes */
    907     pub fn b32(&self, option: &'arg str) -> Value<'arg, Vec<u8>> {
    908         self.value("b32", option, |it| {
    909             crate::encoding::base32::decode(it.as_bytes())
    910         })
    911     }
    912 
    913     /** Access [option] as base64 encode bytes */
    914     pub fn b64(&self, option: &'arg str) -> Value<'arg, Vec<u8>> {
    915         self.value("b64", option, |it| {
    916             crate::encoding::base64::decode(it.as_bytes())
    917         })
    918     }
    919 
    920     /** Access [option] as path */
    921     pub fn path(&self, option: &'arg str) -> Value<'arg, String> {
    922         self.value("path", option, |it| self.config.pathsub(it, 0))
    923     }
    924 
    925     /** Access [option] as UNIX permissions */
    926     pub fn unix_mode(&self, option: &'arg str) -> Value<'arg, Permissions> {
    927         self.value("unix mode", option, |it| {
    928             u32::from_str_radix(it, 8)
    929                 .map(Permissions::from_mode)
    930                 .map_err(|_| format!("'{it}' not a valid number"))
    931         })
    932     }
    933 
    934     /** Access [option] as a number */
    935     pub fn number<T: FromStr>(&self, option: &'arg str) -> Value<'arg, T> {
    936         self.value("number", option, |it| {
    937             it.parse::<T>()
    938                 .map_err(|_| format!("'{it}' not a valid number"))
    939         })
    940     }
    941 
    942     /** Access [option] as Boolean */
    943     pub fn boolean(&self, option: &'arg str) -> Value<'arg, bool> {
    944         self.value("boolean", option, |it| match it.to_uppercase().as_str() {
    945             "YES" => Ok(true),
    946             "NO" => Ok(false),
    947             _ => Err(format!("expected 'YES' or 'NO' got '{it}'")),
    948         })
    949     }
    950 
    951     /** Access [option] as a Currency */
    952     pub fn currency(&self, option: &'arg str) -> Value<'arg, Currency> {
    953         self.parse("currency", option)
    954     }
    955 
    956     /** Access [option] as an Amount */
    957     pub fn amount(&self, option: &'arg str, currency: &Currency) -> Value<'arg, Amount> {
    958         self.value("amount", option, |it| {
    959             let amount = it.parse::<Amount>().map_err(|e| e.to_string())?;
    960             if amount.currency != *currency {
    961                 return Err(format!(
    962                     "expected currency {currency} got {}",
    963                     amount.currency
    964                 ));
    965             }
    966             Ok(amount)
    967         })
    968     }
    969 
    970     /** Access [option] as url */
    971     pub fn url(&self, option: &'arg str) -> Value<'arg, Url> {
    972         self.parse("url", option)
    973     }
    974 
    975     /** Access [option] as base url */
    976     pub fn base_url(&self, option: &'arg str) -> Value<'arg, Url> {
    977         self.value("url", option, |s| {
    978             let url = Url::from_str(s).map_err(|e| e.to_string())?;
    979             validate_base_url(&url)?;
    980             Ok::<_, String>(url)
    981         })
    982     }
    983 
    984     /** Access [option] as payto */
    985     pub fn payto(&self, option: &'arg str) -> Value<'arg, PaytoURI> {
    986         self.parse("payto", option)
    987     }
    988 
    989     /** Access [option] as Postgres URI */
    990     pub fn postgres(&self, option: &'arg str) -> Value<'arg, sqlx::postgres::PgConnectOptions> {
    991         self.parse("Postgres URI", option)
    992     }
    993 
    994     /** Access [option] as a timestamp */
    995     pub fn timestamp(&self, option: &'arg str) -> Value<'arg, jiff::Timestamp> {
    996         self.parse("Timestamp", option)
    997     }
    998 
    999     /** Access [option] as a time */
   1000     pub fn time(&self, option: &'arg str) -> Value<'arg, jiff::civil::Time> {
   1001         self.parse("Time", option)
   1002     }
   1003 
   1004     /** Access [option] as a date */
   1005     pub fn date(&self, option: &'arg str) -> Value<'arg, jiff::civil::Date> {
   1006         self.parse("Date", option)
   1007     }
   1008 
   1009     /** Access [option] as a duration */
   1010     pub fn duration(&self, option: &'arg str) -> Value<'arg, Duration> {
   1011         self.value("temporal", option, |it| {
   1012             let tmp = SignedDuration::from_str(it).map_err(|e| e.to_string())?;
   1013             if tmp < SignedDuration::ZERO {
   1014                 return Err("temporal must not be negative".to_owned());
   1015             }
   1016             let millis =
   1017                 u64::try_from(tmp.as_millis()).map_err(|_| "temporal overflow".to_owned())?;
   1018             Ok::<_, String>(Duration::from_millis(millis))
   1019         })
   1020     }
   1021 
   1022     /** Access [option] as a duration */
   1023     pub fn span(&self, option: &'arg str) -> Value<'arg, Span> {
   1024         self.parse("temporal", option)
   1025     }
   1026 
   1027     /** Access [option] as a regex */
   1028     pub fn regex(&self, option: &'arg str) -> Value<'arg, regex::Regex> {
   1029         self.parse("Pattern", option)
   1030     }
   1031 
   1032     /** Access option as json object */
   1033     pub fn json<'de, T: serde::Deserialize<'de>>(&'de self, option: &'arg str) -> Value<'arg, T> {
   1034         self.value("json", option, |it| serde_json::from_str(it))
   1035     }
   1036 }
   1037 
   1038 pub struct Value<'arg, T> {
   1039     value: Result<Option<T>, CfgErr>,
   1040     option: &'arg str,
   1041     ty: &'arg str,
   1042     section: &'arg str,
   1043 }
   1044 
   1045 impl<T> Value<'_, T> {
   1046     pub fn opt(self) -> Result<Option<T>, CfgErr> {
   1047         self.value
   1048     }
   1049 
   1050     /** Converted value of default if missing */
   1051     pub fn default(self, default: T) -> Result<T, CfgErr> {
   1052         Ok(self.value?.unwrap_or(default))
   1053     }
   1054 
   1055     /** Converted value or throw if missing */
   1056     pub fn require(self) -> Result<T, CfgErr> {
   1057         self.value?.ok_or_else(|| CfgErr::Missing {
   1058             ty: self.ty.to_compact_string(),
   1059             section: self.section.to_compact_string().to_lowercase(),
   1060             option: self.option.to_compact_string().to_uppercase(),
   1061         })
   1062     }
   1063 }
   1064 
   1065 #[cfg(test)]
   1066 mod test {
   1067     use std::{
   1068         fmt::{Debug, Display},
   1069         fs::{File, Permissions},
   1070         os::unix::fs::PermissionsExt,
   1071         time::Duration,
   1072     };
   1073 
   1074     use tracing::error;
   1075 
   1076     use super::{Config, Section, Value};
   1077     use crate::{
   1078         config::parser::ConfigSource,
   1079         types::amount::{self, Currency},
   1080     };
   1081 
   1082     const SOURCE: ConfigSource = ConfigSource::new("test", "test", "test");
   1083 
   1084     #[track_caller]
   1085     fn check_err<T: Debug, E: Display>(err: impl AsRef<str>, lambda: Result<T, E>) {
   1086         let failure = lambda.unwrap_err();
   1087         let fmt = failure.to_string();
   1088         assert_eq!(err.as_ref(), fmt);
   1089     }
   1090 
   1091     /// [`check_err`] for messages with an environment-dependent middle: only
   1092     /// the head and the tail are compared.
   1093     #[track_caller]
   1094     fn check_err_loose<T: Debug, E: Display>(
   1095         head: impl AsRef<str>,
   1096         tail: impl AsRef<str>,
   1097         lambda: Result<T, E>,
   1098     ) {
   1099         let failure = lambda.unwrap_err();
   1100         let fmt = failure.to_string();
   1101         let (head, tail) = (head.as_ref(), tail.as_ref());
   1102         assert!(
   1103             fmt.starts_with(head) && fmt.ends_with(tail),
   1104             "expected an error starting with '{head}' and ending with '{tail}', got '{fmt}'"
   1105         );
   1106     }
   1107 
   1108     #[test]
   1109     fn fs() {
   1110         let dir = tempfile::tempdir().unwrap();
   1111         let config_path = dir.path().join("test-conf.conf");
   1112         let second_path = dir.path().join("test-second-conf.conf");
   1113 
   1114         let config_path_fmt = config_path.to_string_lossy();
   1115         let second_path_fmt = second_path.to_string_lossy();
   1116 
   1117         let check_err = |err: String| check_err(err, Config::load(SOURCE, Some(&config_path)));
   1118         let check_ok = || Config::load(SOURCE, Some(&config_path)).unwrap();
   1119 
   1120         check_err(format!(
   1121             "could not read config at '{config_path_fmt}': entity not found"
   1122         ));
   1123 
   1124         let config_file = std::fs::File::create_new(&config_path).unwrap();
   1125         config_file
   1126             .set_permissions(Permissions::from_mode(0o222))
   1127             .unwrap();
   1128         if File::open(&config_path).is_ok() {
   1129             error!("Cannot finish this test if root");
   1130             return;
   1131         }
   1132         check_err(format!(
   1133             "could not read config at '{config_path_fmt}': permission denied"
   1134         ));
   1135 
   1136         config_file
   1137             .set_permissions(Permissions::from_mode(0o666))
   1138             .unwrap();
   1139         check_ok();
   1140         std::fs::write(&config_path, "@inline@ test-second-conf.conf").unwrap();
   1141         check_err(format!(
   1142             "could not read config at '{second_path_fmt}': entity not found"
   1143         ));
   1144 
   1145         let second_file = std::fs::File::create_new(&second_path).unwrap();
   1146         second_file
   1147             .set_permissions(Permissions::from_mode(0o222))
   1148             .unwrap();
   1149         check_err(format!(
   1150             "could not read config at '{second_path_fmt}': permission denied"
   1151         ));
   1152 
   1153         std::fs::write(&config_path, "@inline-matching@[*").unwrap();
   1154         // glob reports the offset of the '[' within the *expanded* pattern
   1155         // (<tempdir>/[*), so the position moves with the length of $TMPDIR.
   1156         check_err_loose(
   1157             format!(
   1158                 "malformed glob regex at '{config_path_fmt}:1': Pattern syntax error near position "
   1159             ),
   1160             ": invalid range pattern",
   1161             Config::load(SOURCE, Some(&config_path)),
   1162         );
   1163 
   1164         std::fs::write(&config_path, "@inline-matching@*second-conf.conf").unwrap();
   1165         check_err(format!(
   1166             "could not read config at '{second_path_fmt}': permission denied"
   1167         ));
   1168 
   1169         std::fs::write(&config_path, "\n@inline-matching@*.conf").unwrap();
   1170         check_err(format!(
   1171             "recursion limit in config inlining at '{config_path_fmt}:2'"
   1172         ));
   1173         std::fs::write(&config_path, "\n\n@inline-matching@ *.conf").unwrap();
   1174         check_err(format!(
   1175             "recursion limit in config inlining at '{config_path_fmt}:3'"
   1176         ));
   1177 
   1178         std::fs::write(&config_path, "@inline-secret@ secret test-second-conf.conf").unwrap();
   1179         check_ok();
   1180     }
   1181 
   1182     #[test]
   1183     fn parsing() {
   1184         let check = |err: &str, content: &str| check_err(err, Config::from_mem(content));
   1185 
   1186         check(
   1187             "expected section header, option assignment or directive at 'mem:1'",
   1188             "syntax error",
   1189         );
   1190         check(
   1191             "expected section header or directive at 'mem:1'",
   1192             "key=value",
   1193         );
   1194         check(
   1195             "expected section header, option assignment or directive at 'mem:2'",
   1196             "[section]\nbad-line",
   1197         );
   1198 
   1199         let cfg = Config::from_mem(
   1200             r#"
   1201 
   1202         [section-a]
   1203 
   1204         bar = baz
   1205 
   1206         [section-b]
   1207 
   1208         first_value = 1
   1209         second_value = "test"
   1210 
   1211         "#,
   1212         )
   1213         .unwrap();
   1214 
   1215         // Missing section
   1216         check_err(
   1217             "missing string option VALUE in section [unknown]",
   1218             cfg.section("unknown").str("value").require(),
   1219         );
   1220 
   1221         // Missing value
   1222         check_err(
   1223             "missing string option VALUE in section [section-a]",
   1224             cfg.section("section-a").str("value").require(),
   1225         );
   1226     }
   1227 
   1228     #[allow(clippy::type_complexity)]
   1229     fn routine<T: Debug + Eq>(
   1230         ty: &str,
   1231         mut lambda: impl for<'cfg, 'arg> FnMut(&Section<'cfg, 'arg>, &'arg str) -> Value<'arg, T>,
   1232         wellformed: &[(&[&str], T)],
   1233         malformed: &[(&[&str], fn(&str) -> String)],
   1234     ) {
   1235         let conf = |content: &str| {
   1236             Config::from_mem(&format!(
   1237                 "[PATHS]\nDATADIR=mydir\nRECURSIVE=$RECURSIVE\n{content}"
   1238             ))
   1239             .unwrap()
   1240         };
   1241 
   1242         // Check missing msg
   1243         let cfg = conf("");
   1244         check_err(
   1245             format!("missing {ty} option VALUE in section [section]"),
   1246             lambda(&cfg.section("section"), "value").require(),
   1247         );
   1248 
   1249         // Check wellformed options are properly parsed
   1250         for (raws, expected) in wellformed {
   1251             for raw in *raws {
   1252                 let cfg = conf(&format!("[section]\nvalue={raw}"));
   1253                 assert_eq!(
   1254                     *expected,
   1255                     lambda(&cfg.section("section"), "value").require().unwrap()
   1256                 );
   1257             }
   1258         }
   1259 
   1260         // Check malformed options have proper error message
   1261         for (raws, error_fmt) in malformed {
   1262             for raw in *raws {
   1263                 let cfg = conf(&format!("[section]\nvalue={raw}"));
   1264                 check_err(
   1265                     format!(
   1266                         "invalid {ty} option VALUE in section [section]: {}",
   1267                         error_fmt(raw)
   1268                     ),
   1269                     lambda(&cfg.section("section"), "value").require(),
   1270                 )
   1271             }
   1272         }
   1273     }
   1274 
   1275     #[test]
   1276     fn string() {
   1277         routine(
   1278             "string",
   1279             |sect, value| sect.str(value),
   1280             &[
   1281                 (&["1", "\"1\""], "1".to_owned()),
   1282                 (&["test", "\"test\""], "test".to_owned()),
   1283                 (&["\""], "\"".to_owned()),
   1284             ],
   1285             &[],
   1286         );
   1287     }
   1288 
   1289     #[test]
   1290     fn path() {
   1291         routine(
   1292             "path",
   1293             |sect, value| sect.path(value),
   1294             &[
   1295                 (&["path"], "path".to_owned()),
   1296                 (
   1297                     &["foo/$DATADIR/bar", "foo/${DATADIR}/bar"],
   1298                     "foo/mydir/bar".to_owned(),
   1299                 ),
   1300                 (
   1301                     &["foo/$DATADIR$DATADIR/bar"],
   1302                     "foo/mydirmydir/bar".to_owned(),
   1303                 ),
   1304                 (
   1305                     &["foo/pre_$DATADIR/bar", "foo/pre_${DATADIR}/bar"],
   1306                     "foo/pre_mydir/bar".to_owned(),
   1307                 ),
   1308                 (
   1309                     &[
   1310                         "foo/${DATADIR}_next/bar",
   1311                         "foo/${UNKNOWN:-$DATADIR}_next/bar",
   1312                     ],
   1313                     "foo/mydir_next/bar".to_owned(),
   1314                 ),
   1315                 (
   1316                     &[
   1317                         "foo/${UNKNOWN:-default}_next/bar",
   1318                         "foo/${UNKNOWN:-${UNKNOWN:-default}}_next/bar",
   1319                     ],
   1320                     "foo/default_next/bar".to_owned(),
   1321                 ),
   1322                 (
   1323                     &["foo/${UNKNOWN:-pre_${UNKNOWN:-default}_next}_next/bar"],
   1324                     "foo/pre_default_next_next/bar".to_owned(),
   1325                 ),
   1326             ],
   1327             &[
   1328                 (&["foo/${A/bar"], |_| "bad substitution '/bar'".to_owned()),
   1329                 (&["foo/${A:-pre_${B}/bar"], |_| {
   1330                     "unbalanced variable expression 'pre_${B}/bar'".to_owned()
   1331                 }),
   1332                 (&["foo/${A:-${B${C}/bar"], |_| {
   1333                     "unbalanced variable expression '${B${C}/bar'".to_owned()
   1334                 }),
   1335                 (&["foo/$UNKNOWN/bar", "foo/${UNKNOWN}/bar"], |_| {
   1336                     "unbound variable 'UNKNOWN'".to_owned()
   1337                 }),
   1338                 (&["foo/$RECURSIVE/bar"], |_| {
   1339                     "recursion limit in path substitution exceeded for '$RECURSIVE'".to_owned()
   1340                 }),
   1341             ],
   1342         )
   1343     }
   1344 
   1345     #[test]
   1346     fn number() {
   1347         routine(
   1348             "number",
   1349             |sect, value| sect.number(value),
   1350             &[(&["1"], 1), (&["42"], 42)],
   1351             &[(&["true", "YES"], |it| format!("'{it}' not a valid number"))],
   1352         );
   1353     }
   1354 
   1355     #[test]
   1356     fn boolean() {
   1357         routine(
   1358             "boolean",
   1359             |sect, value| sect.boolean(value),
   1360             &[(&["yes", "YES", "Yes"], true), (&["no", "NO", "No"], false)],
   1361             &[(&["true", "1"], |it| {
   1362                 format!("expected 'YES' or 'NO' got '{it}'")
   1363             })],
   1364         );
   1365     }
   1366 
   1367     #[test]
   1368     fn amount() {
   1369         routine(
   1370             "amount",
   1371             |sect, value| sect.amount(value, &Currency::KUDOS),
   1372             &[(
   1373                 &["KUDOS:12", "KUDOS:12.0", "KUDOS:012.0"],
   1374                 amount::amount("KUDOS:12"),
   1375             )],
   1376             &[
   1377                 (&["test", "42"], |it| {
   1378                     format!("amount '{it}' invalid format")
   1379                 }),
   1380                 (&["KUDOS:0.3ABC"], |it| {
   1381                     format!("amount '{it}' invalid fraction (invalid digit found in string)")
   1382                 }),
   1383                 (&["KUDOS:999999999999999999"], |it| {
   1384                     format!("amount '{it}' value overflow (must be <= 4503599627370496)")
   1385                 }),
   1386                 (&["EUR:12"], |_| {
   1387                     "expected currency KUDOS got EUR".to_owned()
   1388                 }),
   1389             ],
   1390         )
   1391     }
   1392 
   1393     #[test]
   1394     fn duration() {
   1395         routine(
   1396             "temporal",
   1397             |sect, value| sect.duration(value),
   1398             &[(&["1.5s", "1500ms"], Duration::from_secs_f64(1.5))],
   1399             &[(&["-1s", "-1ns"], |_| {
   1400                 "temporal must not be negative".to_string()
   1401             })],
   1402         )
   1403     }
   1404 
   1405     #[test]
   1406     fn map() {
   1407         #[derive(Debug, PartialEq, Eq)]
   1408         enum Mode {
   1409             Tcp(u16),
   1410             Unix,
   1411             Systemd,
   1412         }
   1413 
   1414         fn parse<'cfg, 'arg>(sect: &Section<'cfg, 'arg>, value: &'arg str) -> Value<'arg, Mode> {
   1415             map_config!(sect, "mode", value,
   1416                 "tcp" => { Mode::Tcp(sect.number("PORT").require()?) },
   1417                 "unix" => { Mode::Unix },
   1418                 "systemd" => { Mode::Systemd },
   1419             )
   1420         }
   1421 
   1422         routine(
   1423             "mode",
   1424             parse,
   1425             &[(&["unix"], Mode::Unix), (&["systemd"], Mode::Systemd)],
   1426             &[(&["udp", "TCP"], |it| {
   1427                 format!("expected 'tcp', 'unix' or 'systemd' got '{it}'")
   1428             })],
   1429         );
   1430 
   1431         let cfg = Config::from_mem("[section]\nvalue=tcp").unwrap();
   1432 
   1433         check_err(
   1434             "missing number option PORT in section [section]",
   1435             parse(&cfg.section("section"), "value").require(),
   1436         );
   1437 
   1438         check_err(
   1439             "invalid mode option VALUE in section [section]: expected 'unix' got 'tcp'",
   1440             map_config!(&cfg.section("section"), "mode", "value",
   1441                 "unix" => { Mode::Unix },
   1442             )
   1443             .require(),
   1444         );
   1445     }
   1446 }