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