commit e83ede7f0a847f7335a178d7120768a10f70ab38
parent ccada7ece4be95584fad1c8441a534e9ab0606c1
Author: Antoine A <>
Date: Tue, 7 Jan 2025 16:19:45 +0100
common: config parser
Diffstat:
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(§ion) {
+ 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(§ion.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,
})