taler-rust

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

config.rs (40699B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025 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     fmt::{Debug, Display},
     19     fs::Permissions,
     20     os::unix::fs::PermissionsExt,
     21     path::PathBuf,
     22     str::FromStr,
     23 };
     24 
     25 use indexmap::IndexMap;
     26 use url::Url;
     27 
     28 use crate::types::{
     29     amount::{Amount, Currency},
     30     payto::PaytoURI,
     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     };
     41 
     42     use indexmap::IndexMap;
     43     use tracing::{trace, warn};
     44 
     45     use crate::config::{Line, Location};
     46 
     47     use super::{Config, ValueErr};
     48 
     49     #[derive(Debug, thiserror::Error)]
     50     pub enum ConfigErr {
     51         #[error("config error, {0}")]
     52         Parser(#[from] ParserErr),
     53         #[error("invalid config, {0}")]
     54         Value(#[from] ValueErr),
     55     }
     56 
     57     #[derive(Debug)]
     58 
     59     pub enum ParserErr {
     60         IO(Cow<'static, str>, PathBuf, std::io::Error),
     61         Line(Cow<'static, str>, PathBuf, usize, Option<String>),
     62     }
     63 
     64     impl Display for ParserErr {
     65         fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
     66             match self {
     67                 ParserErr::IO(action, path, err) => write!(
     68                     f,
     69                     "Could not {action} at '{}': {}",
     70                     path.to_string_lossy(),
     71                     err.kind()
     72                 ),
     73                 ParserErr::Line(msg, path, line, cause) => {
     74                     if let Some(cause) = cause {
     75                         write!(f, "{msg} at '{}:{line}': {cause}", path.to_string_lossy())
     76                     } else {
     77                         write!(f, "{msg} at '{}:{line}'", path.to_string_lossy())
     78                     }
     79                 }
     80             }
     81         }
     82     }
     83 
     84     impl std::error::Error for ParserErr {
     85         fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
     86             None
     87         }
     88 
     89         fn description(&self) -> &str {
     90             "description() is deprecated; use Display"
     91         }
     92 
     93         fn cause(&self) -> Option<&dyn std::error::Error> {
     94             self.source()
     95         }
     96     }
     97 
     98     fn io_err(
     99         action: impl Into<Cow<'static, str>>,
    100         path: impl Into<PathBuf>,
    101         err: std::io::Error,
    102     ) -> ParserErr {
    103         ParserErr::IO(action.into(), path.into(), err)
    104     }
    105     fn line_err(
    106         msg: impl Into<Cow<'static, str>>,
    107         path: impl Into<PathBuf>,
    108         line: usize,
    109     ) -> ParserErr {
    110         ParserErr::Line(msg.into(), path.into(), line, None)
    111     }
    112     fn line_cause_err(
    113         msg: impl Into<Cow<'static, str>>,
    114         cause: impl Display,
    115         path: impl Into<PathBuf>,
    116         line: usize,
    117     ) -> ParserErr {
    118         ParserErr::Line(msg.into(), path.into(), line, Some(cause.to_string()))
    119     }
    120     struct Parser {
    121         sections: IndexMap<String, IndexMap<String, Line>>,
    122         files: Vec<PathBuf>,
    123         install_path: PathBuf,
    124         buf: String,
    125     }
    126 
    127     impl Parser {
    128         fn empty() -> Self {
    129             Self {
    130                 sections: IndexMap::new(),
    131                 files: Vec::new(),
    132                 install_path: PathBuf::new(),
    133                 buf: String::new(),
    134             }
    135         }
    136 
    137         fn load_env(&mut self, src: ConfigSource) -> Result<(), ParserErr> {
    138             let ConfigSource { project_name, .. } = src;
    139 
    140             // Load default path
    141             let dir = src
    142                 .install_path()
    143                 .map_err(|(p, e)| io_err("find installation path", p, e))?;
    144             self.install_path = dir.clone();
    145 
    146             let paths = IndexMap::from_iter(
    147                 [
    148                     ("PREFIX", dir.join("")),
    149                     ("BINDIR", dir.join("bin")),
    150                     ("LIBEXECDIR", dir.join(project_name).join("libexec")),
    151                     ("DOCDIR", dir.join("share").join("doc").join(project_name)),
    152                     ("ICONDIR", dir.join("bin").join("share").join("icons")),
    153                     ("LOCALEDIR", dir.join("share").join("locale")),
    154                     ("LIBDIR", dir.join("lib").join(project_name)),
    155                     ("DATADIR", dir.join("share").join(project_name)),
    156                 ]
    157                 .map(|(a, b)| {
    158                     (
    159                         a.to_owned(),
    160                         Line {
    161                             content: b.to_string_lossy().into_owned(),
    162                             loc: None,
    163                         },
    164                     )
    165                 }),
    166             );
    167             self.sections.insert("PATHS".to_owned(), paths);
    168 
    169             // Load default configs
    170             let cfg_dir = dir.join("share").join(project_name).join("config.d");
    171             match std::fs::read_dir(&cfg_dir) {
    172                 Ok(entries) => {
    173                     for entry in entries {
    174                         match entry {
    175                             Ok(entry) => self.parse_file(entry.path(), 0)?,
    176                             Err(err) => {
    177                                 warn!(target: "config", "{}", io_err("read base config directory", &cfg_dir, err));
    178                             }
    179                         }
    180                     }
    181                 }
    182                 Err(err) => {
    183                     warn!(target: "config", "{}", io_err("read base config directory", &cfg_dir, err))
    184                 }
    185             }
    186 
    187             Ok(())
    188         }
    189 
    190         fn parse_file(&mut self, src: PathBuf, depth: u8) -> Result<(), ParserErr> {
    191             trace!(target: "config", "load file at '{}'", src.to_string_lossy());
    192             match std::fs::File::open(&src) {
    193                 Ok(file) => self.parse(BufReader::new(file), src, depth + 1),
    194                 Err(e) => Err(io_err("read config", src, e)),
    195             }
    196         }
    197 
    198         fn parse<B: BufRead>(
    199             &mut self,
    200             mut reader: B,
    201             src: PathBuf,
    202             depth: u8,
    203         ) -> Result<(), ParserErr> {
    204             let file = self.files.len();
    205             self.files.push(src.clone());
    206             let src = &src;
    207 
    208             let mut current_section: Option<&mut IndexMap<String, Line>> = None;
    209             let mut line = 0;
    210 
    211             loop {
    212                 // Read a new line
    213                 line += 1;
    214                 self.buf.clear();
    215                 match reader.read_line(&mut self.buf) {
    216                     Ok(0) => break,
    217                     Ok(_) => {}
    218                     Err(e) => return Err(io_err("read config", src, e)),
    219                 }
    220                 // Trim whitespace
    221                 let l = self.buf.trim_ascii();
    222 
    223                 if l.is_empty() || l.starts_with(['#', '%']) {
    224                     // Skip empty lines and comments
    225                     continue;
    226                 } else if let Some(directive) = l.strip_prefix("@") {
    227                     // Parse directive
    228                     let Some((name, arg)) = directive.split_once('@') else {
    229                         return Err(line_err(format!("Invalid directive line '{l}'"), src, line));
    230                     };
    231                     let arg = arg.trim_ascii_start();
    232                     // Exit current section
    233                     current_section = None;
    234                     // Check current file has a parent
    235                     let Some(parent) = src.parent() else {
    236                         return Err(line_err("no parent", src, line));
    237                     };
    238                     // Check recursion depth
    239                     if depth > 128 {
    240                         return Err(line_err("Recursion limit in config inlining", src, line));
    241                     }
    242 
    243                     match name.to_lowercase().as_str() {
    244                         "inline" => self.parse_file(parent.join(arg), depth)?,
    245                         "inline-matching" => {
    246                             let paths =
    247                                 glob::glob(&parent.join(arg).to_string_lossy()).map_err(|e| {
    248                                     line_cause_err("Malformed glob regex", e, src, line)
    249                                 })?;
    250                             for path in paths {
    251                                 let path =
    252                                     path.map_err(|e| line_cause_err("Glob error", e, src, line))?;
    253                                 self.parse_file(path, depth)?;
    254                             }
    255                         }
    256                         "inline-secret" => {
    257                             let (section, secret_file) = arg.split_once(" ").ok_or_else(|| 
    258                                 line_err(
    259                                     "Invalid configuration, @inline-secret@ directive requires exactly two arguments",
    260                                     src,
    261                                     line
    262                                 )
    263                             )?;
    264 
    265                             let section_up = section.to_uppercase();
    266                             let mut secret_cfg = Parser::empty();
    267 
    268                             if let Err(e) = secret_cfg.parse_file(parent.join(secret_file), depth) {
    269                                 if let ParserErr::IO(_, path, err) = e {
    270                                     warn!(target: "config", "{}", io_err(format!("read secret section [{section}]"), &path, err))
    271                                 } else {
    272                                     return Err(e);
    273                                 }
    274                             } else if let Some(secret_section) =
    275                                 secret_cfg.sections.swap_remove(&section_up)
    276                             {
    277                                 self.sections
    278                                     .entry(section_up)
    279                                     .or_default()
    280                                     .extend(secret_section);
    281                             } else {
    282                                 warn!(target: "config", "{}", line_err(format!("Configuration file at '{secret_file}' loaded with @inline-secret@ does not contain section '{section_up}'"), src, line));
    283                             }
    284                         }
    285                         unknown => {
    286                             return Err(line_err(
    287                                 format!("Invalid directive '{unknown}'"),
    288                                 src,
    289                                 line,
    290                             ));
    291                         }
    292                     }
    293                 } else if let Some(section) = l.strip_prefix('[').and_then(|l| l.strip_suffix(']'))
    294                 {
    295                     current_section =
    296                         Some(self.sections.entry(section.to_uppercase()).or_default());
    297                 } else if let Some((name, value)) = l.split_once('=') {
    298                     if let Some(current_section) = &mut current_section {
    299                         // Trim whitespace
    300                         let name = name.trim_ascii_end().to_uppercase();
    301                         let value = value.trim_ascii_start();
    302                         // Escape value
    303                         let value =
    304                             if value.len() > 1 && value.starts_with('"') && value.ends_with('"') {
    305                                 &value[1..value.len() - 1]
    306                             } else {
    307                                 value
    308                             };
    309                         current_section.insert(
    310                             name,
    311                             Line {
    312                                 content: value.to_owned(),
    313                                 loc: Some(Location { file, line }),
    314                             },
    315                         );
    316                     } else {
    317                         return Err(line_err("Expected section header or directive", src, line));
    318                     }
    319                 } else {
    320                     return Err(line_err(
    321                         "Expected section header, option assignment or directive",
    322                         src,
    323                         line,
    324                     ));
    325                 }
    326             }
    327             Ok(())
    328         }
    329 
    330         pub fn finish(self) -> Config {
    331             // Convert to a readonly config struct without location info
    332             Config {
    333                 sections: self.sections,
    334                 files: self.files,
    335                 install_path: self.install_path,
    336             }
    337         }
    338     }
    339 
    340     /** Information about how the configuration is loaded */
    341     #[derive(Debug, Clone, Copy)]
    342     pub struct ConfigSource {
    343         /** Name of the high-level project */
    344         pub project_name: &'static str,
    345         /** Name of the component within the package */
    346         pub component_name: &'static str,
    347         /**
    348          * Executable name that will be located on $PATH to
    349          * find the installation path of the package
    350          */
    351         pub exec_name: &'static str,
    352     }
    353 
    354     impl ConfigSource {
    355         /// Create a new config source
    356         pub const fn new(
    357             project_name: &'static str,
    358             component_name: &'static str,
    359             exec_name: &'static str,
    360         ) -> Self {
    361             Self {
    362                 project_name,
    363                 component_name,
    364                 exec_name,
    365             }
    366         }
    367 
    368         /// Create a config source where the project, component and exec names are the same
    369         pub const fn simple(name: &'static str) -> Self {
    370             Self::new(name, name, name)
    371         }
    372 
    373         /**
    374          * Search the default configuration file path
    375          *
    376          * I will be the first existing file from this list:
    377          * - $XDG_CONFIG_HOME/$componentName.conf
    378          * - $HOME/.config/$componentName.conf
    379          * - /etc/$componentName.conf
    380          * - /etc/$projectName/$componentName.conf
    381          * */
    382         fn default_config_path(&self) -> Result<Option<PathBuf>, (PathBuf, std::io::Error)> {
    383             // TODO use a generator
    384             let conf_name = format!("{}.conf", self.component_name);
    385 
    386             if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
    387                 let path = PathBuf::from(xdg).join(&conf_name);
    388                 match path.try_exists() {
    389                     Ok(false) => {}
    390                     Ok(true) => return Ok(Some(path)),
    391                     Err(e) => return Err((path, e)),
    392                 }
    393             }
    394 
    395             if let Some(home) = std::env::var_os("HOME") {
    396                 let path = PathBuf::from(home).join(".config").join(&conf_name);
    397                 match path.try_exists() {
    398                     Ok(false) => {}
    399                     Ok(true) => return Ok(Some(path)),
    400                     Err(e) => return Err((path, e)),
    401                 }
    402             }
    403 
    404             let path = PathBuf::from("/etc").join(&conf_name);
    405             match path.try_exists() {
    406                 Ok(false) => {}
    407                 Ok(true) => return Ok(Some(path)),
    408                 Err(e) => return Err((path, e)),
    409             }
    410 
    411             let path = PathBuf::from("/etc")
    412                 .join(self.project_name)
    413                 .join(&conf_name);
    414             match path.try_exists() {
    415                 Ok(false) => {}
    416                 Ok(true) => return Ok(Some(path)),
    417                 Err(e) => return Err((path, e)),
    418             }
    419 
    420             Ok(None)
    421         }
    422 
    423         /** Search for the binary installation path in PATH */
    424         fn install_path(&self) -> Result<PathBuf, (PathBuf, std::io::Error)> {
    425             let path_env = std::env::var("PATH").unwrap();
    426             for path_dir in path_env.split(':') {
    427                 let path_dir = PathBuf::from(path_dir);
    428                 let bin_path = path_dir.join(self.exec_name);
    429                 if bin_path.exists()
    430                     && let Some(parent) = path_dir.parent()
    431                 {
    432                     return parent.canonicalize().map_err(|e| (parent.to_path_buf(), e));
    433                 }
    434             }
    435             Ok(PathBuf::from("/usr"))
    436         }
    437     }
    438 
    439     impl Config {
    440         pub fn from_file(
    441             src: ConfigSource,
    442             path: Option<impl Into<PathBuf>>,
    443         ) -> Result<Config, ParserErr> {
    444             let mut parser = Parser::empty();
    445             parser.load_env(src)?;
    446             match path {
    447                 Some(path) => parser.parse_file(path.into(), 0)?,
    448                 None => {
    449                     if let Some(default) = src
    450                         .default_config_path()
    451                         .map_err(|(p, e)| io_err("find default config path", p, e))?
    452                     {
    453                         parser.parse_file(default, 0)?;
    454                     }
    455                 }
    456             }
    457             Ok(parser.finish())
    458         }
    459 
    460         pub fn from_mem(str: &str) -> Result<Config, ParserErr> {
    461             let mut parser = Parser::empty();
    462             parser.parse(
    463                 std::io::Cursor::new(str),
    464                 PathBuf::from_str("mem").unwrap(),
    465                 0,
    466             )?;
    467             Ok(parser.finish())
    468         }
    469     }
    470 }
    471 
    472 #[derive(Debug, thiserror::Error)]
    473 pub enum ValueErr {
    474     #[error("Missing {ty} option '{option}' in section '{section}'")]
    475     Missing {
    476         ty: String,
    477         section: String,
    478         option: String,
    479     },
    480     #[error("Invalid {ty} option '{option}' in section '{section}': {err}")]
    481     Invalid {
    482         ty: String,
    483         section: String,
    484         option: String,
    485         err: String,
    486     },
    487 }
    488 
    489 #[derive(Debug, thiserror::Error)]
    490 
    491 pub enum PathsubErr {
    492     #[error("recursion limit in path substitution exceeded for '{0}'")]
    493     Recursion(String),
    494     #[error("unbalanced variable expression '{0}'")]
    495     Unbalanced(String),
    496     #[error("bad substitution '{0}'")]
    497     Substitution(String),
    498     #[error("unbound variable '{0}'")]
    499     Unbound(String),
    500 }
    501 
    502 #[derive(Debug, Clone)]
    503 struct Location {
    504     file: usize,
    505     line: usize,
    506 }
    507 
    508 #[derive(Debug, Clone)]
    509 struct Line {
    510     content: String,
    511     loc: Option<Location>,
    512 }
    513 
    514 #[derive(Debug, Clone)]
    515 pub struct Config {
    516     sections: IndexMap<String, IndexMap<String, Line>>,
    517     files: Vec<PathBuf>,
    518     install_path: PathBuf,
    519 }
    520 
    521 impl Config {
    522     pub fn section<'cfg, 'arg>(&'cfg self, section: &'arg str) -> Section<'cfg, 'arg> {
    523         Section {
    524             section,
    525             config: self,
    526             values: self.sections.get(&section.to_uppercase()),
    527         }
    528     }
    529 
    530     /**
    531      * Substitute ${...} and $... placeholders in a string
    532      * with values from the PATHS section in the
    533      * configuration and environment variables
    534      *
    535      * This substitution is typically only done for paths.
    536      */
    537     pub fn pathsub(&self, str: &str, depth: u8) -> Result<String, PathsubErr> {
    538         if depth > 128 {
    539             return Err(PathsubErr::Recursion(str.to_owned()));
    540         } else if !str.contains('$') {
    541             return Ok(str.to_owned());
    542         }
    543 
    544         /** Lookup for variable value from PATHS section in the configuration and environment variables */
    545         fn lookup(cfg: &Config, name: &str, depth: u8) -> Option<Result<String, PathsubErr>> {
    546             if let Some(path_res) = cfg
    547                 .sections
    548                 .get("PATHS")
    549                 .and_then(|section| section.get(name))
    550             {
    551                 return Some(cfg.pathsub(&path_res.content, depth + 1));
    552             }
    553 
    554             if let Ok(val) = std::env::var(name) {
    555                 return Some(Ok(val));
    556             }
    557             None
    558         }
    559 
    560         let mut result = String::new();
    561         let mut remaining = str;
    562         loop {
    563             // Look for the next variable
    564             let Some((normal, value)) = remaining.split_once('$') else {
    565                 result.push_str(remaining);
    566                 return Ok(result);
    567             };
    568 
    569             // Append normal character
    570             result.push_str(normal);
    571             remaining = value;
    572 
    573             // Check if variable is enclosed
    574             let is_enclosed = if let Some(enclosed) = remaining.strip_prefix('{') {
    575                 // ${var
    576                 remaining = enclosed;
    577                 true
    578             } else {
    579                 false // $var
    580             };
    581 
    582             // Extract variable name
    583             let name_end = remaining
    584                 .find(|c: char| !c.is_alphanumeric() && c != '_')
    585                 .unwrap_or(remaining.len());
    586             let (name, after_name) = remaining.split_at(name_end);
    587 
    588             // Extract variable default if enclosed
    589             let default = if !is_enclosed {
    590                 remaining = after_name;
    591                 None
    592             } else if let Some(after_enclosed) = after_name.strip_prefix('}') {
    593                 // ${var}
    594                 remaining = after_enclosed;
    595                 None
    596             } else if let Some(default) = after_name.strip_prefix(":-") {
    597                 // ${var:-default}
    598                 let mut depth = 1;
    599                 let Some((default, after_default)) = default.split_once(|c| {
    600                     if c == '{' {
    601                         depth += 1;
    602                         false
    603                     } else if c == '}' {
    604                         depth -= 1;
    605                         depth == 0
    606                     } else {
    607                         false
    608                     }
    609                 }) else {
    610                     return Err(PathsubErr::Unbalanced(default.to_owned()));
    611                 };
    612                 remaining = after_default;
    613                 Some(default)
    614             } else {
    615                 return Err(PathsubErr::Substitution(after_name.to_owned()));
    616             };
    617             if let Some(resolved) = lookup(self, name, depth + 1) {
    618                 result.push_str(&resolved?);
    619                 continue;
    620             } else if let Some(default) = default {
    621                 let resolved = self.pathsub(default, depth + 1)?;
    622                 result.push_str(&resolved);
    623                 continue;
    624             }
    625             return Err(PathsubErr::Unbound(name.to_owned()));
    626         }
    627     }
    628 
    629     pub fn print(&self, mut f: impl std::io::Write, diagnostics: bool) -> std::io::Result<()> {
    630         if diagnostics {
    631             writeln!(f, "#")?;
    632             writeln!(f, "# Configuration file diagnostics")?;
    633             writeln!(f, "#")?;
    634             writeln!(f, "# File Loaded:")?;
    635             for path in &self.files {
    636                 writeln!(f, "# {}", path.to_string_lossy())?;
    637             }
    638             writeln!(f, "#")?;
    639             writeln!(
    640                 f,
    641                 "# Installation path: {}",
    642                 self.install_path.to_string_lossy()
    643             )?;
    644             writeln!(f, "#")?;
    645             writeln!(f)?;
    646         }
    647         for (sect, values) in &self.sections {
    648             writeln!(f, "[{sect}]")?;
    649             if diagnostics {
    650                 writeln!(f)?;
    651             }
    652             for (key, Line { content, loc }) in values {
    653                 if diagnostics {
    654                     match loc {
    655                         Some(Location { file, line }) => {
    656                             let path = &self.files[*file];
    657                             writeln!(f, "# {}:{line}", path.to_string_lossy())?;
    658                         }
    659                         None => writeln!(f, "# default")?,
    660                     }
    661                 }
    662                 writeln!(f, "{key} = {content}")?;
    663                 if diagnostics {
    664                     writeln!(f)?;
    665                 }
    666             }
    667             writeln!(f)?;
    668         }
    669         Ok(())
    670     }
    671 }
    672 
    673 /** Accessor/Converter for Taler-like configuration sections */
    674 pub struct Section<'cfg, 'arg> {
    675     section: &'arg str,
    676     config: &'cfg Config,
    677     values: Option<&'cfg IndexMap<String, Line>>,
    678 }
    679 
    680 #[macro_export]
    681 macro_rules! map_config {
    682     ($self:expr, $ty:expr, $option:expr, $($key:expr => $parse:block),*$(,)?) => {
    683         {
    684             let keys = &[$($key,)*];
    685             $self.map($ty, $option, |value| {
    686                 match value {
    687                     $($key => {
    688                         (|| {
    689                             $parse
    690                         })().map_err(|e| ::taler_common::config::MapErr::Err(e))
    691                     })*,
    692                     _ => Err(::taler_common::config::MapErr::Invalid(keys))
    693                 }
    694             })
    695         }
    696     }
    697 }
    698 
    699 pub use map_config;
    700 
    701 #[doc(hidden)]
    702 pub enum MapErr {
    703     Invalid(&'static [&'static str]),
    704     Err(ValueErr),
    705 }
    706 
    707 impl<'cfg, 'arg> Section<'cfg, 'arg> {
    708     #[doc(hidden)]
    709     fn inner<T>(
    710         &self,
    711         ty: &'arg str,
    712         option: &'arg str,
    713         transform: impl FnOnce(&'cfg str) -> Result<T, ValueErr>,
    714     ) -> Value<'arg, T> {
    715         let value = self
    716             .values
    717             .and_then(|m| m.get(&option.to_uppercase()))
    718             .filter(|it| !it.content.is_empty())
    719             .map(|raw| transform(&raw.content))
    720             .transpose();
    721         Value {
    722             value,
    723             option,
    724             ty,
    725             section: self.section,
    726         }
    727     }
    728 
    729     #[doc(hidden)]
    730     pub fn map<T>(
    731         &self,
    732         ty: &'arg str,
    733         option: &'arg str,
    734         transform: impl FnOnce(&'cfg str) -> Result<T, MapErr>,
    735     ) -> Value<'arg, T> {
    736         self.value(ty, option, |v| {
    737             transform(v).map_err(|e| match e {
    738                 MapErr::Invalid(keys) => {
    739                     let mut buf = "expected '".to_owned();
    740                     match keys {
    741                         [] => unreachable!("you must provide at least one mapping"),
    742                         [unique] => buf.push_str(unique),
    743                         [first, other @ .., last] => {
    744                             buf.push_str(first);
    745                             for k in other {
    746                                 buf.push_str("', '");
    747                                 buf.push_str(k);
    748                             }
    749                             buf.push_str("' or '");
    750                             buf.push_str(last);
    751                         }
    752                     }
    753                     buf.push_str("' got '");
    754                     buf.push_str(v);
    755                     buf.push('\'');
    756                     ValueErr::Invalid {
    757                         ty: ty.to_owned(),
    758                         section: self.section.to_owned(),
    759                         option: option.to_owned(),
    760                         err: buf,
    761                     }
    762                 }
    763                 MapErr::Err(e) => e,
    764             })
    765         })
    766     }
    767 
    768     /** Setup an accessor/converted for a [type] at [option] using [transform] */
    769     pub fn value<T, E: Display>(
    770         &self,
    771         ty: &'arg str,
    772         option: &'arg str,
    773         transform: impl FnOnce(&'cfg str) -> Result<T, E>,
    774     ) -> Value<'arg, T> {
    775         self.inner(ty, option, |v| {
    776             transform(v).map_err(|e| ValueErr::Invalid {
    777                 ty: ty.to_owned(),
    778                 section: self.section.to_owned(),
    779                 option: option.to_owned(),
    780                 err: e.to_string(),
    781             })
    782         })
    783     }
    784 
    785     /** Access [option] as a parsable type */
    786     pub fn parse<E: std::fmt::Display, T: FromStr<Err = E>>(
    787         &self,
    788         ty: &'arg str,
    789         option: &'arg str,
    790     ) -> Value<'arg, T> {
    791         self.value(ty, option, |it| it.parse::<T>().map_err(|e| e.to_string()))
    792     }
    793 
    794     /** Access [option] as str */
    795     pub fn str(&self, option: &'arg str) -> Value<'arg, String> {
    796         self.value("string", option, |it| Ok::<_, &str>(it.to_owned()))
    797     }
    798 
    799     /** Access [option] as path */
    800     pub fn path(&self, option: &'arg str) -> Value<'arg, String> {
    801         self.value("path", option, |it| self.config.pathsub(it, 0))
    802     }
    803 
    804     /** Access [option] as UNIX permissions */
    805     pub fn unix_mode(&self, option: &'arg str) -> Value<'arg, Permissions> {
    806         self.value("unix mode", option, |it| {
    807             u32::from_str_radix(it, 8)
    808                 .map(Permissions::from_mode)
    809                 .map_err(|_| format!("'{it}' not a valid number"))
    810         })
    811     }
    812 
    813     /** Access [option] as a number */
    814     pub fn number<T: FromStr>(&self, option: &'arg str) -> Value<'arg, T> {
    815         self.value("number", option, |it| {
    816             it.parse::<T>()
    817                 .map_err(|_| format!("'{it}' not a valid number"))
    818         })
    819     }
    820 
    821     /** Access [option] as Boolean */
    822     pub fn boolean(&self, option: &'arg str) -> Value<'arg, bool> {
    823         self.value("boolean", option, |it| match it.to_uppercase().as_str() {
    824             "YES" => Ok(true),
    825             "NO" => Ok(false),
    826             _ => Err(format!("expected 'YES' or 'NO' got '{it}'")),
    827         })
    828     }
    829 
    830     /** Access [option] as Amount */
    831     pub fn amount(&self, option: &'arg str, currency: &str) -> Value<'arg, Amount> {
    832         let currency: Currency = currency.parse().unwrap();
    833         self.value("amount", option, |it| {
    834             let amount = it.parse::<Amount>().map_err(|e| e.to_string())?;
    835             if amount.currency != currency {
    836                 return Err(format!(
    837                     "expected currency {currency} got {}",
    838                     amount.currency
    839                 ));
    840             }
    841             Ok(amount)
    842         })
    843     }
    844 
    845     /** Access [option] as url */
    846     pub fn url(&self, option: &'arg str) -> Value<'arg, Url> {
    847         self.parse("url", option)
    848     }
    849 
    850     /** Access [option] as payto */
    851     pub fn payto(&self, option: &'arg str) -> Value<'arg, PaytoURI> {
    852         self.parse("payto", option)
    853     }
    854 
    855     /** Access [option] as Postgres URI */
    856     pub fn postgres(&self, option: &'arg str) -> Value<'arg, sqlx::postgres::PgConnectOptions> {
    857         self.parse("Postgres URI", option)
    858     }
    859 
    860     /** Access [option] as a timestamp */
    861     pub fn timestamp(&self, option: &'arg str) -> Value<'arg, jiff::Timestamp> {
    862         self.parse("Timestamp", option)
    863     }
    864 
    865     /** Access [option] as a date time */
    866     pub fn date(&self, option: &'arg str) -> Value<'arg, jiff::civil::Date> {
    867         self.parse("Date", option)
    868     }
    869 }
    870 
    871 pub struct Value<'arg, T> {
    872     value: Result<Option<T>, ValueErr>,
    873     option: &'arg str,
    874     ty: &'arg str,
    875     section: &'arg str,
    876 }
    877 
    878 impl<T> Value<'_, T> {
    879     pub fn opt(self) -> Result<Option<T>, ValueErr> {
    880         self.value
    881     }
    882 
    883     /** Converted value of default if missing */
    884     pub fn default(self, default: T) -> Result<T, ValueErr> {
    885         Ok(self.value?.unwrap_or(default))
    886     }
    887 
    888     /** Converted value or throw if missing */
    889     pub fn require(self) -> Result<T, ValueErr> {
    890         self.value?.ok_or_else(|| ValueErr::Missing {
    891             ty: self.ty.to_owned(),
    892             section: self.section.to_owned(),
    893             option: self.option.to_owned(),
    894         })
    895     }
    896 }
    897 
    898 #[cfg(test)]
    899 mod test {
    900     use std::{
    901         fmt::{Debug, Display},
    902         fs::{File, Permissions},
    903         os::unix::fs::PermissionsExt,
    904     };
    905 
    906     use tracing::error;
    907 
    908     use crate::{config::parser::ConfigSource, types::amount};
    909 
    910     use super::{Config, Section, Value};
    911 
    912     const SOURCE: ConfigSource = ConfigSource::new("test", "test", "test");
    913 
    914     #[track_caller]
    915     fn check_err<T: Debug, E: Display>(err: impl AsRef<str>, lambda: Result<T, E>) {
    916         let failure = lambda.unwrap_err();
    917         let fmt = failure.to_string();
    918         assert_eq!(err.as_ref(), fmt);
    919     }
    920 
    921     #[test]
    922     fn fs() {
    923         let dir = tempfile::tempdir().unwrap();
    924         let config_path = dir.path().join("test-conf.conf");
    925         let second_path = dir.path().join("test-second-conf.conf");
    926 
    927         let config_path_fmt = config_path.to_string_lossy();
    928         let second_path_fmt = second_path.to_string_lossy();
    929 
    930         let check_err = |err: String| check_err(err, Config::from_file(SOURCE, Some(&config_path)));
    931         let check_ok = || Config::from_file(SOURCE, Some(&config_path)).unwrap();
    932 
    933         check_err(format!(
    934             "Could not read config at '{config_path_fmt}': entity not found"
    935         ));
    936 
    937         let config_file = std::fs::File::create_new(&config_path).unwrap();
    938         config_file
    939             .set_permissions(Permissions::from_mode(0o222))
    940             .unwrap();
    941         if File::open(&config_path).is_ok() {
    942             error!("Cannot finish this test if root");
    943             return;
    944         }
    945         check_err(format!(
    946             "Could not read config at '{config_path_fmt}': permission denied"
    947         ));
    948 
    949         config_file
    950             .set_permissions(Permissions::from_mode(0o666))
    951             .unwrap();
    952         check_ok();
    953         std::fs::write(&config_path, "@inline@ test-second-conf.conf").unwrap();
    954         check_err(format!(
    955             "Could not read config at '{second_path_fmt}': entity not found"
    956         ));
    957 
    958         let second_file = std::fs::File::create_new(&second_path).unwrap();
    959         second_file
    960             .set_permissions(Permissions::from_mode(0222))
    961             .unwrap();
    962         check_err(format!(
    963             "Could not read config at '{second_path_fmt}': permission denied"
    964         ));
    965 
    966         std::fs::write(&config_path, "@inline-matching@[*").unwrap();
    967         check_err(format!(
    968             "Malformed glob regex at '{config_path_fmt}:1': Pattern syntax error near position 16: invalid range pattern"
    969         ));
    970 
    971         std::fs::write(&config_path, "@inline-matching@*second-conf.conf").unwrap();
    972         check_err(format!(
    973             "Could not read config at '{second_path_fmt}': permission denied"
    974         ));
    975 
    976         std::fs::write(&config_path, "\n@inline-matching@*.conf").unwrap();
    977         check_err(format!(
    978             "Recursion limit in config inlining at '{config_path_fmt}:2'"
    979         ));
    980         std::fs::write(&config_path, "\n\n@inline-matching@ *.conf").unwrap();
    981         check_err(format!(
    982             "Recursion limit in config inlining at '{config_path_fmt}:3'"
    983         ));
    984 
    985         std::fs::write(&config_path, "@inline-secret@ secret test-second-conf.conf").unwrap();
    986         check_ok();
    987     }
    988 
    989     #[test]
    990     fn parsing() {
    991         let check = |err: &str, content: &str| check_err(err, Config::from_mem(&content));
    992 
    993         check(
    994             "Expected section header, option assignment or directive at 'mem:1'",
    995             "syntax error",
    996         );
    997         check(
    998             "Expected section header or directive at 'mem:1'",
    999             "key=value",
   1000         );
   1001         check(
   1002             "Expected section header, option assignment or directive at 'mem:2'",
   1003             "[section]\nbad-line",
   1004         );
   1005 
   1006         let cfg = Config::from_mem(
   1007             r#"
   1008 
   1009         [section-a]
   1010 
   1011         bar = baz
   1012 
   1013         [section-b]
   1014 
   1015         first_value = 1
   1016         second_value = "test"
   1017 
   1018         "#,
   1019         )
   1020         .unwrap();
   1021 
   1022         // Missing section
   1023         check_err(
   1024             "Missing string option 'value' in section 'unknown'",
   1025             cfg.section("unknown").str("value").require(),
   1026         );
   1027 
   1028         // Missing value
   1029         check_err(
   1030             "Missing string option 'value' in section 'section-a'",
   1031             cfg.section("section-a").str("value").require(),
   1032         );
   1033     }
   1034 
   1035     const DEFAULT_CONF: &str = "[PATHS]\nDATADIR=mydir\nRECURSIVE=$RECURSIVE";
   1036 
   1037     fn routine<T: Debug + Eq>(
   1038         ty: &str,
   1039         lambda: for<'cfg, 'arg> fn(&Section<'cfg, 'arg>, &'arg str) -> Value<'arg, T>,
   1040         wellformed: &[(&[&str], T)],
   1041         malformed: &[(&[&str], fn(&str) -> String)],
   1042     ) {
   1043         let conf = |content: &str| Config::from_mem(&format!("{DEFAULT_CONF}\n{content}")).unwrap();
   1044 
   1045         // Check missing msg
   1046         let cfg = conf("");
   1047         check_err(
   1048             format!("Missing {ty} option 'value' in section 'section'"),
   1049             lambda(&cfg.section("section"), "value").require(),
   1050         );
   1051 
   1052         // Check wellformed options are properly parsed
   1053         for (raws, expected) in wellformed {
   1054             for raw in *raws {
   1055                 let cfg = conf(&format!("[section]\nvalue={raw}"));
   1056                 dbg!(&cfg);
   1057                 assert_eq!(
   1058                     *expected,
   1059                     lambda(&cfg.section("section"), "value").require().unwrap()
   1060                 );
   1061             }
   1062         }
   1063 
   1064         // Check malformed options have proper error message
   1065         for (raws, error_fmt) in malformed {
   1066             for raw in *raws {
   1067                 let cfg = conf(&format!("[section]\nvalue={raw}"));
   1068                 check_err(
   1069                     format!(
   1070                         "Invalid {ty} option 'value' in section 'section': {}",
   1071                         error_fmt(&raw)
   1072                     ),
   1073                     lambda(&cfg.section("section"), "value").require(),
   1074                 )
   1075             }
   1076         }
   1077     }
   1078 
   1079     #[test]
   1080     fn string() {
   1081         routine(
   1082             "string",
   1083             |sect, value| sect.str(value),
   1084             &[
   1085                 (&["1", "\"1\""], "1".to_owned()),
   1086                 (&["test", "\"test\""], "test".to_owned()),
   1087                 (&["\""], "\"".to_owned()),
   1088             ],
   1089             &[],
   1090         );
   1091     }
   1092 
   1093     #[test]
   1094     fn path() {
   1095         routine(
   1096             "path",
   1097             |sect, value| sect.path(value),
   1098             &[
   1099                 (&["path"], "path".to_owned()),
   1100                 (
   1101                     &["foo/$DATADIR/bar", "foo/${DATADIR}/bar"],
   1102                     "foo/mydir/bar".to_owned(),
   1103                 ),
   1104                 (
   1105                     &["foo/$DATADIR$DATADIR/bar"],
   1106                     "foo/mydirmydir/bar".to_owned(),
   1107                 ),
   1108                 (
   1109                     &["foo/pre_$DATADIR/bar", "foo/pre_${DATADIR}/bar"],
   1110                     "foo/pre_mydir/bar".to_owned(),
   1111                 ),
   1112                 (
   1113                     &[
   1114                         "foo/${DATADIR}_next/bar",
   1115                         "foo/${UNKNOWN:-$DATADIR}_next/bar",
   1116                     ],
   1117                     "foo/mydir_next/bar".to_owned(),
   1118                 ),
   1119                 (
   1120                     &[
   1121                         "foo/${UNKNOWN:-default}_next/bar",
   1122                         "foo/${UNKNOWN:-${UNKNOWN:-default}}_next/bar",
   1123                     ],
   1124                     "foo/default_next/bar".to_owned(),
   1125                 ),
   1126                 (
   1127                     &["foo/${UNKNOWN:-pre_${UNKNOWN:-default}_next}_next/bar"],
   1128                     "foo/pre_default_next_next/bar".to_owned(),
   1129                 ),
   1130             ],
   1131             &[
   1132                 (&["foo/${A/bar"], |_| "bad substitution '/bar'".to_owned()),
   1133                 (&["foo/${A:-pre_${B}/bar"], |_| {
   1134                     "unbalanced variable expression 'pre_${B}/bar'".to_owned()
   1135                 }),
   1136                 (&["foo/${A:-${B${C}/bar"], |_| {
   1137                     "unbalanced variable expression '${B${C}/bar'".to_owned()
   1138                 }),
   1139                 (&["foo/$UNKNOWN/bar", "foo/${UNKNOWN}/bar"], |_| {
   1140                     "unbound variable 'UNKNOWN'".to_owned()
   1141                 }),
   1142                 (&["foo/$RECURSIVE/bar"], |_| {
   1143                     "recursion limit in path substitution exceeded for '$RECURSIVE'".to_owned()
   1144                 }),
   1145             ],
   1146         )
   1147     }
   1148 
   1149     #[test]
   1150     fn number() {
   1151         routine(
   1152             "number",
   1153             |sect, value| sect.number(value),
   1154             &[(&["1"], 1), (&["42"], 42)],
   1155             &[(&["true", "YES"], |it| format!("'{it}' not a valid number"))],
   1156         );
   1157     }
   1158 
   1159     #[test]
   1160     fn boolean() {
   1161         routine(
   1162             "boolean",
   1163             |sect, value| sect.boolean(value),
   1164             &[(&["yes", "YES", "Yes"], true), (&["no", "NO", "No"], false)],
   1165             &[(&["true", "1"], |it| {
   1166                 format!("expected 'YES' or 'NO' got '{it}'")
   1167             })],
   1168         );
   1169     }
   1170 
   1171     #[test]
   1172     fn amount() {
   1173         routine(
   1174             "amount",
   1175             |sect, value| sect.amount(value, "KUDOS"),
   1176             &[(
   1177                 &["KUDOS:12", "KUDOS:12.0", "KUDOS:012.0"],
   1178                 amount::amount("KUDOS:12"),
   1179             )],
   1180             &[
   1181                 (&["test", "42"], |it| {
   1182                     format!("amount '{it}' invalid format")
   1183                 }),
   1184                 (&["KUDOS:0.3ABC"], |it| {
   1185                     format!("amount '{it}' invalid fraction (invalid digit found in string)")
   1186                 }),
   1187                 (&["KUDOS:999999999999999999"], |it| {
   1188                     format!("amount '{it}' value overflow (must be <= 9007199254740992)")
   1189                 }),
   1190                 (&["EUR:12"], |_| {
   1191                     "expected currency KUDOS got EUR".to_owned()
   1192                 }),
   1193             ],
   1194         )
   1195     }
   1196 }