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 parse_env_strict(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
61    let mut values = BTreeMap::new();
62    for (line_number, line) in text.lines().enumerate() {
63        let line = line.trim();
64        if line.is_empty() || line.starts_with('#') {
65            continue;
66        }
67        let Some((key, value)) = line.split_once('=') else {
68            bail!("invalid dotenv line {}", line_number + 1)
69        };
70        let key = key.trim();
71        if key.is_empty() || values.contains_key(key) {
72            bail!(
73                "invalid or duplicate dotenv variable on line {}",
74                line_number + 1
75            )
76        }
77        values.insert(key.to_owned(), value.trim().trim_matches('"').to_owned());
78    }
79    Ok(values)
80}
81
82pub fn validate(
83    schema: &Schema,
84    values: &BTreeMap<String, String>,
85    environment: &str,
86) -> Vec<Finding> {
87    let mut findings = Vec::new();
88    for (name, rule) in &schema.variables {
89        if !rule.environments.is_empty() && !rule.environments.iter().any(|e| e == environment) {
90            continue;
91        }
92        let Some(value) = values.get(name) else {
93            if rule.required && rule.default.is_none() {
94                findings.push(Finding {
95                    variable: name.clone(),
96                    kind: "missing".into(),
97                    message: "required variable is missing".into(),
98                });
99            }
100            continue;
101        };
102        let valid = match rule.kind.as_str() {
103            "integer" => value.parse::<i64>().is_ok(),
104            "boolean" => matches!(value.as_str(), "true" | "false"),
105            "url" => value.starts_with("http://") || value.starts_with("https://"),
106            "enum" => rule.values.iter().any(|v| v == value),
107            "duration" => {
108                !value.is_empty()
109                    && value
110                        .chars()
111                        .all(|c| c.is_ascii_digit() || matches!(c, 's' | 'm' | 'h' | 'd'))
112            }
113            "string" => true,
114            _ => false,
115        };
116        if !valid {
117            findings.push(Finding {
118                variable: name.clone(),
119                kind: "invalid".into(),
120                message: format!("invalid {} value", rule.kind),
121            });
122        }
123    }
124    for name in values.keys() {
125        if !schema.variables.contains_key(name) {
126            findings.push(Finding {
127                variable: name.clone(),
128                kind: "extra".into(),
129                message: "variable is not defined in the schema".into(),
130            });
131        }
132    }
133    findings
134}
135
136pub fn example(schema: &Schema) -> String {
137    schema
138        .variables
139        .iter()
140        .map(|(name, rule)| {
141            let description = rule.description.as_deref().unwrap_or("value");
142            let value = if rule.secret {
143                ""
144            } else {
145                rule.default.as_deref().unwrap_or("")
146            };
147            format!("# {description}\n{name}={value}\n")
148        })
149        .collect()
150}