config.rs (48203B)
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; 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: String, 481 section: String, 482 option: String, 483 }, 484 Invalid { 485 ty: String, 486 section: String, 487 option: String, 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 } => write!( 522 f, 523 "invalid {ty} option {option} in section [{section}]: {err}" 524 ), 525 CfgErr::Custom(e) => e.fmt(f), 526 } 527 } 528 } 529 530 impl CfgErr { 531 pub fn custom(cause: impl std::fmt::Display) -> Self { 532 Self::Custom(cause.to_string()) 533 } 534 } 535 536 #[derive(Debug, thiserror::Error)] 537 pub enum PathsubErr { 538 #[error("recursion limit in path substitution exceeded for '{0}'")] 539 Recursion(String), 540 #[error("unbalanced variable expression '{0}'")] 541 Unbalanced(String), 542 #[error("bad substitution '{0}'")] 543 Substitution(String), 544 #[error("unbound variable '{0}'")] 545 Unbound(String), 546 } 547 548 #[derive(Debug, Clone)] 549 struct Location { 550 file: usize, 551 line: usize, 552 } 553 554 #[derive(Debug, Clone)] 555 struct Line { 556 content: String, 557 loc: Option<Location>, 558 } 559 560 #[derive(Debug)] 561 struct Inner { 562 sections: IndexMap<String, IndexMap<String, Line>>, 563 files: Vec<PathBuf>, 564 install_path: PathBuf, 565 } 566 567 #[derive(Clone)] 568 pub struct Config(Arc<Inner>); 569 570 impl std::fmt::Debug for Config { 571 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 572 self.0.fmt(f) 573 } 574 } 575 576 fn make_lowercase<'a>(s: &'a str) -> Cow<'a, str> { 577 if s.chars().all(|c| c.is_ascii_lowercase()) { 578 Cow::Borrowed(s) 579 } else { 580 Cow::Owned(s.to_ascii_lowercase()) 581 } 582 } 583 584 fn make_uppercase<'a>(s: &'a str) -> Cow<'a, str> { 585 if s.chars().all(|c| c.is_ascii_uppercase()) { 586 Cow::Borrowed(s) 587 } else { 588 Cow::Owned(s.to_ascii_uppercase()) 589 } 590 } 591 592 impl Config { 593 /// Get a config section from its name 594 pub fn section<'cfg, 'arg>(&'cfg self, name: &'arg str) -> Section<'cfg, 'arg> { 595 Section { 596 name, 597 config: self, 598 values: self.0.sections.get(make_lowercase(name).as_ref()), 599 } 600 } 601 602 /// List all config sections 603 pub fn sections<'cfg>(&'cfg self) -> impl Iterator<Item = Section<'cfg, 'cfg>> { 604 self.0.sections.iter().map(|(name, values)| Section { 605 name, 606 config: self, 607 values: Some(values), 608 }) 609 } 610 611 /** 612 * Substitute ${...} and $... placeholders in a string 613 * with values from the PATHS section in the 614 * configuration and environment variables 615 * 616 * This substitution is typically only done for paths. 617 */ 618 pub fn pathsub(&self, str: &str, depth: u8) -> Result<String, PathsubErr> { 619 if depth > 128 { 620 return Err(PathsubErr::Recursion(str.to_owned())); 621 } else if !str.contains('$') { 622 return Ok(str.to_owned()); 623 } 624 625 /** Lookup for variable value from PATHS section in the configuration and environment variables */ 626 fn lookup(cfg: &Config, name: &str, depth: u8) -> Option<Result<String, PathsubErr>> { 627 if let Some(path_res) = cfg 628 .0 629 .sections 630 .get("paths") 631 .and_then(|section| section.get(make_uppercase(name).as_ref())) 632 { 633 return Some(cfg.pathsub(&path_res.content, depth + 1)); 634 } 635 636 if let Ok(val) = std::env::var(name) { 637 return Some(Ok(val)); 638 } 639 None 640 } 641 642 let mut result = String::new(); 643 let mut remaining = str; 644 loop { 645 // Look for the next variable 646 let Some((normal, value)) = remaining.split_once('$') else { 647 result.push_str(remaining); 648 return Ok(result); 649 }; 650 651 // Append normal character 652 result.push_str(normal); 653 remaining = value; 654 655 // Check if variable is enclosed 656 let is_enclosed = if let Some(enclosed) = remaining.strip_prefix('{') { 657 // ${var 658 remaining = enclosed; 659 true 660 } else { 661 false // $var 662 }; 663 664 // Extract variable name 665 let name_end = remaining 666 .find(|c: char| !c.is_alphanumeric() && c != '_') 667 .unwrap_or(remaining.len()); 668 let (name, after_name) = remaining.split_at(name_end); 669 670 // Extract variable default if enclosed 671 let default = if !is_enclosed { 672 remaining = after_name; 673 None 674 } else if let Some(after_enclosed) = after_name.strip_prefix('}') { 675 // ${var} 676 remaining = after_enclosed; 677 None 678 } else if let Some(default) = after_name.strip_prefix(":-") { 679 // ${var:-default} 680 let mut depth = 1; 681 let Some((default, after_default)) = default.split_once(|c| { 682 if c == '{' { 683 depth += 1; 684 false 685 } else if c == '}' { 686 depth -= 1; 687 depth == 0 688 } else { 689 false 690 } 691 }) else { 692 return Err(PathsubErr::Unbalanced(default.to_owned())); 693 }; 694 remaining = after_default; 695 Some(default) 696 } else { 697 return Err(PathsubErr::Substitution(after_name.to_owned())); 698 }; 699 if let Some(resolved) = lookup(self, name, depth + 1) { 700 result.push_str(&resolved?); 701 continue; 702 } else if let Some(default) = default { 703 let resolved = self.pathsub(default, depth + 1)?; 704 result.push_str(&resolved); 705 continue; 706 } 707 return Err(PathsubErr::Unbound(name.to_owned())); 708 } 709 } 710 711 /// Print config in a human format, optionally with diagnostics information 712 pub fn print(&self, mut f: impl std::io::Write, diagnostics: bool) -> std::io::Result<()> { 713 let Inner { 714 sections, 715 files, 716 install_path, 717 } = self.0.as_ref(); 718 if diagnostics { 719 writeln!(f, "#")?; 720 writeln!(f, "# Configuration file diagnostics")?; 721 writeln!(f, "#")?; 722 writeln!(f, "# File Loaded:")?; 723 for path in files { 724 writeln!(f, "# {}", path.to_string_lossy())?; 725 } 726 writeln!(f, "#")?; 727 writeln!(f, "# Installation path: {}", install_path.to_string_lossy())?; 728 writeln!(f, "#")?; 729 writeln!(f)?; 730 } 731 for (sect, values) in sections { 732 writeln!(f, "[{sect}]")?; 733 if diagnostics { 734 writeln!(f)?; 735 } 736 for (key, Line { content, loc }) in values { 737 if diagnostics { 738 match loc { 739 Some(Location { file, line }) => { 740 let path = &files[*file]; 741 writeln!(f, "# {}:{line}", path.to_string_lossy())?; 742 } 743 None => writeln!(f, "# default")?, 744 } 745 } 746 writeln!(f, "{key} = {content}")?; 747 if diagnostics { 748 writeln!(f)?; 749 } 750 } 751 writeln!(f)?; 752 } 753 Ok(()) 754 } 755 } 756 757 /** Accessor/Converter for Taler-like configuration sections */ 758 pub struct Section<'cfg, 'arg> { 759 pub name: &'arg str, 760 config: &'cfg Config, 761 values: Option<&'cfg IndexMap<String, Line>>, 762 } 763 764 #[macro_export] 765 macro_rules! map_config { 766 ($self:expr, $ty:expr, $option:expr, $($key:expr => $parse:block),*$(,)?) => { 767 { 768 let keys = &[$($key,)*]; 769 $self.map($ty, $option, |value| { 770 match value { 771 $($key => { 772 (||Ok($parse))().map_err(|e| $crate::config::MapErr::Err(e)) 773 })*, 774 _ => Err($crate::config::MapErr::Invalid(keys)) 775 } 776 }) 777 } 778 } 779 } 780 781 pub use map_config; 782 783 #[doc(hidden)] 784 pub enum MapErr { 785 Invalid(&'static [&'static str]), 786 Err(CfgErr), 787 } 788 789 impl<'cfg, 'arg> Section<'cfg, 'arg> { 790 #[doc(hidden)] 791 fn inner<T>( 792 &self, 793 ty: &'arg str, 794 option: &'arg str, 795 transform: impl FnOnce(&'cfg str) -> Result<T, CfgErr>, 796 ) -> Value<'arg, T> { 797 let value = self 798 .values 799 .and_then(|m| m.get(make_uppercase(option).as_ref())) 800 .filter(|it| !it.content.is_empty()) 801 .map(|raw| transform(&raw.content)) 802 .transpose(); 803 Value { 804 value, 805 option, 806 ty, 807 section: self.name, 808 } 809 } 810 811 #[doc(hidden)] 812 pub fn map<T>( 813 &self, 814 ty: &'arg str, 815 option: &'arg str, 816 transform: impl FnOnce(&'cfg str) -> Result<T, MapErr>, 817 ) -> Value<'arg, T> { 818 self.inner(ty, option, |v| { 819 transform(v).map_err(|e| match e { 820 MapErr::Invalid(keys) => { 821 let mut buf = "expected '".to_owned(); 822 match keys { 823 [] => unreachable!("you must provide at least one mapping"), 824 [unique] => buf.push_str(unique), 825 [first, other @ .., last] => { 826 buf.push_str(first); 827 for k in other { 828 buf.push_str("', '"); 829 buf.push_str(k); 830 } 831 buf.push_str("' or '"); 832 buf.push_str(last); 833 } 834 } 835 buf.push_str("' got '"); 836 buf.push_str(v); 837 buf.push('\''); 838 CfgErr::Invalid { 839 ty: ty.to_owned(), 840 section: self.name.to_lowercase(), 841 option: option.to_uppercase(), 842 err: buf, 843 } 844 } 845 MapErr::Err(e) => e, 846 }) 847 }) 848 } 849 850 /** Setup an accessor/converted for a [type] at [option] using [transform] */ 851 pub fn value<T, E: std::fmt::Display>( 852 &self, 853 ty: &'arg str, 854 option: &'arg str, 855 transform: impl FnOnce(&'cfg str) -> Result<T, E>, 856 ) -> Value<'arg, T> { 857 self.inner(ty, option, |v| { 858 transform(v).map_err(|e| CfgErr::Invalid { 859 ty: ty.to_owned(), 860 section: self.name.to_lowercase(), 861 option: option.to_uppercase(), 862 err: e.to_string(), 863 }) 864 }) 865 } 866 867 /** Access [option] as a parsable type */ 868 pub fn parse<E: std::fmt::Display, T: FromStr<Err = E>>( 869 &self, 870 ty: &'arg str, 871 option: &'arg str, 872 ) -> Value<'arg, T> { 873 self.value(ty, option, |it| it.parse::<T>().map_err(|e| e.to_string())) 874 } 875 876 /** Access [option] as str */ 877 pub fn str(&self, option: &'arg str) -> Value<'arg, String> { 878 self.value("string", option, |it| Ok::<_, &str>(it.to_owned())) 879 } 880 881 /** Access [option] as compact str */ 882 pub fn cstr(&self, option: &'arg str) -> Value<'arg, CompactString> { 883 self.value("string", option, |it| Ok::<_, CompactString>(it.into())) 884 } 885 886 /** Access [option] as hex encode bytes */ 887 pub fn hex(&self, option: &'arg str) -> Value<'arg, Vec<u8>> { 888 self.value("hex", option, |it| { 889 crate::encoding::hex::decode(it.as_bytes()) 890 }) 891 } 892 893 /** Access [option] as base32 encode bytes */ 894 pub fn b32(&self, option: &'arg str) -> Value<'arg, Vec<u8>> { 895 self.value("b32", option, |it| { 896 crate::encoding::base32::decode(it.as_bytes()) 897 }) 898 } 899 900 /** Access [option] as base64 encode bytes */ 901 pub fn b64(&self, option: &'arg str) -> Value<'arg, Vec<u8>> { 902 self.value("b64", option, |it| { 903 crate::encoding::base64::decode(it.as_bytes()) 904 }) 905 } 906 907 /** Access [option] as path */ 908 pub fn path(&self, option: &'arg str) -> Value<'arg, String> { 909 self.value("path", option, |it| self.config.pathsub(it, 0)) 910 } 911 912 /** Access [option] as UNIX permissions */ 913 pub fn unix_mode(&self, option: &'arg str) -> Value<'arg, Permissions> { 914 self.value("unix mode", option, |it| { 915 u32::from_str_radix(it, 8) 916 .map(Permissions::from_mode) 917 .map_err(|_| format!("'{it}' not a valid number")) 918 }) 919 } 920 921 /** Access [option] as a number */ 922 pub fn number<T: FromStr>(&self, option: &'arg str) -> Value<'arg, T> { 923 self.value("number", option, |it| { 924 it.parse::<T>() 925 .map_err(|_| format!("'{it}' not a valid number")) 926 }) 927 } 928 929 /** Access [option] as Boolean */ 930 pub fn boolean(&self, option: &'arg str) -> Value<'arg, bool> { 931 self.value("boolean", option, |it| match it.to_uppercase().as_str() { 932 "YES" => Ok(true), 933 "NO" => Ok(false), 934 _ => Err(format!("expected 'YES' or 'NO' got '{it}'")), 935 }) 936 } 937 938 /** Access [option] as a Currency */ 939 pub fn currency(&self, option: &'arg str) -> Value<'arg, Currency> { 940 self.parse("currency", option) 941 } 942 943 /** Access [option] as an Amount */ 944 pub fn amount(&self, option: &'arg str, currency: &Currency) -> Value<'arg, Amount> { 945 self.value("amount", option, |it| { 946 let amount = it.parse::<Amount>().map_err(|e| e.to_string())?; 947 if amount.currency != *currency { 948 return Err(format!( 949 "expected currency {currency} got {}", 950 amount.currency 951 )); 952 } 953 Ok(amount) 954 }) 955 } 956 957 /** Access [option] as url */ 958 pub fn url(&self, option: &'arg str) -> Value<'arg, Url> { 959 self.parse("url", option) 960 } 961 962 /** Access [option] as base url */ 963 pub fn base_url(&self, option: &'arg str) -> Value<'arg, Url> { 964 self.value("url", option, |s| { 965 let url = Url::from_str(s).map_err(|e| e.to_string())?; 966 validate_base_url(&url)?; 967 Ok::<_, String>(url) 968 }) 969 } 970 971 /** Access [option] as payto */ 972 pub fn payto(&self, option: &'arg str) -> Value<'arg, PaytoURI> { 973 self.parse("payto", option) 974 } 975 976 /** Access [option] as Postgres URI */ 977 pub fn postgres(&self, option: &'arg str) -> Value<'arg, sqlx::postgres::PgConnectOptions> { 978 self.parse("Postgres URI", option) 979 } 980 981 /** Access [option] as a timestamp */ 982 pub fn timestamp(&self, option: &'arg str) -> Value<'arg, jiff::Timestamp> { 983 self.parse("Timestamp", option) 984 } 985 986 /** Access [option] as a time */ 987 pub fn time(&self, option: &'arg str) -> Value<'arg, jiff::civil::Time> { 988 self.parse("Time", option) 989 } 990 991 /** Access [option] as a date */ 992 pub fn date(&self, option: &'arg str) -> Value<'arg, jiff::civil::Date> { 993 self.parse("Date", option) 994 } 995 996 /** Access [option] as a duration */ 997 pub fn duration(&self, option: &'arg str) -> Value<'arg, Duration> { 998 self.value("temporal", option, |it| { 999 let tmp = SignedDuration::from_str(it).map_err(|e| e.to_string())?; 1000 Ok::<_, String>(Duration::from_millis(tmp.as_millis() as u64)) 1001 }) 1002 } 1003 1004 /** Access [option] as a duration */ 1005 pub fn span(&self, option: &'arg str) -> Value<'arg, Span> { 1006 self.parse("temporal", option) 1007 } 1008 1009 /** Access [option] as a regex */ 1010 pub fn regex(&self, option: &'arg str) -> Value<'arg, regex::Regex> { 1011 self.parse("Pattern", option) 1012 } 1013 1014 /** Access option as json object */ 1015 pub fn json<'de, T: serde::Deserialize<'de>>(&'de self, option: &'arg str) -> Value<'arg, T> { 1016 self.value("json", option, |it| serde_json::from_str(it)) 1017 } 1018 } 1019 1020 pub struct Value<'arg, T> { 1021 value: Result<Option<T>, CfgErr>, 1022 option: &'arg str, 1023 ty: &'arg str, 1024 section: &'arg str, 1025 } 1026 1027 impl<T> Value<'_, T> { 1028 pub fn opt(self) -> Result<Option<T>, CfgErr> { 1029 self.value 1030 } 1031 1032 /** Converted value of default if missing */ 1033 pub fn default(self, default: T) -> Result<T, CfgErr> { 1034 Ok(self.value?.unwrap_or(default)) 1035 } 1036 1037 /** Converted value or throw if missing */ 1038 pub fn require(self) -> Result<T, CfgErr> { 1039 self.value?.ok_or_else(|| CfgErr::Missing { 1040 ty: self.ty.to_owned(), 1041 section: self.section.to_lowercase(), 1042 option: self.option.to_uppercase(), 1043 }) 1044 } 1045 } 1046 1047 #[cfg(test)] 1048 mod test { 1049 use std::{ 1050 fmt::{Debug, Display}, 1051 fs::{File, Permissions}, 1052 os::unix::fs::PermissionsExt, 1053 }; 1054 1055 use tracing::error; 1056 1057 use super::{Config, Section, Value}; 1058 use crate::{ 1059 config::parser::ConfigSource, 1060 types::amount::{self, Currency}, 1061 }; 1062 1063 const SOURCE: ConfigSource = ConfigSource::new("test", "test", "test"); 1064 1065 #[track_caller] 1066 fn check_err<T: Debug, E: Display>(err: impl AsRef<str>, lambda: Result<T, E>) { 1067 let failure = lambda.unwrap_err(); 1068 let fmt = failure.to_string(); 1069 assert_eq!(err.as_ref(), fmt); 1070 } 1071 1072 /// [`check_err`] for messages with an environment-dependent middle: only 1073 /// the head and the tail are compared. 1074 #[track_caller] 1075 fn check_err_loose<T: Debug, E: Display>( 1076 head: impl AsRef<str>, 1077 tail: impl AsRef<str>, 1078 lambda: Result<T, E>, 1079 ) { 1080 let failure = lambda.unwrap_err(); 1081 let fmt = failure.to_string(); 1082 let (head, tail) = (head.as_ref(), tail.as_ref()); 1083 assert!( 1084 fmt.starts_with(head) && fmt.ends_with(tail), 1085 "expected an error starting with '{head}' and ending with '{tail}', got '{fmt}'" 1086 ); 1087 } 1088 1089 #[test] 1090 fn fs() { 1091 let dir = tempfile::tempdir().unwrap(); 1092 let config_path = dir.path().join("test-conf.conf"); 1093 let second_path = dir.path().join("test-second-conf.conf"); 1094 1095 let config_path_fmt = config_path.to_string_lossy(); 1096 let second_path_fmt = second_path.to_string_lossy(); 1097 1098 let check_err = |err: String| check_err(err, Config::load(SOURCE, Some(&config_path))); 1099 let check_ok = || Config::load(SOURCE, Some(&config_path)).unwrap(); 1100 1101 check_err(format!( 1102 "could not read config at '{config_path_fmt}': entity not found" 1103 )); 1104 1105 let config_file = std::fs::File::create_new(&config_path).unwrap(); 1106 config_file 1107 .set_permissions(Permissions::from_mode(0o222)) 1108 .unwrap(); 1109 if File::open(&config_path).is_ok() { 1110 error!("Cannot finish this test if root"); 1111 return; 1112 } 1113 check_err(format!( 1114 "could not read config at '{config_path_fmt}': permission denied" 1115 )); 1116 1117 config_file 1118 .set_permissions(Permissions::from_mode(0o666)) 1119 .unwrap(); 1120 check_ok(); 1121 std::fs::write(&config_path, "@inline@ test-second-conf.conf").unwrap(); 1122 check_err(format!( 1123 "could not read config at '{second_path_fmt}': entity not found" 1124 )); 1125 1126 let second_file = std::fs::File::create_new(&second_path).unwrap(); 1127 second_file 1128 .set_permissions(Permissions::from_mode(0o222)) 1129 .unwrap(); 1130 check_err(format!( 1131 "could not read config at '{second_path_fmt}': permission denied" 1132 )); 1133 1134 std::fs::write(&config_path, "@inline-matching@[*").unwrap(); 1135 // glob reports the offset of the '[' within the *expanded* pattern 1136 // (<tempdir>/[*), so the position moves with the length of $TMPDIR. 1137 check_err_loose( 1138 format!( 1139 "malformed glob regex at '{config_path_fmt}:1': Pattern syntax error near position " 1140 ), 1141 ": invalid range pattern", 1142 Config::load(SOURCE, Some(&config_path)), 1143 ); 1144 1145 std::fs::write(&config_path, "@inline-matching@*second-conf.conf").unwrap(); 1146 check_err(format!( 1147 "could not read config at '{second_path_fmt}': permission denied" 1148 )); 1149 1150 std::fs::write(&config_path, "\n@inline-matching@*.conf").unwrap(); 1151 check_err(format!( 1152 "recursion limit in config inlining at '{config_path_fmt}:2'" 1153 )); 1154 std::fs::write(&config_path, "\n\n@inline-matching@ *.conf").unwrap(); 1155 check_err(format!( 1156 "recursion limit in config inlining at '{config_path_fmt}:3'" 1157 )); 1158 1159 std::fs::write(&config_path, "@inline-secret@ secret test-second-conf.conf").unwrap(); 1160 check_ok(); 1161 } 1162 1163 #[test] 1164 fn parsing() { 1165 let check = |err: &str, content: &str| check_err(err, Config::from_mem(content)); 1166 1167 check( 1168 "expected section header, option assignment or directive at 'mem:1'", 1169 "syntax error", 1170 ); 1171 check( 1172 "expected section header or directive at 'mem:1'", 1173 "key=value", 1174 ); 1175 check( 1176 "expected section header, option assignment or directive at 'mem:2'", 1177 "[section]\nbad-line", 1178 ); 1179 1180 let cfg = Config::from_mem( 1181 r#" 1182 1183 [section-a] 1184 1185 bar = baz 1186 1187 [section-b] 1188 1189 first_value = 1 1190 second_value = "test" 1191 1192 "#, 1193 ) 1194 .unwrap(); 1195 1196 // Missing section 1197 check_err( 1198 "missing string option VALUE in section [unknown]", 1199 cfg.section("unknown").str("value").require(), 1200 ); 1201 1202 // Missing value 1203 check_err( 1204 "missing string option VALUE in section [section-a]", 1205 cfg.section("section-a").str("value").require(), 1206 ); 1207 } 1208 1209 #[allow(clippy::type_complexity)] 1210 fn routine<T: Debug + Eq>( 1211 ty: &str, 1212 mut lambda: impl for<'cfg, 'arg> FnMut(&Section<'cfg, 'arg>, &'arg str) -> Value<'arg, T>, 1213 wellformed: &[(&[&str], T)], 1214 malformed: &[(&[&str], fn(&str) -> String)], 1215 ) { 1216 let conf = |content: &str| { 1217 Config::from_mem(&format!( 1218 "[PATHS]\nDATADIR=mydir\nRECURSIVE=$RECURSIVE\n{content}" 1219 )) 1220 .unwrap() 1221 }; 1222 1223 // Check missing msg 1224 let cfg = conf(""); 1225 check_err( 1226 format!("missing {ty} option VALUE in section [section]"), 1227 lambda(&cfg.section("section"), "value").require(), 1228 ); 1229 1230 // Check wellformed options are properly parsed 1231 for (raws, expected) in wellformed { 1232 for raw in *raws { 1233 let cfg = conf(&format!("[section]\nvalue={raw}")); 1234 dbg!(&cfg); 1235 assert_eq!( 1236 *expected, 1237 lambda(&cfg.section("section"), "value").require().unwrap() 1238 ); 1239 } 1240 } 1241 1242 // Check malformed options have proper error message 1243 for (raws, error_fmt) in malformed { 1244 for raw in *raws { 1245 let cfg = conf(&format!("[section]\nvalue={raw}")); 1246 check_err( 1247 format!( 1248 "invalid {ty} option VALUE in section [section]: {}", 1249 error_fmt(raw) 1250 ), 1251 lambda(&cfg.section("section"), "value").require(), 1252 ) 1253 } 1254 } 1255 } 1256 1257 #[test] 1258 fn string() { 1259 routine( 1260 "string", 1261 |sect, value| sect.str(value), 1262 &[ 1263 (&["1", "\"1\""], "1".to_owned()), 1264 (&["test", "\"test\""], "test".to_owned()), 1265 (&["\""], "\"".to_owned()), 1266 ], 1267 &[], 1268 ); 1269 } 1270 1271 #[test] 1272 fn path() { 1273 routine( 1274 "path", 1275 |sect, value| sect.path(value), 1276 &[ 1277 (&["path"], "path".to_owned()), 1278 ( 1279 &["foo/$DATADIR/bar", "foo/${DATADIR}/bar"], 1280 "foo/mydir/bar".to_owned(), 1281 ), 1282 ( 1283 &["foo/$DATADIR$DATADIR/bar"], 1284 "foo/mydirmydir/bar".to_owned(), 1285 ), 1286 ( 1287 &["foo/pre_$DATADIR/bar", "foo/pre_${DATADIR}/bar"], 1288 "foo/pre_mydir/bar".to_owned(), 1289 ), 1290 ( 1291 &[ 1292 "foo/${DATADIR}_next/bar", 1293 "foo/${UNKNOWN:-$DATADIR}_next/bar", 1294 ], 1295 "foo/mydir_next/bar".to_owned(), 1296 ), 1297 ( 1298 &[ 1299 "foo/${UNKNOWN:-default}_next/bar", 1300 "foo/${UNKNOWN:-${UNKNOWN:-default}}_next/bar", 1301 ], 1302 "foo/default_next/bar".to_owned(), 1303 ), 1304 ( 1305 &["foo/${UNKNOWN:-pre_${UNKNOWN:-default}_next}_next/bar"], 1306 "foo/pre_default_next_next/bar".to_owned(), 1307 ), 1308 ], 1309 &[ 1310 (&["foo/${A/bar"], |_| "bad substitution '/bar'".to_owned()), 1311 (&["foo/${A:-pre_${B}/bar"], |_| { 1312 "unbalanced variable expression 'pre_${B}/bar'".to_owned() 1313 }), 1314 (&["foo/${A:-${B${C}/bar"], |_| { 1315 "unbalanced variable expression '${B${C}/bar'".to_owned() 1316 }), 1317 (&["foo/$UNKNOWN/bar", "foo/${UNKNOWN}/bar"], |_| { 1318 "unbound variable 'UNKNOWN'".to_owned() 1319 }), 1320 (&["foo/$RECURSIVE/bar"], |_| { 1321 "recursion limit in path substitution exceeded for '$RECURSIVE'".to_owned() 1322 }), 1323 ], 1324 ) 1325 } 1326 1327 #[test] 1328 fn number() { 1329 routine( 1330 "number", 1331 |sect, value| sect.number(value), 1332 &[(&["1"], 1), (&["42"], 42)], 1333 &[(&["true", "YES"], |it| format!("'{it}' not a valid number"))], 1334 ); 1335 } 1336 1337 #[test] 1338 fn boolean() { 1339 routine( 1340 "boolean", 1341 |sect, value| sect.boolean(value), 1342 &[(&["yes", "YES", "Yes"], true), (&["no", "NO", "No"], false)], 1343 &[(&["true", "1"], |it| { 1344 format!("expected 'YES' or 'NO' got '{it}'") 1345 })], 1346 ); 1347 } 1348 1349 #[test] 1350 fn amount() { 1351 routine( 1352 "amount", 1353 |sect, value| sect.amount(value, &Currency::KUDOS), 1354 &[( 1355 &["KUDOS:12", "KUDOS:12.0", "KUDOS:012.0"], 1356 amount::amount("KUDOS:12"), 1357 )], 1358 &[ 1359 (&["test", "42"], |it| { 1360 format!("amount '{it}' invalid format") 1361 }), 1362 (&["KUDOS:0.3ABC"], |it| { 1363 format!("amount '{it}' invalid fraction (invalid digit found in string)") 1364 }), 1365 (&["KUDOS:999999999999999999"], |it| { 1366 format!("amount '{it}' value overflow (must be <= 4503599627370496)") 1367 }), 1368 (&["EUR:12"], |_| { 1369 "expected currency KUDOS got EUR".to_owned() 1370 }), 1371 ], 1372 ) 1373 } 1374 1375 #[test] 1376 fn map() { 1377 #[derive(Debug, PartialEq, Eq)] 1378 enum Mode { 1379 Tcp(u16), 1380 Unix, 1381 Systemd, 1382 } 1383 1384 fn parse<'cfg, 'arg>(sect: &Section<'cfg, 'arg>, value: &'arg str) -> Value<'arg, Mode> { 1385 map_config!(sect, "mode", value, 1386 "tcp" => { Mode::Tcp(sect.number("PORT").require()?) }, 1387 "unix" => { Mode::Unix }, 1388 "systemd" => { Mode::Systemd }, 1389 ) 1390 } 1391 1392 routine( 1393 "mode", 1394 parse, 1395 &[(&["unix"], Mode::Unix), (&["systemd"], Mode::Systemd)], 1396 &[(&["udp", "TCP"], |it| { 1397 format!("expected 'tcp', 'unix' or 'systemd' got '{it}'") 1398 })], 1399 ); 1400 1401 let cfg = Config::from_mem("[section]\nvalue=tcp").unwrap(); 1402 1403 check_err( 1404 "missing number option PORT in section [section]", 1405 parse(&cfg.section("section"), "value").require(), 1406 ); 1407 1408 check_err( 1409 "invalid mode option VALUE in section [section]: expected 'unix' got 'tcp'", 1410 map_config!(&cfg.section("section"), "mode", "value", 1411 "unix" => { Mode::Unix }, 1412 ) 1413 .require(), 1414 ); 1415 } 1416 }