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