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