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