config.rs (44095B)
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 pub fn finish(self) -> Config { 344 // Convert to a readonly config struct without location info 345 Config(Arc::new(Inner { 346 sections: self.sections, 347 files: self.files, 348 install_path: self.install_path, 349 })) 350 } 351 } 352 353 /** Information about how the configuration is loaded */ 354 #[derive(Debug, Clone, Copy)] 355 pub struct ConfigSource { 356 /** Name of the high-level project */ 357 pub project_name: &'static str, 358 /** Name of the component within the package */ 359 pub component_name: &'static str, 360 /** 361 * Executable name that will be located on $PATH to 362 * find the installation path of the package 363 */ 364 pub exec_name: &'static str, 365 } 366 367 impl ConfigSource { 368 /// Create a new config source 369 pub const fn new( 370 project_name: &'static str, 371 component_name: &'static str, 372 exec_name: &'static str, 373 ) -> Self { 374 Self { 375 project_name, 376 component_name, 377 exec_name, 378 } 379 } 380 381 /// Create a config source where the project, component and exec names are the same 382 pub const fn simple(name: &'static str) -> Self { 383 Self::new(name, name, name) 384 } 385 386 /** 387 * Search the default configuration file path 388 * 389 * I will be the first existing file from this list: 390 * - $XDG_CONFIG_HOME/$componentName.conf 391 * - $HOME/.config/$componentName.conf 392 * - /etc/$componentName.conf 393 * - /etc/$projectName/$componentName.conf 394 * */ 395 fn default_config_path(&self) -> Result<Option<PathBuf>, (PathBuf, std::io::Error)> { 396 // TODO use a generator 397 let conf_name = format!("{}.conf", self.component_name); 398 399 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { 400 let path = PathBuf::from(xdg).join(&conf_name); 401 match path.try_exists() { 402 Ok(false) => {} 403 Ok(true) => return Ok(Some(path)), 404 Err(e) => return Err((path, e)), 405 } 406 } 407 408 if let Some(home) = std::env::var_os("HOME") { 409 let path = PathBuf::from(home).join(".config").join(&conf_name); 410 match path.try_exists() { 411 Ok(false) => {} 412 Ok(true) => return Ok(Some(path)), 413 Err(e) => return Err((path, e)), 414 } 415 } 416 417 let path = PathBuf::from("/etc").join(&conf_name); 418 match path.try_exists() { 419 Ok(false) => {} 420 Ok(true) => return Ok(Some(path)), 421 Err(e) => return Err((path, e)), 422 } 423 424 let path = PathBuf::from("/etc") 425 .join(self.project_name) 426 .join(&conf_name); 427 match path.try_exists() { 428 Ok(false) => {} 429 Ok(true) => return Ok(Some(path)), 430 Err(e) => return Err((path, e)), 431 } 432 433 Ok(None) 434 } 435 436 /** Search for the binary installation path in PATH */ 437 fn install_path(&self) -> Result<PathBuf, (PathBuf, std::io::Error)> { 438 let path_env = std::env::var("PATH").unwrap(); 439 for path_dir in path_env.split(':') { 440 let path_dir = PathBuf::from(path_dir); 441 let bin_path = path_dir.join(self.exec_name); 442 if bin_path.exists() 443 && let Some(parent) = path_dir.parent() 444 { 445 return parent.canonicalize().map_err(|e| (parent.to_path_buf(), e)); 446 } 447 } 448 Ok(PathBuf::from("/usr")) 449 } 450 } 451 452 impl Config { 453 pub fn from_file( 454 src: ConfigSource, 455 path: Option<impl Into<PathBuf>>, 456 ) -> Result<Config, ParserErr> { 457 let mut parser = Parser::empty(); 458 parser.load_env(src)?; 459 match path { 460 Some(path) => parser.parse_file(path.into(), 0)?, 461 None => { 462 if let Some(default) = src 463 .default_config_path() 464 .map_err(|(p, e)| io_err("find default config path", p, e))? 465 { 466 parser.parse_file(default, 0)?; 467 } 468 } 469 } 470 Ok(parser.finish()) 471 } 472 473 pub fn from_mem(str: &str) -> Result<Config, ParserErr> { 474 let mut parser = Parser::empty(); 475 parser.parse_str(str)?; 476 Ok(parser.finish()) 477 } 478 479 pub fn from_mem_with_env(src: ConfigSource, str: &str) -> Result<Config, ParserErr> { 480 let mut parser = Parser::empty(); 481 parser.load_env(src)?; 482 parser.parse_str(str)?; 483 Ok(parser.finish()) 484 } 485 } 486 } 487 488 #[derive(Debug, thiserror::Error)] 489 pub enum ValueErr { 490 #[error("Missing {ty} option '{option}' in section '{section}'")] 491 Missing { 492 ty: String, 493 section: String, 494 option: String, 495 }, 496 #[error("Invalid {ty} option '{option}' in section '{section}': {err}")] 497 Invalid { 498 ty: String, 499 section: String, 500 option: String, 501 err: String, 502 }, 503 } 504 505 #[derive(Debug, thiserror::Error)] 506 507 pub enum PathsubErr { 508 #[error("recursion limit in path substitution exceeded for '{0}'")] 509 Recursion(String), 510 #[error("unbalanced variable expression '{0}'")] 511 Unbalanced(String), 512 #[error("bad substitution '{0}'")] 513 Substitution(String), 514 #[error("unbound variable '{0}'")] 515 Unbound(String), 516 } 517 518 #[derive(Debug, Clone)] 519 struct Location { 520 file: usize, 521 line: usize, 522 } 523 524 #[derive(Debug, Clone)] 525 struct Line { 526 content: String, 527 loc: Option<Location>, 528 } 529 530 #[derive(Debug)] 531 pub struct Inner { 532 sections: IndexMap<String, IndexMap<String, Line>>, 533 files: Vec<PathBuf>, 534 install_path: PathBuf, 535 } 536 537 #[derive(Clone)] 538 pub struct Config(Arc<Inner>); 539 540 impl Debug for Config { 541 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 542 self.0.fmt(f) 543 } 544 } 545 546 impl Config { 547 pub fn section<'cfg, 'arg>(&'cfg self, section: &'arg str) -> Section<'cfg, 'arg> { 548 Section { 549 name: section, 550 config: self, 551 values: self.0.sections.get(§ion.to_uppercase()), 552 } 553 } 554 555 pub fn sections<'cfg>(&'cfg self) -> impl Iterator<Item = Section<'cfg, 'cfg>> { 556 self.0.sections.iter().map(|(section, values)| Section { 557 name: section, 558 config: self, 559 values: Some(values), 560 }) 561 } 562 563 /** 564 * Substitute ${...} and $... placeholders in a string 565 * with values from the PATHS section in the 566 * configuration and environment variables 567 * 568 * This substitution is typically only done for paths. 569 */ 570 pub fn pathsub(&self, str: &str, depth: u8) -> Result<String, PathsubErr> { 571 if depth > 128 { 572 return Err(PathsubErr::Recursion(str.to_owned())); 573 } else if !str.contains('$') { 574 return Ok(str.to_owned()); 575 } 576 577 /** Lookup for variable value from PATHS section in the configuration and environment variables */ 578 fn lookup(cfg: &Config, name: &str, depth: u8) -> Option<Result<String, PathsubErr>> { 579 if let Some(path_res) = cfg 580 .0 581 .sections 582 .get("PATHS") 583 .and_then(|section| section.get(name)) 584 { 585 return Some(cfg.pathsub(&path_res.content, depth + 1)); 586 } 587 588 if let Ok(val) = std::env::var(name) { 589 return Some(Ok(val)); 590 } 591 None 592 } 593 594 let mut result = String::new(); 595 let mut remaining = str; 596 loop { 597 // Look for the next variable 598 let Some((normal, value)) = remaining.split_once('$') else { 599 result.push_str(remaining); 600 return Ok(result); 601 }; 602 603 // Append normal character 604 result.push_str(normal); 605 remaining = value; 606 607 // Check if variable is enclosed 608 let is_enclosed = if let Some(enclosed) = remaining.strip_prefix('{') { 609 // ${var 610 remaining = enclosed; 611 true 612 } else { 613 false // $var 614 }; 615 616 // Extract variable name 617 let name_end = remaining 618 .find(|c: char| !c.is_alphanumeric() && c != '_') 619 .unwrap_or(remaining.len()); 620 let (name, after_name) = remaining.split_at(name_end); 621 622 // Extract variable default if enclosed 623 let default = if !is_enclosed { 624 remaining = after_name; 625 None 626 } else if let Some(after_enclosed) = after_name.strip_prefix('}') { 627 // ${var} 628 remaining = after_enclosed; 629 None 630 } else if let Some(default) = after_name.strip_prefix(":-") { 631 // ${var:-default} 632 let mut depth = 1; 633 let Some((default, after_default)) = default.split_once(|c| { 634 if c == '{' { 635 depth += 1; 636 false 637 } else if c == '}' { 638 depth -= 1; 639 depth == 0 640 } else { 641 false 642 } 643 }) else { 644 return Err(PathsubErr::Unbalanced(default.to_owned())); 645 }; 646 remaining = after_default; 647 Some(default) 648 } else { 649 return Err(PathsubErr::Substitution(after_name.to_owned())); 650 }; 651 if let Some(resolved) = lookup(self, name, depth + 1) { 652 result.push_str(&resolved?); 653 continue; 654 } else if let Some(default) = default { 655 let resolved = self.pathsub(default, depth + 1)?; 656 result.push_str(&resolved); 657 continue; 658 } 659 return Err(PathsubErr::Unbound(name.to_owned())); 660 } 661 } 662 663 pub fn print(&self, mut f: impl std::io::Write, diagnostics: bool) -> std::io::Result<()> { 664 let Inner { 665 sections, 666 files, 667 install_path, 668 } = self.0.as_ref(); 669 if diagnostics { 670 writeln!(f, "#")?; 671 writeln!(f, "# Configuration file diagnostics")?; 672 writeln!(f, "#")?; 673 writeln!(f, "# File Loaded:")?; 674 for path in files { 675 writeln!(f, "# {}", path.to_string_lossy())?; 676 } 677 writeln!(f, "#")?; 678 writeln!(f, "# Installation path: {}", install_path.to_string_lossy())?; 679 writeln!(f, "#")?; 680 writeln!(f)?; 681 } 682 for (sect, values) in sections { 683 writeln!(f, "[{sect}]")?; 684 if diagnostics { 685 writeln!(f)?; 686 } 687 for (key, Line { content, loc }) in values { 688 if diagnostics { 689 match loc { 690 Some(Location { file, line }) => { 691 let path = &files[*file]; 692 writeln!(f, "# {}:{line}", path.to_string_lossy())?; 693 } 694 None => writeln!(f, "# default")?, 695 } 696 } 697 writeln!(f, "{key} = {content}")?; 698 if diagnostics { 699 writeln!(f)?; 700 } 701 } 702 writeln!(f)?; 703 } 704 Ok(()) 705 } 706 } 707 708 /** Accessor/Converter for Taler-like configuration sections */ 709 pub struct Section<'cfg, 'arg> { 710 pub name: &'arg str, 711 config: &'cfg Config, 712 values: Option<&'cfg IndexMap<String, Line>>, 713 } 714 715 #[macro_export] 716 macro_rules! map_config { 717 ($self:expr, $ty:expr, $option:expr, $($key:expr => $parse:block),*$(,)?) => { 718 { 719 let keys = &[$($key,)*]; 720 $self.map($ty, $option, |value| { 721 match value { 722 $($key => { 723 (||Ok($parse))().map_err(|e| ::taler_common::config::MapErr::Err(e)) 724 })*, 725 _ => Err(::taler_common::config::MapErr::Invalid(keys)) 726 } 727 }) 728 } 729 } 730 } 731 732 pub use map_config; 733 734 #[doc(hidden)] 735 pub enum MapErr { 736 Invalid(&'static [&'static str]), 737 Err(ValueErr), 738 } 739 740 impl<'cfg, 'arg> Section<'cfg, 'arg> { 741 #[doc(hidden)] 742 fn inner<T>( 743 &self, 744 ty: &'arg str, 745 option: &'arg str, 746 transform: impl FnOnce(&'cfg str) -> Result<T, ValueErr>, 747 ) -> Value<'arg, T> { 748 let value = self 749 .values 750 .and_then(|m| m.get(&option.to_uppercase())) 751 .filter(|it| !it.content.is_empty()) 752 .map(|raw| transform(&raw.content)) 753 .transpose(); 754 Value { 755 value, 756 option, 757 ty, 758 section: self.name, 759 } 760 } 761 762 #[doc(hidden)] 763 pub fn map<T>( 764 &self, 765 ty: &'arg str, 766 option: &'arg str, 767 transform: impl FnOnce(&'cfg str) -> Result<T, MapErr>, 768 ) -> Value<'arg, T> { 769 self.value(ty, option, |v| { 770 transform(v).map_err(|e| match e { 771 MapErr::Invalid(keys) => { 772 let mut buf = "expected '".to_owned(); 773 match keys { 774 [] => unreachable!("you must provide at least one mapping"), 775 [unique] => buf.push_str(unique), 776 [first, other @ .., last] => { 777 buf.push_str(first); 778 for k in other { 779 buf.push_str("', '"); 780 buf.push_str(k); 781 } 782 buf.push_str("' or '"); 783 buf.push_str(last); 784 } 785 } 786 buf.push_str("' got '"); 787 buf.push_str(v); 788 buf.push('\''); 789 ValueErr::Invalid { 790 ty: ty.to_owned(), 791 section: self.name.to_owned(), 792 option: option.to_owned(), 793 err: buf, 794 } 795 } 796 MapErr::Err(e) => e, 797 }) 798 }) 799 } 800 801 /** Setup an accessor/converted for a [type] at [option] using [transform] */ 802 pub fn value<T, E: Display>( 803 &self, 804 ty: &'arg str, 805 option: &'arg str, 806 transform: impl FnOnce(&'cfg str) -> Result<T, E>, 807 ) -> Value<'arg, T> { 808 self.inner(ty, option, |v| { 809 transform(v).map_err(|e| ValueErr::Invalid { 810 ty: ty.to_owned(), 811 section: self.name.to_owned(), 812 option: option.to_owned(), 813 err: e.to_string(), 814 }) 815 }) 816 } 817 818 /** Access [option] as a parsable type */ 819 pub fn parse<E: std::fmt::Display, T: FromStr<Err = E>>( 820 &self, 821 ty: &'arg str, 822 option: &'arg str, 823 ) -> Value<'arg, T> { 824 self.value(ty, option, |it| it.parse::<T>().map_err(|e| e.to_string())) 825 } 826 827 /** Access [option] as str */ 828 pub fn str(&self, option: &'arg str) -> Value<'arg, String> { 829 self.value("string", option, |it| Ok::<_, &str>(it.to_owned())) 830 } 831 832 /** Access [option] as compact str */ 833 pub fn cstr(&self, option: &'arg str) -> Value<'arg, CompactString> { 834 self.value("string", option, |it| Ok::<_, CompactString>(it.into())) 835 } 836 837 /** Access [option] as hex encode bytes */ 838 pub fn hex(&self, option: &'arg str) -> Value<'arg, Vec<u8>> { 839 self.value("hex", option, |it| { 840 crate::encoding::hex::decode(it.as_bytes()) 841 }) 842 } 843 844 /** Access [option] as base32 encode bytes */ 845 pub fn b32(&self, option: &'arg str) -> Value<'arg, Vec<u8>> { 846 self.value("hex", option, |it| { 847 crate::encoding::base32::decode(it.as_bytes()) 848 }) 849 } 850 851 /** Access [option] as base64 encode bytes */ 852 pub fn b64(&self, option: &'arg str) -> Value<'arg, Vec<u8>> { 853 self.value("hex", option, |it| { 854 crate::encoding::base64::decode(it.as_bytes()) 855 }) 856 } 857 858 /** Access [option] as path */ 859 pub fn path(&self, option: &'arg str) -> Value<'arg, String> { 860 self.value("path", option, |it| self.config.pathsub(it, 0)) 861 } 862 863 /** Access [option] as UNIX permissions */ 864 pub fn unix_mode(&self, option: &'arg str) -> Value<'arg, Permissions> { 865 self.value("unix mode", option, |it| { 866 u32::from_str_radix(it, 8) 867 .map(Permissions::from_mode) 868 .map_err(|_| format!("'{it}' not a valid number")) 869 }) 870 } 871 872 /** Access [option] as a number */ 873 pub fn number<T: FromStr>(&self, option: &'arg str) -> Value<'arg, T> { 874 self.value("number", option, |it| { 875 it.parse::<T>() 876 .map_err(|_| format!("'{it}' not a valid number")) 877 }) 878 } 879 880 /** Access [option] as Boolean */ 881 pub fn boolean(&self, option: &'arg str) -> Value<'arg, bool> { 882 self.value("boolean", option, |it| match it.to_uppercase().as_str() { 883 "YES" => Ok(true), 884 "NO" => Ok(false), 885 _ => Err(format!("expected 'YES' or 'NO' got '{it}'")), 886 }) 887 } 888 889 /** Access [option] as a Currency */ 890 pub fn currency(&self, option: &'arg str) -> Value<'arg, Currency> { 891 self.parse("currency", option) 892 } 893 894 /** Access [option] as an Amount */ 895 pub fn amount(&self, option: &'arg str, currency: &Currency) -> Value<'arg, Amount> { 896 self.value("amount", option, |it| { 897 let amount = it.parse::<Amount>().map_err(|e| e.to_string())?; 898 if amount.currency != *currency { 899 return Err(format!( 900 "expected currency {currency} got {}", 901 amount.currency 902 )); 903 } 904 Ok(amount) 905 }) 906 } 907 908 /** Access [option] as url */ 909 pub fn url(&self, option: &'arg str) -> Value<'arg, Url> { 910 self.parse("url", option) 911 } 912 913 /** Access [option] as base url */ 914 pub fn base_url(&self, option: &'arg str) -> Value<'arg, Url> { 915 self.value("url", option, |s| { 916 let url = Url::from_str(s).map_err(|e| e.to_string())?; 917 validate_base_url(&url)?; 918 Ok::<_, String>(url) 919 }) 920 } 921 922 /** Access [option] as payto */ 923 pub fn payto(&self, option: &'arg str) -> Value<'arg, PaytoURI> { 924 self.parse("payto", option) 925 } 926 927 /** Access [option] as Postgres URI */ 928 pub fn postgres(&self, option: &'arg str) -> Value<'arg, sqlx::postgres::PgConnectOptions> { 929 self.parse("Postgres URI", option) 930 } 931 932 /** Access [option] as a timestamp */ 933 pub fn timestamp(&self, option: &'arg str) -> Value<'arg, jiff::Timestamp> { 934 self.parse("Timestamp", option) 935 } 936 937 /** Access [option] as a time */ 938 pub fn time(&self, option: &'arg str) -> Value<'arg, jiff::civil::Time> { 939 self.parse("Time", option) 940 } 941 942 /** Access [option] as a date */ 943 pub fn date(&self, option: &'arg str) -> Value<'arg, jiff::civil::Date> { 944 self.parse("Date", option) 945 } 946 947 /** Access [option] as a duration */ 948 pub fn duration(&self, option: &'arg str) -> Value<'arg, Duration> { 949 self.value("temporal", option, |it| { 950 let tmp = SignedDuration::from_str(it).map_err(|e| e.to_string())?; 951 Ok::<_, String>(Duration::from_millis(tmp.as_millis() as u64)) 952 }) 953 } 954 955 /** Access [option] as a duration */ 956 pub fn span(&self, option: &'arg str) -> Value<'arg, Span> { 957 self.parse("temporal", option) 958 } 959 960 /** Access [option] as a regex */ 961 pub fn regex(&self, option: &'arg str) -> Value<'arg, regex::Regex> { 962 self.parse("Pattern", option) 963 } 964 965 /** Access option as json object */ 966 pub fn json<'de, T: serde::Deserialize<'de>>(&'de self, option: &'arg str) -> Value<'arg, T> { 967 self.value("json", option, |it| serde_json::from_str(it)) 968 } 969 } 970 971 pub struct Value<'arg, T> { 972 value: Result<Option<T>, ValueErr>, 973 option: &'arg str, 974 ty: &'arg str, 975 section: &'arg str, 976 } 977 978 impl<T> Value<'_, T> { 979 pub fn opt(self) -> Result<Option<T>, ValueErr> { 980 self.value 981 } 982 983 /** Converted value of default if missing */ 984 pub fn default(self, default: T) -> Result<T, ValueErr> { 985 Ok(self.value?.unwrap_or(default)) 986 } 987 988 /** Converted value or throw if missing */ 989 pub fn require(self) -> Result<T, ValueErr> { 990 self.value?.ok_or_else(|| ValueErr::Missing { 991 ty: self.ty.to_owned(), 992 section: self.section.to_owned(), 993 option: self.option.to_owned(), 994 }) 995 } 996 } 997 998 #[cfg(test)] 999 mod test { 1000 use std::{ 1001 fmt::{Debug, Display}, 1002 fs::{File, Permissions}, 1003 os::unix::fs::PermissionsExt, 1004 }; 1005 1006 use tracing::error; 1007 1008 use super::{Config, Section, Value}; 1009 use crate::{ 1010 config::parser::ConfigSource, 1011 types::amount::{self, Currency}, 1012 }; 1013 1014 const SOURCE: ConfigSource = ConfigSource::new("test", "test", "test"); 1015 1016 #[track_caller] 1017 fn check_err<T: Debug, E: Display>(err: impl AsRef<str>, lambda: Result<T, E>) { 1018 let failure = lambda.unwrap_err(); 1019 let fmt = failure.to_string(); 1020 assert_eq!(err.as_ref(), fmt); 1021 } 1022 1023 #[test] 1024 fn fs() { 1025 let dir = tempfile::tempdir().unwrap(); 1026 let config_path = dir.path().join("test-conf.conf"); 1027 let second_path = dir.path().join("test-second-conf.conf"); 1028 1029 let config_path_fmt = config_path.to_string_lossy(); 1030 let second_path_fmt = second_path.to_string_lossy(); 1031 1032 let check_err = |err: String| check_err(err, Config::from_file(SOURCE, Some(&config_path))); 1033 let check_ok = || Config::from_file(SOURCE, Some(&config_path)).unwrap(); 1034 1035 check_err(format!( 1036 "Could not read config at '{config_path_fmt}': entity not found" 1037 )); 1038 1039 let config_file = std::fs::File::create_new(&config_path).unwrap(); 1040 config_file 1041 .set_permissions(Permissions::from_mode(0o222)) 1042 .unwrap(); 1043 if File::open(&config_path).is_ok() { 1044 error!("Cannot finish this test if root"); 1045 return; 1046 } 1047 check_err(format!( 1048 "Could not read config at '{config_path_fmt}': permission denied" 1049 )); 1050 1051 config_file 1052 .set_permissions(Permissions::from_mode(0o666)) 1053 .unwrap(); 1054 check_ok(); 1055 std::fs::write(&config_path, "@inline@ test-second-conf.conf").unwrap(); 1056 check_err(format!( 1057 "Could not read config at '{second_path_fmt}': entity not found" 1058 )); 1059 1060 let second_file = std::fs::File::create_new(&second_path).unwrap(); 1061 second_file 1062 .set_permissions(Permissions::from_mode(0o222)) 1063 .unwrap(); 1064 check_err(format!( 1065 "Could not read config at '{second_path_fmt}': permission denied" 1066 )); 1067 1068 std::fs::write(&config_path, "@inline-matching@[*").unwrap(); 1069 check_err(format!( 1070 "Malformed glob regex at '{config_path_fmt}:1': Pattern syntax error near position 16: invalid range pattern" 1071 )); 1072 1073 std::fs::write(&config_path, "@inline-matching@*second-conf.conf").unwrap(); 1074 check_err(format!( 1075 "Could not read config at '{second_path_fmt}': permission denied" 1076 )); 1077 1078 std::fs::write(&config_path, "\n@inline-matching@*.conf").unwrap(); 1079 check_err(format!( 1080 "Recursion limit in config inlining at '{config_path_fmt}:2'" 1081 )); 1082 std::fs::write(&config_path, "\n\n@inline-matching@ *.conf").unwrap(); 1083 check_err(format!( 1084 "Recursion limit in config inlining at '{config_path_fmt}:3'" 1085 )); 1086 1087 std::fs::write(&config_path, "@inline-secret@ secret test-second-conf.conf").unwrap(); 1088 check_ok(); 1089 } 1090 1091 #[test] 1092 fn parsing() { 1093 let check = |err: &str, content: &str| check_err(err, Config::from_mem(content)); 1094 1095 check( 1096 "Expected section header, option assignment or directive at 'mem:1'", 1097 "syntax error", 1098 ); 1099 check( 1100 "Expected section header or directive at 'mem:1'", 1101 "key=value", 1102 ); 1103 check( 1104 "Expected section header, option assignment or directive at 'mem:2'", 1105 "[section]\nbad-line", 1106 ); 1107 1108 let cfg = Config::from_mem( 1109 r#" 1110 1111 [section-a] 1112 1113 bar = baz 1114 1115 [section-b] 1116 1117 first_value = 1 1118 second_value = "test" 1119 1120 "#, 1121 ) 1122 .unwrap(); 1123 1124 // Missing section 1125 check_err( 1126 "Missing string option 'value' in section 'unknown'", 1127 cfg.section("unknown").str("value").require(), 1128 ); 1129 1130 // Missing value 1131 check_err( 1132 "Missing string option 'value' in section 'section-a'", 1133 cfg.section("section-a").str("value").require(), 1134 ); 1135 } 1136 1137 const DEFAULT_CONF: &str = "[PATHS]\nDATADIR=mydir\nRECURSIVE=$RECURSIVE"; 1138 1139 #[allow(clippy::type_complexity)] 1140 fn routine<T: Debug + Eq>( 1141 ty: &str, 1142 mut lambda: impl for<'cfg, 'arg> FnMut(&Section<'cfg, 'arg>, &'arg str) -> Value<'arg, T>, 1143 wellformed: &[(&[&str], T)], 1144 malformed: &[(&[&str], fn(&str) -> String)], 1145 ) { 1146 let conf = |content: &str| Config::from_mem(&format!("{DEFAULT_CONF}\n{content}")).unwrap(); 1147 1148 // Check missing msg 1149 let cfg = conf(""); 1150 check_err( 1151 format!("Missing {ty} option 'value' in section 'section'"), 1152 lambda(&cfg.section("section"), "value").require(), 1153 ); 1154 1155 // Check wellformed options are properly parsed 1156 for (raws, expected) in wellformed { 1157 for raw in *raws { 1158 let cfg = conf(&format!("[section]\nvalue={raw}")); 1159 dbg!(&cfg); 1160 assert_eq!( 1161 *expected, 1162 lambda(&cfg.section("section"), "value").require().unwrap() 1163 ); 1164 } 1165 } 1166 1167 // Check malformed options have proper error message 1168 for (raws, error_fmt) in malformed { 1169 for raw in *raws { 1170 let cfg = conf(&format!("[section]\nvalue={raw}")); 1171 check_err( 1172 format!( 1173 "Invalid {ty} option 'value' in section 'section': {}", 1174 error_fmt(raw) 1175 ), 1176 lambda(&cfg.section("section"), "value").require(), 1177 ) 1178 } 1179 } 1180 } 1181 1182 #[test] 1183 fn string() { 1184 routine( 1185 "string", 1186 |sect, value| sect.str(value), 1187 &[ 1188 (&["1", "\"1\""], "1".to_owned()), 1189 (&["test", "\"test\""], "test".to_owned()), 1190 (&["\""], "\"".to_owned()), 1191 ], 1192 &[], 1193 ); 1194 } 1195 1196 #[test] 1197 fn path() { 1198 routine( 1199 "path", 1200 |sect, value| sect.path(value), 1201 &[ 1202 (&["path"], "path".to_owned()), 1203 ( 1204 &["foo/$DATADIR/bar", "foo/${DATADIR}/bar"], 1205 "foo/mydir/bar".to_owned(), 1206 ), 1207 ( 1208 &["foo/$DATADIR$DATADIR/bar"], 1209 "foo/mydirmydir/bar".to_owned(), 1210 ), 1211 ( 1212 &["foo/pre_$DATADIR/bar", "foo/pre_${DATADIR}/bar"], 1213 "foo/pre_mydir/bar".to_owned(), 1214 ), 1215 ( 1216 &[ 1217 "foo/${DATADIR}_next/bar", 1218 "foo/${UNKNOWN:-$DATADIR}_next/bar", 1219 ], 1220 "foo/mydir_next/bar".to_owned(), 1221 ), 1222 ( 1223 &[ 1224 "foo/${UNKNOWN:-default}_next/bar", 1225 "foo/${UNKNOWN:-${UNKNOWN:-default}}_next/bar", 1226 ], 1227 "foo/default_next/bar".to_owned(), 1228 ), 1229 ( 1230 &["foo/${UNKNOWN:-pre_${UNKNOWN:-default}_next}_next/bar"], 1231 "foo/pre_default_next_next/bar".to_owned(), 1232 ), 1233 ], 1234 &[ 1235 (&["foo/${A/bar"], |_| "bad substitution '/bar'".to_owned()), 1236 (&["foo/${A:-pre_${B}/bar"], |_| { 1237 "unbalanced variable expression 'pre_${B}/bar'".to_owned() 1238 }), 1239 (&["foo/${A:-${B${C}/bar"], |_| { 1240 "unbalanced variable expression '${B${C}/bar'".to_owned() 1241 }), 1242 (&["foo/$UNKNOWN/bar", "foo/${UNKNOWN}/bar"], |_| { 1243 "unbound variable 'UNKNOWN'".to_owned() 1244 }), 1245 (&["foo/$RECURSIVE/bar"], |_| { 1246 "recursion limit in path substitution exceeded for '$RECURSIVE'".to_owned() 1247 }), 1248 ], 1249 ) 1250 } 1251 1252 #[test] 1253 fn number() { 1254 routine( 1255 "number", 1256 |sect, value| sect.number(value), 1257 &[(&["1"], 1), (&["42"], 42)], 1258 &[(&["true", "YES"], |it| format!("'{it}' not a valid number"))], 1259 ); 1260 } 1261 1262 #[test] 1263 fn boolean() { 1264 routine( 1265 "boolean", 1266 |sect, value| sect.boolean(value), 1267 &[(&["yes", "YES", "Yes"], true), (&["no", "NO", "No"], false)], 1268 &[(&["true", "1"], |it| { 1269 format!("expected 'YES' or 'NO' got '{it}'") 1270 })], 1271 ); 1272 } 1273 1274 #[test] 1275 fn amount() { 1276 routine( 1277 "amount", 1278 |sect, value| sect.amount(value, &Currency::KUDOS), 1279 &[( 1280 &["KUDOS:12", "KUDOS:12.0", "KUDOS:012.0"], 1281 amount::amount("KUDOS:12"), 1282 )], 1283 &[ 1284 (&["test", "42"], |it| { 1285 format!("amount '{it}' invalid format") 1286 }), 1287 (&["KUDOS:0.3ABC"], |it| { 1288 format!("amount '{it}' invalid fraction (invalid digit found in string)") 1289 }), 1290 (&["KUDOS:999999999999999999"], |it| { 1291 format!("amount '{it}' value overflow (must be <= 4503599627370496)") 1292 }), 1293 (&["EUR:12"], |_| { 1294 "expected currency KUDOS got EUR".to_owned() 1295 }), 1296 ], 1297 ) 1298 } 1299 }