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