taler-rust

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

config.rs (43799B)


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