Skip to main content

open_envault/
schema.rs

1use crate::output::Finding;
2use anyhow::{Context, bail};
3use serde::Deserialize;
4use std::{collections::BTreeMap, fs, path::Path};
5
6#[derive(Debug, Clone, Deserialize)]
7pub struct Schema {
8    pub variables: BTreeMap<String, Variable>,
9}
10#[derive(Debug, Clone, Deserialize)]
11pub struct Variable {
12    #[serde(rename = "type", default = "default_type")]
13    pub kind: String,
14    #[serde(default)]
15    pub values: Vec<String>,
16    #[serde(default)]
17    pub default: Option<String>,
18    #[serde(default)]
19    pub required: bool,
20    #[serde(default)]
21    pub description: Option<String>,
22    #[serde(default = "default_secret")]
23    pub secret: bool,
24    #[serde(default)]
25    pub environments: Vec<String>,
26}
27fn default_type() -> String {
28    "string".into()
29}
30fn default_secret() -> bool {
31    true
32}
33
34pub fn load(path: &Path) -> anyhow::Result<Schema> {
35    let text =
36        fs::read_to_string(path).with_context(|| format!("read schema {}", path.display()))?;
37    let schema: Schema = serde_yaml::from_str(&text).context("parse schema YAML")?;
38    if schema.variables.is_empty() {
39        bail!("schema contains no variables")
40    }
41    Ok(schema)
42}
43
44pub fn parse_env(text: &str) -> BTreeMap<String, String> {
45    text.lines()
46        .filter_map(|line| {
47            let line = line.trim();
48            if line.is_empty() || line.starts_with('#') {
49                return None;
50            }
51            let (key, value) = line.split_once('=')?;
52            Some((
53                key.trim().to_string(),
54                value.trim().trim_matches('"').to_string(),
55            ))
56        })
57        .collect()
58}
59
60pub fn validate(
61    schema: &Schema,
62    values: &BTreeMap<String, String>,
63    environment: &str,
64) -> Vec<Finding> {
65    let mut findings = Vec::new();
66    for (name, rule) in &schema.variables {
67        if !rule.environments.is_empty() && !rule.environments.iter().any(|e| e == environment) {
68            continue;
69        }
70        let Some(value) = values.get(name) else {
71            if rule.required && rule.default.is_none() {
72                findings.push(Finding {
73                    variable: name.clone(),
74                    kind: "missing".into(),
75                    message: "required variable is missing".into(),
76                });
77            }
78            continue;
79        };
80        let valid = match rule.kind.as_str() {
81            "integer" => value.parse::<i64>().is_ok(),
82            "boolean" => matches!(value.as_str(), "true" | "false"),
83            "url" => value.starts_with("http://") || value.starts_with("https://"),
84            "enum" => rule.values.iter().any(|v| v == value),
85            "duration" => {
86                !value.is_empty()
87                    && value
88                        .chars()
89                        .all(|c| c.is_ascii_digit() || matches!(c, 's' | 'm' | 'h' | 'd'))
90            }
91            "string" => true,
92            _ => false,
93        };
94        if !valid {
95            findings.push(Finding {
96                variable: name.clone(),
97                kind: "invalid".into(),
98                message: format!("invalid {} value", rule.kind),
99            });
100        }
101    }
102    findings
103}
104
105pub fn example(schema: &Schema) -> String {
106    schema
107        .variables
108        .iter()
109        .map(|(name, rule)| {
110            let description = rule.description.as_deref().unwrap_or("value");
111            let value = if rule.secret {
112                ""
113            } else {
114                rule.default.as_deref().unwrap_or("")
115            };
116            format!("# {description}\n{name}={value}\n")
117        })
118        .collect()
119}