taler-rust

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

config.rs (43897B)


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