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