config.rs (42240B)
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 time::Duration, 24 }; 25 26 use indexmap::IndexMap; 27 use jiff::SignedDuration; 28 use url::Url; 29 30 use crate::types::{ 31 amount::{Amount, Currency}, 32 payto::PaytoURI, 33 }; 34 35 pub mod parser { 36 use std::{ 37 borrow::Cow, 38 fmt::Display, 39 io::{BufRead, BufReader}, 40 path::PathBuf, 41 str::FromStr, 42 }; 43 44 use indexmap::IndexMap; 45 use tracing::{trace, warn}; 46 47 use crate::config::{Line, Location}; 48 49 use super::{Config, ValueErr}; 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 } 473 474 #[derive(Debug, thiserror::Error)] 475 pub enum ValueErr { 476 #[error("Missing {ty} option '{option}' in section '{section}'")] 477 Missing { 478 ty: String, 479 section: String, 480 option: String, 481 }, 482 #[error("Invalid {ty} option '{option}' in section '{section}': {err}")] 483 Invalid { 484 ty: String, 485 section: String, 486 option: String, 487 err: String, 488 }, 489 } 490 491 #[derive(Debug, thiserror::Error)] 492 493 pub enum PathsubErr { 494 #[error("recursion limit in path substitution exceeded for '{0}'")] 495 Recursion(String), 496 #[error("unbalanced variable expression '{0}'")] 497 Unbalanced(String), 498 #[error("bad substitution '{0}'")] 499 Substitution(String), 500 #[error("unbound variable '{0}'")] 501 Unbound(String), 502 } 503 504 #[derive(Debug, Clone)] 505 struct Location { 506 file: usize, 507 line: usize, 508 } 509 510 #[derive(Debug, Clone)] 511 struct Line { 512 content: String, 513 loc: Option<Location>, 514 } 515 516 #[derive(Debug, Clone)] 517 pub struct Config { 518 sections: IndexMap<String, IndexMap<String, Line>>, 519 files: Vec<PathBuf>, 520 install_path: PathBuf, 521 } 522 523 impl Config { 524 pub fn section<'cfg, 'arg>(&'cfg self, section: &'arg str) -> Section<'cfg, 'arg> { 525 Section { 526 section, 527 config: self, 528 values: self.sections.get(§ion.to_uppercase()), 529 } 530 } 531 532 /** 533 * Substitute ${...} and $... placeholders in a string 534 * with values from the PATHS section in the 535 * configuration and environment variables 536 * 537 * This substitution is typically only done for paths. 538 */ 539 pub fn pathsub(&self, str: &str, depth: u8) -> Result<String, PathsubErr> { 540 if depth > 128 { 541 return Err(PathsubErr::Recursion(str.to_owned())); 542 } else if !str.contains('$') { 543 return Ok(str.to_owned()); 544 } 545 546 /** Lookup for variable value from PATHS section in the configuration and environment variables */ 547 fn lookup(cfg: &Config, name: &str, depth: u8) -> Option<Result<String, PathsubErr>> { 548 if let Some(path_res) = cfg 549 .sections 550 .get("PATHS") 551 .and_then(|section| section.get(name)) 552 { 553 return Some(cfg.pathsub(&path_res.content, depth + 1)); 554 } 555 556 if let Ok(val) = std::env::var(name) { 557 return Some(Ok(val)); 558 } 559 None 560 } 561 562 let mut result = String::new(); 563 let mut remaining = str; 564 loop { 565 // Look for the next variable 566 let Some((normal, value)) = remaining.split_once('$') else { 567 result.push_str(remaining); 568 return Ok(result); 569 }; 570 571 // Append normal character 572 result.push_str(normal); 573 remaining = value; 574 575 // Check if variable is enclosed 576 let is_enclosed = if let Some(enclosed) = remaining.strip_prefix('{') { 577 // ${var 578 remaining = enclosed; 579 true 580 } else { 581 false // $var 582 }; 583 584 // Extract variable name 585 let name_end = remaining 586 .find(|c: char| !c.is_alphanumeric() && c != '_') 587 .unwrap_or(remaining.len()); 588 let (name, after_name) = remaining.split_at(name_end); 589 590 // Extract variable default if enclosed 591 let default = if !is_enclosed { 592 remaining = after_name; 593 None 594 } else if let Some(after_enclosed) = after_name.strip_prefix('}') { 595 // ${var} 596 remaining = after_enclosed; 597 None 598 } else if let Some(default) = after_name.strip_prefix(":-") { 599 // ${var:-default} 600 let mut depth = 1; 601 let Some((default, after_default)) = default.split_once(|c| { 602 if c == '{' { 603 depth += 1; 604 false 605 } else if c == '}' { 606 depth -= 1; 607 depth == 0 608 } else { 609 false 610 } 611 }) else { 612 return Err(PathsubErr::Unbalanced(default.to_owned())); 613 }; 614 remaining = after_default; 615 Some(default) 616 } else { 617 return Err(PathsubErr::Substitution(after_name.to_owned())); 618 }; 619 if let Some(resolved) = lookup(self, name, depth + 1) { 620 result.push_str(&resolved?); 621 continue; 622 } else if let Some(default) = default { 623 let resolved = self.pathsub(default, depth + 1)?; 624 result.push_str(&resolved); 625 continue; 626 } 627 return Err(PathsubErr::Unbound(name.to_owned())); 628 } 629 } 630 631 pub fn print(&self, mut f: impl std::io::Write, diagnostics: bool) -> std::io::Result<()> { 632 if diagnostics { 633 writeln!(f, "#")?; 634 writeln!(f, "# Configuration file diagnostics")?; 635 writeln!(f, "#")?; 636 writeln!(f, "# File Loaded:")?; 637 for path in &self.files { 638 writeln!(f, "# {}", path.to_string_lossy())?; 639 } 640 writeln!(f, "#")?; 641 writeln!( 642 f, 643 "# Installation path: {}", 644 self.install_path.to_string_lossy() 645 )?; 646 writeln!(f, "#")?; 647 writeln!(f)?; 648 } 649 for (sect, values) in &self.sections { 650 writeln!(f, "[{sect}]")?; 651 if diagnostics { 652 writeln!(f)?; 653 } 654 for (key, Line { content, loc }) in values { 655 if diagnostics { 656 match loc { 657 Some(Location { file, line }) => { 658 let path = &self.files[*file]; 659 writeln!(f, "# {}:{line}", path.to_string_lossy())?; 660 } 661 None => writeln!(f, "# default")?, 662 } 663 } 664 writeln!(f, "{key} = {content}")?; 665 if diagnostics { 666 writeln!(f)?; 667 } 668 } 669 writeln!(f)?; 670 } 671 Ok(()) 672 } 673 } 674 675 /** Accessor/Converter for Taler-like configuration sections */ 676 pub struct Section<'cfg, 'arg> { 677 section: &'arg str, 678 config: &'cfg Config, 679 values: Option<&'cfg IndexMap<String, Line>>, 680 } 681 682 #[macro_export] 683 macro_rules! map_config { 684 ($self:expr, $ty:expr, $option:expr, $($key:expr => $parse:block),*$(,)?) => { 685 { 686 let keys = &[$($key,)*]; 687 $self.map($ty, $option, |value| { 688 match value { 689 $($key => { 690 (|| { 691 $parse 692 })().map_err(|e| ::taler_common::config::MapErr::Err(e)) 693 })*, 694 _ => Err(::taler_common::config::MapErr::Invalid(keys)) 695 } 696 }) 697 } 698 } 699 } 700 701 pub use map_config; 702 703 #[doc(hidden)] 704 pub enum MapErr { 705 Invalid(&'static [&'static str]), 706 Err(ValueErr), 707 } 708 709 impl<'cfg, 'arg> Section<'cfg, 'arg> { 710 #[doc(hidden)] 711 fn inner<T>( 712 &self, 713 ty: &'arg str, 714 option: &'arg str, 715 transform: impl FnOnce(&'cfg str) -> Result<T, ValueErr>, 716 ) -> Value<'arg, T> { 717 let value = self 718 .values 719 .and_then(|m| m.get(&option.to_uppercase())) 720 .filter(|it| !it.content.is_empty()) 721 .map(|raw| transform(&raw.content)) 722 .transpose(); 723 Value { 724 value, 725 option, 726 ty, 727 section: self.section, 728 } 729 } 730 731 #[doc(hidden)] 732 pub fn map<T>( 733 &self, 734 ty: &'arg str, 735 option: &'arg str, 736 transform: impl FnOnce(&'cfg str) -> Result<T, MapErr>, 737 ) -> Value<'arg, T> { 738 self.value(ty, option, |v| { 739 transform(v).map_err(|e| match e { 740 MapErr::Invalid(keys) => { 741 let mut buf = "expected '".to_owned(); 742 match keys { 743 [] => unreachable!("you must provide at least one mapping"), 744 [unique] => buf.push_str(unique), 745 [first, other @ .., last] => { 746 buf.push_str(first); 747 for k in other { 748 buf.push_str("', '"); 749 buf.push_str(k); 750 } 751 buf.push_str("' or '"); 752 buf.push_str(last); 753 } 754 } 755 buf.push_str("' got '"); 756 buf.push_str(v); 757 buf.push('\''); 758 ValueErr::Invalid { 759 ty: ty.to_owned(), 760 section: self.section.to_owned(), 761 option: option.to_owned(), 762 err: buf, 763 } 764 } 765 MapErr::Err(e) => e, 766 }) 767 }) 768 } 769 770 /** Setup an accessor/converted for a [type] at [option] using [transform] */ 771 pub fn value<T, E: Display>( 772 &self, 773 ty: &'arg str, 774 option: &'arg str, 775 transform: impl FnOnce(&'cfg str) -> Result<T, E>, 776 ) -> Value<'arg, T> { 777 self.inner(ty, option, |v| { 778 transform(v).map_err(|e| ValueErr::Invalid { 779 ty: ty.to_owned(), 780 section: self.section.to_owned(), 781 option: option.to_owned(), 782 err: e.to_string(), 783 }) 784 }) 785 } 786 787 /** Access [option] as a parsable type */ 788 pub fn parse<E: std::fmt::Display, T: FromStr<Err = E>>( 789 &self, 790 ty: &'arg str, 791 option: &'arg str, 792 ) -> Value<'arg, T> { 793 self.value(ty, option, |it| it.parse::<T>().map_err(|e| e.to_string())) 794 } 795 796 /** Access [option] as str */ 797 pub fn str(&self, option: &'arg str) -> Value<'arg, String> { 798 self.value("string", option, |it| Ok::<_, &str>(it.to_owned())) 799 } 800 801 /** Access [option] as path */ 802 pub fn path(&self, option: &'arg str) -> Value<'arg, String> { 803 self.value("path", option, |it| self.config.pathsub(it, 0)) 804 } 805 806 /** Access [option] as UNIX permissions */ 807 pub fn unix_mode(&self, option: &'arg str) -> Value<'arg, Permissions> { 808 self.value("unix mode", option, |it| { 809 u32::from_str_radix(it, 8) 810 .map(Permissions::from_mode) 811 .map_err(|_| format!("'{it}' not a valid number")) 812 }) 813 } 814 815 /** Access [option] as a number */ 816 pub fn number<T: FromStr>(&self, option: &'arg str) -> Value<'arg, T> { 817 self.value("number", option, |it| { 818 it.parse::<T>() 819 .map_err(|_| format!("'{it}' not a valid number")) 820 }) 821 } 822 823 /** Access [option] as Boolean */ 824 pub fn boolean(&self, option: &'arg str) -> Value<'arg, bool> { 825 self.value("boolean", option, |it| match it.to_uppercase().as_str() { 826 "YES" => Ok(true), 827 "NO" => Ok(false), 828 _ => Err(format!("expected 'YES' or 'NO' got '{it}'")), 829 }) 830 } 831 832 /** Access [option] as Amount */ 833 pub fn amount(&self, option: &'arg str, currency: &str) -> Value<'arg, Amount> { 834 let currency: Currency = currency.parse().unwrap(); 835 self.value("amount", option, |it| { 836 let amount = it.parse::<Amount>().map_err(|e| e.to_string())?; 837 if amount.currency != currency { 838 return Err(format!( 839 "expected currency {currency} got {}", 840 amount.currency 841 )); 842 } 843 Ok(amount) 844 }) 845 } 846 847 /** Access [option] as url */ 848 pub fn url(&self, option: &'arg str) -> Value<'arg, Url> { 849 self.parse("url", option) 850 } 851 852 /** Access [option] as base url */ 853 pub fn base_url(&self, option: &'arg str) -> Value<'arg, Url> { 854 self.value("url", option, |s| { 855 let url = Url::from_str(s).map_err(|e| e.to_string())?; 856 if url.scheme() != "http" && url.scheme() != "https" { 857 Err(format!( 858 "only 'http' and 'https' are accepted for baseURL got '{}''", 859 url.scheme() 860 )) 861 } else if !url.has_host() { 862 Err(format!("missing host in baseURL got '{url}'")) 863 } else if url.query().is_some() { 864 Err(format!( 865 "require no query in baseURL got '{}'", 866 url.query().unwrap() 867 )) 868 } else if url.fragment().is_some() { 869 Err(format!( 870 "require no fragment in baseURL got '{}'", 871 url.fragment().unwrap() 872 )) 873 } else if !url.path().ends_with('/') { 874 Err(format!("baseURL path must end with / got '{}'", url.path())) 875 } else { 876 Ok(url) 877 } 878 }) 879 } 880 881 /** Access [option] as payto */ 882 pub fn payto(&self, option: &'arg str) -> Value<'arg, PaytoURI> { 883 self.parse("payto", option) 884 } 885 886 /** Access [option] as Postgres URI */ 887 pub fn postgres(&self, option: &'arg str) -> Value<'arg, sqlx::postgres::PgConnectOptions> { 888 self.parse("Postgres URI", option) 889 } 890 891 /** Access [option] as a timestamp */ 892 pub fn timestamp(&self, option: &'arg str) -> Value<'arg, jiff::Timestamp> { 893 self.parse("Timestamp", option) 894 } 895 896 /** Access [option] as a date time */ 897 pub fn date(&self, option: &'arg str) -> Value<'arg, jiff::civil::Date> { 898 self.parse("Date", option) 899 } 900 901 /** Access [option] as a duration */ 902 pub fn duration(&self, option: &'arg str) -> Value<'arg, Duration> { 903 self.value("temporal", option, |it| { 904 let tmp = SignedDuration::from_str(it).map_err(|e| e.to_string())?; 905 Ok::<_, String>(Duration::from_millis(tmp.as_millis() as u64)) 906 }) 907 } 908 } 909 910 pub struct Value<'arg, T> { 911 value: Result<Option<T>, ValueErr>, 912 option: &'arg str, 913 ty: &'arg str, 914 section: &'arg str, 915 } 916 917 impl<T> Value<'_, T> { 918 pub fn opt(self) -> Result<Option<T>, ValueErr> { 919 self.value 920 } 921 922 /** Converted value of default if missing */ 923 pub fn default(self, default: T) -> Result<T, ValueErr> { 924 Ok(self.value?.unwrap_or(default)) 925 } 926 927 /** Converted value or throw if missing */ 928 pub fn require(self) -> Result<T, ValueErr> { 929 self.value?.ok_or_else(|| ValueErr::Missing { 930 ty: self.ty.to_owned(), 931 section: self.section.to_owned(), 932 option: self.option.to_owned(), 933 }) 934 } 935 } 936 937 #[cfg(test)] 938 mod test { 939 use std::{ 940 fmt::{Debug, Display}, 941 fs::{File, Permissions}, 942 os::unix::fs::PermissionsExt, 943 }; 944 945 use tracing::error; 946 947 use crate::{config::parser::ConfigSource, types::amount}; 948 949 use super::{Config, Section, Value}; 950 951 const SOURCE: ConfigSource = ConfigSource::new("test", "test", "test"); 952 953 #[track_caller] 954 fn check_err<T: Debug, E: Display>(err: impl AsRef<str>, lambda: Result<T, E>) { 955 let failure = lambda.unwrap_err(); 956 let fmt = failure.to_string(); 957 assert_eq!(err.as_ref(), fmt); 958 } 959 960 #[test] 961 fn fs() { 962 let dir = tempfile::tempdir().unwrap(); 963 let config_path = dir.path().join("test-conf.conf"); 964 let second_path = dir.path().join("test-second-conf.conf"); 965 966 let config_path_fmt = config_path.to_string_lossy(); 967 let second_path_fmt = second_path.to_string_lossy(); 968 969 let check_err = |err: String| check_err(err, Config::from_file(SOURCE, Some(&config_path))); 970 let check_ok = || Config::from_file(SOURCE, Some(&config_path)).unwrap(); 971 972 check_err(format!( 973 "Could not read config at '{config_path_fmt}': entity not found" 974 )); 975 976 let config_file = std::fs::File::create_new(&config_path).unwrap(); 977 config_file 978 .set_permissions(Permissions::from_mode(0o222)) 979 .unwrap(); 980 if File::open(&config_path).is_ok() { 981 error!("Cannot finish this test if root"); 982 return; 983 } 984 check_err(format!( 985 "Could not read config at '{config_path_fmt}': permission denied" 986 )); 987 988 config_file 989 .set_permissions(Permissions::from_mode(0o666)) 990 .unwrap(); 991 check_ok(); 992 std::fs::write(&config_path, "@inline@ test-second-conf.conf").unwrap(); 993 check_err(format!( 994 "Could not read config at '{second_path_fmt}': entity not found" 995 )); 996 997 let second_file = std::fs::File::create_new(&second_path).unwrap(); 998 second_file 999 .set_permissions(Permissions::from_mode(0222)) 1000 .unwrap(); 1001 check_err(format!( 1002 "Could not read config at '{second_path_fmt}': permission denied" 1003 )); 1004 1005 std::fs::write(&config_path, "@inline-matching@[*").unwrap(); 1006 check_err(format!( 1007 "Malformed glob regex at '{config_path_fmt}:1': Pattern syntax error near position 16: invalid range pattern" 1008 )); 1009 1010 std::fs::write(&config_path, "@inline-matching@*second-conf.conf").unwrap(); 1011 check_err(format!( 1012 "Could not read config at '{second_path_fmt}': permission denied" 1013 )); 1014 1015 std::fs::write(&config_path, "\n@inline-matching@*.conf").unwrap(); 1016 check_err(format!( 1017 "Recursion limit in config inlining at '{config_path_fmt}:2'" 1018 )); 1019 std::fs::write(&config_path, "\n\n@inline-matching@ *.conf").unwrap(); 1020 check_err(format!( 1021 "Recursion limit in config inlining at '{config_path_fmt}:3'" 1022 )); 1023 1024 std::fs::write(&config_path, "@inline-secret@ secret test-second-conf.conf").unwrap(); 1025 check_ok(); 1026 } 1027 1028 #[test] 1029 fn parsing() { 1030 let check = |err: &str, content: &str| check_err(err, Config::from_mem(&content)); 1031 1032 check( 1033 "Expected section header, option assignment or directive at 'mem:1'", 1034 "syntax error", 1035 ); 1036 check( 1037 "Expected section header or directive at 'mem:1'", 1038 "key=value", 1039 ); 1040 check( 1041 "Expected section header, option assignment or directive at 'mem:2'", 1042 "[section]\nbad-line", 1043 ); 1044 1045 let cfg = Config::from_mem( 1046 r#" 1047 1048 [section-a] 1049 1050 bar = baz 1051 1052 [section-b] 1053 1054 first_value = 1 1055 second_value = "test" 1056 1057 "#, 1058 ) 1059 .unwrap(); 1060 1061 // Missing section 1062 check_err( 1063 "Missing string option 'value' in section 'unknown'", 1064 cfg.section("unknown").str("value").require(), 1065 ); 1066 1067 // Missing value 1068 check_err( 1069 "Missing string option 'value' in section 'section-a'", 1070 cfg.section("section-a").str("value").require(), 1071 ); 1072 } 1073 1074 const DEFAULT_CONF: &str = "[PATHS]\nDATADIR=mydir\nRECURSIVE=$RECURSIVE"; 1075 1076 fn routine<T: Debug + Eq>( 1077 ty: &str, 1078 lambda: for<'cfg, 'arg> fn(&Section<'cfg, 'arg>, &'arg str) -> Value<'arg, T>, 1079 wellformed: &[(&[&str], T)], 1080 malformed: &[(&[&str], fn(&str) -> String)], 1081 ) { 1082 let conf = |content: &str| Config::from_mem(&format!("{DEFAULT_CONF}\n{content}")).unwrap(); 1083 1084 // Check missing msg 1085 let cfg = conf(""); 1086 check_err( 1087 format!("Missing {ty} option 'value' in section 'section'"), 1088 lambda(&cfg.section("section"), "value").require(), 1089 ); 1090 1091 // Check wellformed options are properly parsed 1092 for (raws, expected) in wellformed { 1093 for raw in *raws { 1094 let cfg = conf(&format!("[section]\nvalue={raw}")); 1095 dbg!(&cfg); 1096 assert_eq!( 1097 *expected, 1098 lambda(&cfg.section("section"), "value").require().unwrap() 1099 ); 1100 } 1101 } 1102 1103 // Check malformed options have proper error message 1104 for (raws, error_fmt) in malformed { 1105 for raw in *raws { 1106 let cfg = conf(&format!("[section]\nvalue={raw}")); 1107 check_err( 1108 format!( 1109 "Invalid {ty} option 'value' in section 'section': {}", 1110 error_fmt(&raw) 1111 ), 1112 lambda(&cfg.section("section"), "value").require(), 1113 ) 1114 } 1115 } 1116 } 1117 1118 #[test] 1119 fn string() { 1120 routine( 1121 "string", 1122 |sect, value| sect.str(value), 1123 &[ 1124 (&["1", "\"1\""], "1".to_owned()), 1125 (&["test", "\"test\""], "test".to_owned()), 1126 (&["\""], "\"".to_owned()), 1127 ], 1128 &[], 1129 ); 1130 } 1131 1132 #[test] 1133 fn path() { 1134 routine( 1135 "path", 1136 |sect, value| sect.path(value), 1137 &[ 1138 (&["path"], "path".to_owned()), 1139 ( 1140 &["foo/$DATADIR/bar", "foo/${DATADIR}/bar"], 1141 "foo/mydir/bar".to_owned(), 1142 ), 1143 ( 1144 &["foo/$DATADIR$DATADIR/bar"], 1145 "foo/mydirmydir/bar".to_owned(), 1146 ), 1147 ( 1148 &["foo/pre_$DATADIR/bar", "foo/pre_${DATADIR}/bar"], 1149 "foo/pre_mydir/bar".to_owned(), 1150 ), 1151 ( 1152 &[ 1153 "foo/${DATADIR}_next/bar", 1154 "foo/${UNKNOWN:-$DATADIR}_next/bar", 1155 ], 1156 "foo/mydir_next/bar".to_owned(), 1157 ), 1158 ( 1159 &[ 1160 "foo/${UNKNOWN:-default}_next/bar", 1161 "foo/${UNKNOWN:-${UNKNOWN:-default}}_next/bar", 1162 ], 1163 "foo/default_next/bar".to_owned(), 1164 ), 1165 ( 1166 &["foo/${UNKNOWN:-pre_${UNKNOWN:-default}_next}_next/bar"], 1167 "foo/pre_default_next_next/bar".to_owned(), 1168 ), 1169 ], 1170 &[ 1171 (&["foo/${A/bar"], |_| "bad substitution '/bar'".to_owned()), 1172 (&["foo/${A:-pre_${B}/bar"], |_| { 1173 "unbalanced variable expression 'pre_${B}/bar'".to_owned() 1174 }), 1175 (&["foo/${A:-${B${C}/bar"], |_| { 1176 "unbalanced variable expression '${B${C}/bar'".to_owned() 1177 }), 1178 (&["foo/$UNKNOWN/bar", "foo/${UNKNOWN}/bar"], |_| { 1179 "unbound variable 'UNKNOWN'".to_owned() 1180 }), 1181 (&["foo/$RECURSIVE/bar"], |_| { 1182 "recursion limit in path substitution exceeded for '$RECURSIVE'".to_owned() 1183 }), 1184 ], 1185 ) 1186 } 1187 1188 #[test] 1189 fn number() { 1190 routine( 1191 "number", 1192 |sect, value| sect.number(value), 1193 &[(&["1"], 1), (&["42"], 42)], 1194 &[(&["true", "YES"], |it| format!("'{it}' not a valid number"))], 1195 ); 1196 } 1197 1198 #[test] 1199 fn boolean() { 1200 routine( 1201 "boolean", 1202 |sect, value| sect.boolean(value), 1203 &[(&["yes", "YES", "Yes"], true), (&["no", "NO", "No"], false)], 1204 &[(&["true", "1"], |it| { 1205 format!("expected 'YES' or 'NO' got '{it}'") 1206 })], 1207 ); 1208 } 1209 1210 #[test] 1211 fn amount() { 1212 routine( 1213 "amount", 1214 |sect, value| sect.amount(value, "KUDOS"), 1215 &[( 1216 &["KUDOS:12", "KUDOS:12.0", "KUDOS:012.0"], 1217 amount::amount("KUDOS:12"), 1218 )], 1219 &[ 1220 (&["test", "42"], |it| { 1221 format!("amount '{it}' invalid format") 1222 }), 1223 (&["KUDOS:0.3ABC"], |it| { 1224 format!("amount '{it}' invalid fraction (invalid digit found in string)") 1225 }), 1226 (&["KUDOS:999999999999999999"], |it| { 1227 format!("amount '{it}' value overflow (must be <= 9007199254740992)") 1228 }), 1229 (&["EUR:12"], |_| { 1230 "expected currency KUDOS got EUR".to_owned() 1231 }), 1232 ], 1233 ) 1234 } 1235 }