use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::errors::OrionError;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(transparent)]
pub struct VarsConfig(pub BTreeMap<String, toml::Value>);
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(transparent)]
pub struct SecretsConfig(pub BTreeMap<String, String>);
impl VarsConfig {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn to_json(&self) -> Option<serde_json::Value> {
if self.is_empty() {
return None;
}
serde_json::to_value(&self.0).ok()
}
pub(super) fn validate(&self) -> Result<(), OrionError> {
for (name, value) in &self.0 {
validate_name(name, "vars")?;
check_var_value(name, value)?;
}
Ok(())
}
}
impl SecretsConfig {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
self.0.iter()
}
pub(super) fn validate(&self) -> Result<(), OrionError> {
for (name, reference) in &self.0 {
validate_name(name, "secrets")?;
if !crate::connector::secrets::is_resolvable_reference(reference) {
return Err(OrionError::Config {
message: format!(
"secrets.{name} must be a secret reference such as \
\"env://SOME_VAR\" or \"vault://path#key\", not a literal value \
(a key written into a config file is a key in the deployment's \
file tree)"
),
});
}
}
Ok(())
}
}
fn validate_name(name: &str, section: &str) -> Result<(), OrionError> {
let ok = !name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !ok {
return Err(OrionError::Config {
message: format!(
"[{section}] name '{name}' is not an identifier — names may hold \
ASCII letters, digits and underscores, and may not start with a digit"
),
});
}
Ok(())
}
fn check_var_value(name: &str, value: &toml::Value) -> Result<(), OrionError> {
match value {
toml::Value::String(s) => {
if crate::connector::secrets::is_resolvable_reference(s) {
return Err(OrionError::Config {
message: format!(
"vars.{name} is a secret reference, and nothing resolves one on its \
way into metadata — a workflow would read the literal text '{s}'. \
Declare it under [secrets] and read it with \
{{\"secret\": \"{name}\"}}, or inline the value here"
),
});
}
Ok(())
}
toml::Value::Integer(_) | toml::Value::Float(_) | toml::Value::Boolean(_) => Ok(()),
toml::Value::Array(items) => items
.iter()
.try_for_each(|item| check_var_value(name, item)),
toml::Value::Table(table) => table
.values()
.try_for_each(|item| check_var_value(name, item)),
toml::Value::Datetime(_) => Err(OrionError::Config {
message: format!(
"vars.{name} is a TOML datetime, which has no JSON form — write it as a \
quoted string"
),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(toml_text: &str) -> VarsConfig {
VarsConfig(toml::from_str(toml_text).expect("test fixture parses"))
}
fn secrets(toml_text: &str) -> SecretsConfig {
SecretsConfig(toml::from_str(toml_text).expect("test fixture parses"))
}
#[test]
fn a_var_keeps_the_type_it_was_written_as() {
let json = vars("prefix = \"eu\"\nretries = 3\nverbose = true")
.to_json()
.expect("non-empty");
assert_eq!(json["prefix"], serde_json::json!("eu"));
assert_eq!(json["retries"], serde_json::json!(3));
assert_eq!(json["verbose"], serde_json::json!(true));
}
#[test]
fn an_empty_section_stamps_nothing() {
assert!(vars("").to_json().is_none());
}
#[test]
fn a_secret_reference_in_vars_is_refused() {
let err = vars("token = \"env://PARTNER_TOKEN\"")
.validate()
.expect_err("a reference in vars reaches the workflow as literal text");
assert!(err.to_string().contains("[secrets]"), "{err}");
}
#[test]
fn a_literal_in_secrets_is_refused() {
let err = secrets("token = \"sk-live-abc\"")
.validate()
.expect_err("a literal key in a config file is a key on disk");
assert!(err.to_string().contains("env://"), "{err}");
secrets("token = \"env://PARTNER_TOKEN\"")
.validate()
.expect("a reference is the whole point");
}
#[test]
fn names_must_be_identifiers() {
for bad in ["", "a.b", "2fast", "with space", "dash-ed"] {
let mut map = BTreeMap::new();
map.insert(bad.to_string(), toml::Value::String("x".into()));
VarsConfig(map)
.validate()
.expect_err("'{bad}' is not a typable path segment");
}
vars("ok_name_2 = \"x\"")
.validate()
.expect("an identifier is fine");
}
#[test]
fn a_datetime_var_is_refused_rather_than_silently_reshaped() {
vars("cutover = 1979-05-27T07:32:00Z")
.validate()
.expect_err("TOML datetimes have no JSON form");
}
}