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