taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

commit e83ede7f0a847f7335a178d7120768a10f70ab38
parent ccada7ece4be95584fad1c8441a534e9ab0606c1
Author: Antoine A <>
Date:   Tue,  7 Jan 2025 16:19:45 +0100

common: config parser

Diffstat:
MCargo.lock | 22++++++++++++++++------
MCargo.toml | 4++--
Mtaler-api/Cargo.toml | 2+-
Mtaler-common/Cargo.toml | 4++++
Ataler-common/src/config.rs | 922+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtaler-common/src/lib.rs | 6++----
Mtaler-common/src/types/amount.rs | 44++++++++++++++++++++++----------------------
7 files changed, 969 insertions(+), 35 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.84" +version = "0.1.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1244b10dcd56c92219da4e14caa97e312079e185f04ba3eea25061561dc0a0" +checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" dependencies = [ "proc-macro2", "quote", @@ -831,6 +831,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] name = "half" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1542,9 +1548,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -1952,9 +1958,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.134" +version = "1.0.135" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" +checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" dependencies = [ "itoa", "memchr", @@ -2391,6 +2397,8 @@ version = "0.1.0" dependencies = [ "criterion", "fastrand", + "glob", + "indexmap 2.7.0", "jiff", "rand", "serde", @@ -2398,7 +2406,9 @@ dependencies = [ "serde_urlencoded", "serde_with", "sqlx", + "tempfile", "thiserror 2.0.9", + "tracing", "url", ] diff --git a/Cargo.toml b/Cargo.toml @@ -14,4 +14,5 @@ axum = "0.7.9" sqlx = { version = "0.8", default-features = false } url = { version = "2.2", features = ["serde"] } criterion = { version = "0.5" } -fastrand = "2.2.0" -\ No newline at end of file +fastrand = "2.2.0" +tracing = "0.1" diff --git a/taler-api/Cargo.toml b/taler-api/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" [dependencies] listenfd = "1.0.0" -tracing = "0.1" tracing-subscriber = "0.3" tracing-test = "0.2" dashmap = "6.1" @@ -20,6 +19,7 @@ libdeflater = "1.22.0" ed25519-dalek = { version = "2.1.1", default-features = false } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] } serde = { workspace = true, features = ["derive"] } +tracing.workspace= true serde_json.workspace = true axum.workspace = true url.workspace = true diff --git a/taler-common/Cargo.toml b/taler-common/Cargo.toml @@ -7,12 +7,16 @@ edition = "2021" serde_with = "3.11.0" rand = "0.8" serde_urlencoded = "0.7" +glob = "0.3" +indexmap = "2.7" +tempfile = "3.15" jiff = { version = "0.1", default-features = false, features = ["std"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["raw_value"] } url.workspace = true thiserror.workspace = true fastrand.workspace = true +tracing.workspace = true sqlx = { workspace = true, features = ["macros"] } [dev-dependencies] diff --git a/taler-common/src/config.rs b/taler-common/src/config.rs @@ -0,0 +1,922 @@ +/* + This file is part of TALER + Copyright (C) 2025 Taler Systems SA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +*/ + +use std::fmt::Debug; + +use indexmap::IndexMap; + +use crate::types::amount::{Amount, Currency}; + +pub mod parser { + use std::{ + borrow::Cow, + fmt::Display, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + }; + + use indexmap::IndexMap; + use tracing::{trace, warn}; + + use super::Config; + + #[derive(Debug)] + + pub enum ParserErr { + IO(&'static str, PathBuf, std::io::Error), + Line(Cow<'static, str>, PathBuf, usize, Option<String>), + } + + impl Display for ParserErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParserErr::IO(action, path, err) => write!( + f, + "Could not {action} at '{}': {}", + path.to_string_lossy(), + err.kind() + ), + ParserErr::Line(msg, path, num, cause) => { + if let Some(cause) = cause { + write!(f, "{msg} at '{}:{num}': {cause}", path.to_string_lossy()) + } else { + write!(f, "{msg} at '{}:{num}'", path.to_string_lossy()) + } + } + } + } + } + + fn io_err(action: &'static str, path: impl Into<PathBuf>, err: std::io::Error) -> ParserErr { + ParserErr::IO(action, path.into(), err) + } + fn line_err( + msg: impl Into<Cow<'static, str>>, + path: impl Into<PathBuf>, + num: usize, + ) -> ParserErr { + ParserErr::Line(msg.into(), path.into(), num, None) + } + fn line_cause_err( + msg: impl Into<Cow<'static, str>>, + cause: impl Display, + path: impl Into<PathBuf>, + num: usize, + ) -> ParserErr { + ParserErr::Line(msg.into(), path.into(), num, Some(cause.to_string())) + } + + struct Parser { + cfg: IndexMap<String, IndexMap<String, String>>, + src: ConfigSource, + buf: String, + } + + impl Parser { + pub fn new(src: ConfigSource) -> Self { + Self { + cfg: IndexMap::new(), + src, + buf: String::new(), + } + } + + fn load_defaults(&mut self) -> Result<(), ParserErr> { + let ConfigSource { + project_name, + exec_name, + .. + } = self.src; + + // Load default path + let dir = + install_path(exec_name).map_err(|(p, e)| io_err("find installation path", p, e))?; + + let paths = IndexMap::from_iter( + [ + ("PREFIX", dir.join("")), + ("BINDIR", dir.join("bin")), + ("LIBEXECDIR", dir.join(project_name).join("libexec")), + ("DOCDIR", dir.join("share").join("doc").join(project_name)), + ("ICONDIR", dir.join("bin").join("share").join("icons")), + ("LOCALEDIR", dir.join("share").join("locale")), + ("LIBDIR", dir.join("lib").join(project_name)), + ("DATADIR", dir.join("share").join(project_name)), + ] + .map(|(a, b)| (a.to_owned(), b.to_string_lossy().into_owned())), + ); + self.cfg.insert("PATHS".to_owned(), paths); + + // Load default configs + let cfg_dir = dir.join("share").join(project_name).join("config.d"); + match std::fs::read_dir(&cfg_dir) { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => self.parse_file(&entry.path(), 0)?, + Err(err) => { + warn!("{}", io_err("read base config directory", &cfg_dir, err)); + } + } + } + } + Err(err) => warn!("{}", io_err("read base config directory", &cfg_dir, err)), + } + + Ok(()) + } + + fn parse_file(&mut self, src: &Path, depth: u8) -> Result<(), ParserErr> { + trace!("load file at '{}'", src.to_string_lossy()); + match std::fs::File::open(src) { + Ok(file) => self.parse(BufReader::new(file), src, depth + 1), + Err(e) => Err(io_err("read config", src, e)), + } + } + + fn parse<B: BufRead>( + &mut self, + mut reader: B, + src: &Path, + depth: u8, + ) -> Result<(), ParserErr> { + let mut current_section: Option<&mut IndexMap<String, String>> = None; + let mut num = 0; + loop { + // Read a new line + num += 1; + self.buf.clear(); + match reader.read_line(&mut self.buf) { + Ok(0) => break, + Ok(_) => {} + Err(e) => return Err(io_err("read config", src, e)), + } + // Trim whitespace + let line = self.buf.trim_ascii(); + + if line.is_empty() || line.starts_with(['#', '%']) { + // Skip empty lines and comments + continue; + } else if let Some(directive) = line.strip_prefix("@") { + // Parse directive + let Some((name, arg)) = directive.split_once('@') else { + return Err(line_err( + format!("Invalid directive line '{line}'"), + src, + num, + )); + }; + // Exit current section + current_section = None; + // Check current file has a parent + let Some(parent) = src.parent() else { + return Err(line_err("no parent", src, num)); + }; + // Check recursion depth + if depth > 128 { + return Err(line_err("Recursion limit in config inlining", src, num)); + } + + match name.to_lowercase().as_str() { + "inline" => self.parse_file(&parent.join(arg), depth)?, + "inline-matching" => { + let paths = glob::glob(&parent.join(arg).to_string_lossy()) + .map_err(|e| line_cause_err("Malformed glob regex", e, src, num))?; + for path in paths { + let path = + path.map_err(|e| line_cause_err("Glob error", e, src, num))?; + self.parse_file(&path, depth)?; + } + } + "inline-secret" => { + let (section, file) = arg.split_once(" ").ok_or_else(|| + line_err( + "Invalid configuration, @inline-secret@ directive requires exactly two arguments", + src, + num + ) + )?; + + let section = section.to_uppercase(); + + let mut tmp = Parser::new(self.src); + tmp.load_defaults()?; + tmp.parse_file(src, depth)?; + + if let Err(e) = tmp.parse_file(src, depth) { + if let ParserErr::IO(_, path, err) = e { + warn!("{}", io_err("read secrets", path, err)) + } else { + return Err(e); + } + } else { + let mut secret_cfg = tmp.finalize(); + if let Some(secret_section) = secret_cfg.0.swap_remove(&section) { + self.cfg.entry(section).or_default().extend(secret_section); + } else { + warn!("{}", line_err(format!("Configuration file at '{}' loaded with @inline-secret@ does not contain section '{section}' ", file), src, num)); + } + } + } + unknown => { + return Err(line_err( + format!("Invalid directive '{unknown}'"), + src, + num, + )) + } + } + } else if let Some(section) = + line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) + { + current_section = Some(self.cfg.entry(section.to_uppercase()).or_default()); + } else if let Some((name, value)) = line.split_once('=') { + if let Some(current_section) = &mut current_section { + // Trim whitespace + let name = name.trim_ascii_end().to_uppercase(); + let value = value.trim_ascii_start(); + // Escape value + let value = + if value.len() > 1 && value.starts_with('"') && value.ends_with('"') { + &value[1..value.len() - 1] + } else { + value + }; + current_section.insert(name, value.to_owned()); + } else { + return Err(line_err("Expected section header or directive", src, num)); + } + } else { + return Err(line_err( + "Expected section header, option assignment or directive", + src, + num, + )); + } + } + Ok(()) + } + + fn finalize(self) -> Config { + Config(self.cfg) + } + } + + /** Information about how the configuration is loaded */ + #[derive(Debug, Clone, Copy)] + pub struct ConfigSource { + /** Name of the high-level project */ + project_name: &'static str, + /** Name of the component within the package */ + component_name: &'static str, + /** + * Executable name that will be located on $PATH to + * find the installation path of the package + */ + exec_name: &'static str, + } + + impl ConfigSource { + pub const fn new( + project_name: &'static str, + component_name: &'static str, + exec_name: &'static str, + ) -> Self { + Self { + project_name, + component_name, + exec_name, + } + } + + pub fn parse_default(self) -> Result<Config, ParserErr> { + let mut parser = Parser::new(self); + parser.load_defaults()?; + if let Some(default) = default_config_path(self.project_name, self.component_name) + .map_err(|(p, e)| io_err("find defauld config path", p, e))? + { + parser.parse_file(&default, 0)?; + } + Ok(parser.finalize()) + } + + pub fn parse_file(self, src: impl AsRef<Path>) -> Result<Config, ParserErr> { + let mut parser = Parser::new(self); + parser.load_defaults()?; + parser.parse_file(src.as_ref(), 0)?; + Ok(parser.finalize()) + } + + pub fn parse_str(self, src: &str) -> Result<Config, ParserErr> { + let mut parser = Parser::new(self); + parser.load_defaults()?; + parser.parse(std::io::Cursor::new(src), "mem".as_ref(), 0)?; + Ok(parser.finalize()) + } + } + + /** + * Search the default configuration file path + * + * I will be the first existing file from this list: + * - $XDG_CONFIG_HOME/$componentName.conf + * - $HOME/.config/$componentName.conf + * - /etc/$componentName.conf + * - /etc/$projectName/$componentName.conf + * */ + fn default_config_path( + project_name: &str, + component_name: &str, + ) -> Result<Option<PathBuf>, (PathBuf, std::io::Error)> { + // TODO use a generator + let conf_name = format!("{component_name}.conf"); + + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + let path = PathBuf::from(xdg).join(&conf_name); + match path.try_exists() { + Ok(false) => {} + Ok(true) => return Ok(Some(path)), + Err(e) => return Err((path, e)), + } + } + + if let Some(home) = std::env::var_os("HOME") { + let path = PathBuf::from(home).join(".config").join(&conf_name); + match path.try_exists() { + Ok(false) => {} + Ok(true) => return Ok(Some(path)), + Err(e) => return Err((path, e)), + } + } + + let path = PathBuf::from("/etc").join(&conf_name); + match path.try_exists() { + Ok(false) => {} + Ok(true) => return Ok(Some(path)), + Err(e) => return Err((path, e)), + } + + let path = PathBuf::from("/etc").join(project_name).join(&conf_name); + match path.try_exists() { + Ok(false) => {} + Ok(true) => return Ok(Some(path)), + Err(e) => return Err((path, e)), + } + + Ok(None) + } + + /** Search for the binary installation path in PATH */ + fn install_path(exec_name: &str) -> Result<PathBuf, (PathBuf, std::io::Error)> { + let path_env = std::env::var("PATH").unwrap(); + for entry in path_env.split(':') { + let path = PathBuf::from(entry).join(exec_name); + if path.join(exec_name).exists() { + if let Some(parent) = path.parent() { + return parent.canonicalize().map_err(|e| (parent.to_path_buf(), e)); + } + } + } + Ok(PathBuf::from("/usr")) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ValueError { + #[error("Missing {ty} option '{option}' in section '{section}'")] + Missing { + ty: String, + section: String, + option: String, + }, + #[error("Invalid {ty} option '{option}' in section '{section}': {err}")] + Invalid { + ty: String, + section: String, + option: String, + err: String, + }, +} + +#[derive(Debug)] +pub struct Config(IndexMap<String, IndexMap<String, String>>); + +impl Config { + pub fn section<'a>(&'a self, section: &'a str) -> Section<'a> { + Section { + section, + config: self, + values: self.0.get(&section.to_uppercase()), + } + } + + /** + * Substitute ${...} and $... placeholders in a string + * with values from the PATHS section in the + * configuration and environment variables + * + * This substitution is typically only done for paths. + */ + fn pathsub(&self, str: &str, depth: u8) -> Result<String, String> { + if depth > 128 { + return Err(format!( + "recursion limit in path substitution exceeded for '{str}'" + )); + } else if !str.contains('$') { + return Ok(str.to_owned()); + } + + /** Lookup for variable value from PATHS section in the configuration and environment variables */ + fn lookup(cfg: &Config, name: &str, depth: u8) -> Option<Result<String, String>> { + if let Some(path_res) = cfg.0.get("PATHS").and_then(|section| section.get(name)) { + return Some(cfg.pathsub(path_res, depth + 1)); + } + + if let Ok(val) = std::env::var(name) { + return Some(Ok(val)); + } + None + } + + let mut result = String::new(); + let mut remaining = str; + loop { + // Look for the next variable + let Some((normal, value)) = remaining.split_once('$') else { + result.push_str(remaining); + return Ok(result); + }; + + // Append normal character + result.push_str(normal); + remaining = value; + + // Check if variable is enclosed + let is_enclosed = if let Some(enclosed) = remaining.strip_prefix('{') { + // ${var + remaining = enclosed; + true + } else { + false // $var + }; + + // Extract variable name + let name_end = remaining + .find(|c: char| !c.is_alphanumeric() && c != '_') + .unwrap_or(remaining.len()); + let (name, after_name) = remaining.split_at(name_end); + + // Extract variable default if enclosed + let default = if !is_enclosed { + remaining = after_name; + None + } else if let Some(after_enclosed) = after_name.strip_prefix('}') { + // ${var} + remaining = after_enclosed; + None + } else if let Some(default) = after_name.strip_prefix(":-") { + // ${var:-default} + let mut depth = 1; + let Some((default, after_default)) = default.split_once(|c| { + if c == '{' { + depth += 1; + false + } else if c == '}' { + depth -= 1; + depth == 0 + } else { + false + } + }) else { + return Err(format!("unbalanced variable expression '{default}'")); + }; + remaining = after_default; + Some(default) + } else { + return Err(format!("bad substitution '{after_name}'")); + }; + if let Some(resolved) = lookup(self, name, depth + 1) { + result.push_str(&resolved?); + continue; + } else if let Some(default) = default { + let resolved = self.pathsub(default, depth + 1)?; + result.push_str(&resolved); + continue; + } + return Err(format!("unbound variable '{name}'")); + } + } +} + +/** Accessor/Converter for Taler-like configuration sections */ +pub struct Section<'a> { + section: &'a str, + config: &'a Config, + values: Option<&'a IndexMap<String, String>>, +} + +impl<'a> Section<'a> { + /** Setup an accessor/converted for a [type] at [option] using [transform] */ + fn value<T, E: ToString>( + &'a self, + option: &'a str, + ty: &'a str, + transform: impl FnOnce(&'a str) -> Result<T, E>, + ) -> Value<'a, T> { + let value = self + .values + .and_then(|m| m.get(&option.to_uppercase())) + .filter(|it| !it.is_empty()) + .map(|raw| { + transform(raw).map_err(|e| ValueError::Invalid { + ty: ty.to_owned(), + section: self.section.to_owned(), + option: option.to_owned(), + err: e.to_string(), + }) + }) + .transpose(); + Value { + value, + option, + ty, + section: self.section, + } + } + + /** Access [option] as str */ + pub fn string(&'a self, option: &'a str) -> Value<'a, String> { + self.value(option, "string", |it| Ok::<_, &str>(it.to_owned())) + } + + /** Access [option] as path */ + pub fn path(&'a self, option: &'a str) -> Value<'a, String> { + self.value(option, "path", |it| self.config.pathsub(it, 0)) + } + + /** Access [option] as a number */ + pub fn number(&'a self, option: &'a str) -> Value<'a, u64> { + self.value(option, "number", |it| { + it.parse::<u64>() + .map_err(|_| format!("'{it}' not a valid number")) + }) + } + + /** Access [option] as Boolean */ + pub fn boolean(&'a self, option: &'a str) -> Value<'a, bool> { + self.value(option, "boolean", |it| match it.to_uppercase().as_str() { + "YES" => Ok(true), + "NO" => Ok(false), + _ => Err(format!("expected 'YES' or 'NO' got '{it}'")), + }) + } + + /** Access [option] as Amount */ + pub fn amount(&'a self, option: &'a str, currency: &'static str) -> Value<'a, Amount> { + let currency: Currency = currency.parse().unwrap(); + self.value(option, "amount", |it| { + let amount = it.parse::<Amount>().map_err(|e| e.to_string())?; + if amount.currency != currency { + return Err(format!( + "expected currency {currency} got {}", + amount.currency + )); + } + Ok(amount) + }) + } +} + +pub struct Value<'a, T> { + value: Result<Option<T>, ValueError>, + option: &'a str, + ty: &'a str, + section: &'a str, +} + +impl<T> Value<'_, T> { + pub fn opt(self) -> Result<Option<T>, ValueError> { + self.value + } + + /** Converted value of default if missing */ + pub fn default(self, default: T) -> Result<T, ValueError> { + Ok(self.value?.unwrap_or(default)) + } + + /** Converted value or throw if missing */ + pub fn require(self) -> Result<T, ValueError> { + self.value?.ok_or_else(|| ValueError::Missing { + ty: self.ty.to_owned(), + section: self.section.to_owned(), + option: self.option.to_owned(), + }) + } +} + +#[cfg(test)] +mod test { + use std::{ + fmt::{Debug, Display}, + fs::Permissions, + os::unix::fs::PermissionsExt, + }; + + use crate::{config::parser::ConfigSource, types::amount}; + + use super::{Section, Value}; + + const SOURCE: ConfigSource = ConfigSource::new("test", "test", "test"); + + #[track_caller] + fn check_err<T: Debug, E: Display>(err: impl AsRef<str>, lambda: Result<T, E>) { + let failure = lambda.unwrap_err(); + let fmt = failure.to_string(); + assert_eq!(err.as_ref(), fmt); + } + + #[test] + fn fs() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("test-conf.conf"); + let second_path = dir.path().join("test-second-conf.conf"); + + let config_path_fmt = config_path.to_string_lossy(); + let second_path_fmt = second_path.to_string_lossy(); + + let check = |err: String| check_err(err, SOURCE.parse_file(&config_path)); + + check(format!( + "Could not read config at '{config_path_fmt}': entity not found" + )); + + let config_file = std::fs::File::create_new(&config_path).unwrap(); + config_file + .set_permissions(Permissions::from_mode(0o222)) + .unwrap(); + check(format!( + "Could not read config at '{config_path_fmt}': permission denied" + )); + + config_file + .set_permissions(Permissions::from_mode(0o666)) + .unwrap(); + std::fs::write(&config_path, "@inline@test-second-conf.conf").unwrap(); + check(format!( + "Could not read config at '{second_path_fmt}': entity not found" + )); + + let second_file = std::fs::File::create_new(&second_path).unwrap(); + second_file + .set_permissions(Permissions::from_mode(0222)) + .unwrap(); + check(format!( + "Could not read config at '{second_path_fmt}': permission denied" + )); + + std::fs::write(&config_path, "@inline-matching@[*").unwrap(); + check(format!( + "Malformed glob regex at '{config_path_fmt}:1': Pattern syntax error near position 16: invalid range pattern" + )); + + std::fs::write(&config_path, "@inline-matching@*second-conf.conf").unwrap(); + check(format!( + "Could not read config at '{second_path_fmt}': permission denied" + )); + + std::fs::write(&config_path, "\n@inline-matching@*.conf").unwrap(); + check(format!( + "Recursion limit in config inlining at '{config_path_fmt}:2'" + )); + std::fs::write(&config_path, "\n\n@inline-matching@*.conf").unwrap(); + check(format!( + "Recursion limit in config inlining at '{config_path_fmt}:3'" + )); + } + + #[test] + fn parsing() { + let check = |err: &str, content: &str| check_err(err, SOURCE.parse_str(&content)); + + check( + "Expected section header, option assignment or directive at 'mem:1'", + "syntax error", + ); + check( + "Expected section header or directive at 'mem:1'", + "key=value", + ); + check( + "Expected section header, option assignment or directive at 'mem:2'", + "[section]\nbad-line", + ); + + let cfg = SOURCE + .parse_str( + r#" + + [section-a] + + bar = baz + + [section-b] + + first_value = 1 + second_value = "test" + + "#, + ) + .unwrap(); + + // Missing section + check_err( + "Missing string option 'value' in section 'unknown'", + cfg.section("unknown").string("value").require(), + ); + + // Missing value + check_err( + "Missing string option 'value' in section 'section-a'", + cfg.section("section-a").string("value").require(), + ); + } + + const DEFAULT_CONF: &str = "[PATHS]\nDATADIR=mydir\nRECURSIVE=$RECURSIVE"; + + fn routine<T: Debug + Eq>( + ty: &str, + lambda: for<'a> fn(&'a Section<'a>, &'a str) -> Value<'a, T>, + wellformed: &[(&[&str], T)], + malformed: &[(&[&str], fn(&str) -> String)], + ) { + let conf = |content: &str| { + SOURCE + .parse_str(&format!("{DEFAULT_CONF}\n{content}")) + .unwrap() + }; + + // Check missing msg + let cfg = conf(""); + check_err( + format!("Missing {ty} option 'value' in section 'section'"), + lambda(&cfg.section("section"), "value").require(), + ); + + // Check wellformed options are properly parsed + for (raws, expected) in wellformed { + for raw in *raws { + let cfg = conf(&format!("[section]\nvalue={raw}")); + dbg!(&cfg); + assert_eq!( + *expected, + lambda(&cfg.section("section"), "value").require().unwrap() + ); + } + } + + // Check malformed options have proper error message + for (raws, error_fmt) in malformed { + for raw in *raws { + let cfg = conf(&format!("[section]\nvalue={raw}")); + check_err( + format!( + "Invalid {ty} option 'value' in section 'section': {}", + error_fmt(&raw) + ), + lambda(&cfg.section("section"), "value").require(), + ) + } + } + } + + #[test] + fn string() { + routine( + "string", + |sect, value| sect.string(value), + &[ + (&["1", "\"1\""], "1".to_owned()), + (&["test", "\"test\""], "test".to_owned()), + (&["\""], "\"".to_owned()), + ], + &[], + ); + } + + #[test] + fn path() { + routine( + "path", + |sect, value| sect.path(value), + &[ + (&["path"], "path".to_owned()), + ( + &["foo/$DATADIR/bar", "foo/${DATADIR}/bar"], + "foo/mydir/bar".to_owned(), + ), + ( + &["foo/$DATADIR$DATADIR/bar"], + "foo/mydirmydir/bar".to_owned(), + ), + ( + &["foo/pre_$DATADIR/bar", "foo/pre_${DATADIR}/bar"], + "foo/pre_mydir/bar".to_owned(), + ), + ( + &[ + "foo/${DATADIR}_next/bar", + "foo/${UNKNOWN:-$DATADIR}_next/bar", + ], + "foo/mydir_next/bar".to_owned(), + ), + ( + &[ + "foo/${UNKNOWN:-default}_next/bar", + "foo/${UNKNOWN:-${UNKNOWN:-default}}_next/bar", + ], + "foo/default_next/bar".to_owned(), + ), + ( + &["foo/${UNKNOWN:-pre_${UNKNOWN:-default}_next}_next/bar"], + "foo/pre_default_next_next/bar".to_owned(), + ), + ], + &[ + (&["foo/${A/bar"], |_| "bad substitution '/bar'".to_owned()), + (&["foo/${A:-pre_${B}/bar"], |_| { + "unbalanced variable expression 'pre_${B}/bar'".to_owned() + }), + (&["foo/${A:-${B${C}/bar"], |_| { + "unbalanced variable expression '${B${C}/bar'".to_owned() + }), + (&["foo/$UNKNOWN/bar", "foo/${UNKNOWN}/bar"], |_| { + "unbound variable 'UNKNOWN'".to_owned() + }), + (&["foo/$RECURSIVE/bar"], |_| { + "recursion limit in path substitution exceeded for '$RECURSIVE'".to_owned() + }), + ], + ) + } + + #[test] + fn number() { + routine( + "number", + |sect, value| sect.number(value), + &[(&["1"], 1), (&["42"], 42)], + &[(&["true", "YES"], |it| format!("'{it}' not a valid number"))], + ); + } + + #[test] + fn boolean() { + routine( + "boolean", + |sect, value| sect.boolean(value), + &[(&["yes", "YES", "Yes"], true), (&["no", "NO", "No"], false)], + &[(&["true", "1"], |it| { + format!("expected 'YES' or 'NO' got '{it}'") + })], + ); + } + + #[test] + fn amount() { + routine( + "amount", + |sect, value| sect.amount(value, "KUDOS"), + &[( + &["KUDOS:12", "KUDOS:12.0", "KUDOS:012.0"], + amount::amount("KUDOS:12"), + )], + &[ + (&["test", "42"], |it| { + format!("amount '{it}' invalid format") + }), + (&["KUDOS:0.3ABC"], |it| { + format!("amount '{it}' invalid fraction (invalid digit found in string)") + }), + (&["KUDOS:999999999999999999"], |it| { + format!("amount '{it}' value overflow (must be <= 9007199254740992)") + }), + (&["EUR:12"], |_| { + "expected currency KUDOS got EUR".to_owned() + }), + ], + ) + } +} diff --git a/taler-common/src/lib.rs b/taler-common/src/lib.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024 Taler Systems SA + Copyright (C) 2024-2025 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -17,8 +17,6 @@ pub mod api_common; pub mod api_params; pub mod api_wire; +pub mod config; pub mod error_code; pub mod types; -pub mod config { - // TODO -} diff --git a/taler-common/src/types/amount.rs b/taler-common/src/types/amount.rs @@ -1,6 +1,6 @@ /* This file is part of TALER - Copyright (C) 2024 Taler Systems SA + Copyright (C) 2024-2025 Taler Systems SA TALER is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software @@ -150,46 +150,46 @@ impl Decimal { } #[derive(Debug, thiserror::Error)] -pub enum DecimalErrorKind { - #[error("value specified is too large (must be <= {MAX_VALUE})")] +pub enum DecimalErrKind { + #[error("value overflow (must be <= {MAX_VALUE})")] Overflow, - #[error("invalid value: {0}")] + #[error("invalid value ({0})")] InvalidValue(ParseIntError), - #[error("invalid fraction: {0}")] + #[error("invalid fraction ({0})")] InvalidFraction(ParseIntError), - #[error("fractional value overflow (max {FRAC_BASE_NB_DIGITS} digits)")] + #[error("fraction overflow (max {FRAC_BASE_NB_DIGITS} digits)")] FractionOverflow, } #[derive(Debug, thiserror::Error)] #[error("decimal '{decimal}' {kind}")] -pub struct ParseDecimalError { +pub struct ParseDecimalErr { decimal: String, - pub kind: DecimalErrorKind, + pub kind: DecimalErrKind, } impl FromStr for Decimal { - type Err = ParseDecimalError; + type Err = ParseDecimalErr; fn from_str(s: &str) -> Result<Self, Self::Err> { let (value, fraction) = s.split_once('.').unwrap_or((s, "")); // TODO use try block when stable (|| { - let value: u64 = value.parse().map_err(DecimalErrorKind::InvalidValue)?; + let value: u64 = value.parse().map_err(DecimalErrKind::InvalidValue)?; if value > MAX_VALUE { - return Err(DecimalErrorKind::Overflow); + return Err(DecimalErrKind::Overflow); } if fraction.len() > FRAC_BASE_NB_DIGITS as usize { - return Err(DecimalErrorKind::FractionOverflow); + return Err(DecimalErrKind::FractionOverflow); } let fraction: u32 = if fraction.is_empty() { 0 } else { fraction .parse::<u32>() - .map_err(DecimalErrorKind::InvalidFraction)? + .map_err(DecimalErrKind::InvalidFraction)? * 10u32.pow(FRAC_BASE_NB_DIGITS as u32 - fraction.len() as u32) }; Ok(Self { @@ -197,7 +197,7 @@ impl FromStr for Decimal { frac: fraction, }) })() - .map_err(|kind| ParseDecimalError { + .map_err(|kind| ParseDecimalErr { decimal: s.to_owned(), kind, }) @@ -273,34 +273,34 @@ pub fn amount(amount: impl AsRef<str>) -> Amount { } #[derive(Debug, thiserror::Error)] -pub enum AmountErrorKind { +pub enum AmountErrKind { #[error("invalid format")] Format, #[error("currency {0}")] Currency(#[from] CurrencyErrorKind), #[error(transparent)] - Decimal(#[from] DecimalErrorKind), + Decimal(#[from] DecimalErrKind), } #[derive(Debug, thiserror::Error)] #[error("amount '{amount}' {kind}")] -pub struct ParseAmountError { +pub struct ParseAmountErr { amount: String, - kind: AmountErrorKind, + pub kind: AmountErrKind, } impl FromStr for Amount { - type Err = ParseAmountError; + type Err = ParseAmountErr; fn from_str(s: &str) -> Result<Self, Self::Err> { // TODO use try block when stable (|| { - let (currency, amount) = s.trim().split_once(':').ok_or(AmountErrorKind::Format)?; + let (currency, amount) = s.trim().split_once(':').ok_or(AmountErrKind::Format)?; let currency = currency.parse().map_err(|e: ParseCurrencyError| e.kind)?; - let decimal = amount.parse().map_err(|e: ParseDecimalError| e.kind)?; + let decimal = amount.parse().map_err(|e: ParseDecimalErr| e.kind)?; Ok((currency, decimal).into()) })() - .map_err(|kind| ParseAmountError { + .map_err(|kind| ParseAmountErr { amount: s.to_owned(), kind, })