Skip to main content

bnto_core/
secrets.rs

1// Secret declarations for recipes — pre-flight validation of required env vars.
2//
3// Recipes declare what secrets they need via a `secrets` array in the
4// PipelineDefinition. This module defines the type and validation logic.
5// See strategy/recipe-secrets.md for the full design.
6
7use serde::{Deserialize, Serialize};
8
9use crate::context::ProcessContext;
10
11/// A secret required by a recipe, declared in the definition's `secrets` array.
12///
13/// The `key` matches the `{{env.KEY}}` placeholder used in node params.
14/// At execution time, the key is resolved via `ProcessContext::env_var()`.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct SecretDef {
17    /// Environment variable name (e.g., `"OPENAI_API_KEY"`).
18    pub key: String,
19
20    /// Human-readable description shown in error messages and `bnto doctor`.
21    #[serde(default, skip_serializing_if = "String::is_empty")]
22    pub description: String,
23
24    /// Whether the pipeline should refuse to start without this secret.
25    /// Optional secrets resolve to empty string if absent.
26    #[serde(default = "default_required")]
27    pub required: bool,
28}
29
30fn default_required() -> bool {
31    true
32}
33
34/// Result of validating a single secret.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SecretStatus {
37    pub key: String,
38    pub description: String,
39    pub required: bool,
40    pub present: bool,
41}
42
43/// Validate that all required secrets are available via the ProcessContext.
44///
45/// Returns a list of statuses for each declared secret. Use `missing_required()`
46/// to extract only the ones that would block execution.
47pub fn check_secrets(secrets: &[SecretDef], ctx: &dyn ProcessContext) -> Vec<SecretStatus> {
48    secrets
49        .iter()
50        .map(|s| SecretStatus {
51            key: s.key.clone(),
52            description: s.description.clone(),
53            required: s.required,
54            present: ctx.env_var(&s.key).is_some(),
55        })
56        .collect()
57}
58
59/// Filter statuses to only required secrets that are missing.
60pub fn missing_required(statuses: &[SecretStatus]) -> Vec<&SecretStatus> {
61    statuses
62        .iter()
63        .filter(|s| s.required && !s.present)
64        .collect()
65}
66
67/// Format a user-friendly error message for missing required secrets.
68pub fn format_missing_error(missing: &[&SecretStatus]) -> String {
69    let mut lines = vec!["Missing required secrets:".to_string()];
70    for s in missing {
71        if s.description.is_empty() {
72            lines.push(format!("  - {}", s.key));
73        } else {
74            lines.push(format!("  - {} ({})", s.key, s.description));
75        }
76    }
77    lines.push(String::new());
78    lines.push("Set them via environment variables or add to ~/.config/bnto/.env".to_string());
79    lines.join("\n")
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::context::NoopContext;
86    use std::collections::BTreeMap;
87    use std::path::{Path, PathBuf};
88
89    /// Mock context that returns controlled env var values.
90    struct MockCtx {
91        env: BTreeMap<String, String>,
92    }
93
94    impl MockCtx {
95        fn new(pairs: &[(&str, &str)]) -> Self {
96            Self {
97                env: pairs
98                    .iter()
99                    .map(|(k, v)| (k.to_string(), v.to_string()))
100                    .collect(),
101            }
102        }
103    }
104
105    impl ProcessContext for MockCtx {
106        fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, crate::BntoError> {
107            Err(crate::BntoError::ProcessingFailed("mock".into()))
108        }
109        fn temp_file(&self, _suffix: &str) -> Result<PathBuf, crate::BntoError> {
110            Ok(PathBuf::from("/tmp/mock"))
111        }
112        fn env_var(&self, key: &str) -> Option<String> {
113            self.env.get(key).cloned()
114        }
115        fn work_dir(&self) -> Result<&Path, crate::BntoError> {
116            Err(crate::BntoError::ProcessingFailed("mock".into()))
117        }
118    }
119
120    fn secret(key: &str, required: bool) -> SecretDef {
121        SecretDef {
122            key: key.to_string(),
123            description: String::new(),
124            required,
125        }
126    }
127
128    fn secret_with_desc(key: &str, desc: &str, required: bool) -> SecretDef {
129        SecretDef {
130            key: key.to_string(),
131            description: desc.to_string(),
132            required,
133        }
134    }
135
136    #[test]
137    fn all_required_present() {
138        let secrets = vec![secret("API_KEY", true), secret("DB_URL", true)];
139        let ctx = MockCtx::new(&[("API_KEY", "sk-123"), ("DB_URL", "postgres://")]);
140        let statuses = check_secrets(&secrets, &ctx);
141        let missing = missing_required(&statuses);
142        assert!(missing.is_empty());
143    }
144
145    #[test]
146    fn required_secret_missing() {
147        let secrets = vec![secret("API_KEY", true)];
148        let ctx = MockCtx::new(&[]);
149        let statuses = check_secrets(&secrets, &ctx);
150        let missing = missing_required(&statuses);
151        assert_eq!(missing.len(), 1);
152        assert_eq!(missing[0].key, "API_KEY");
153    }
154
155    #[test]
156    fn optional_secret_missing_is_ok() {
157        let secrets = vec![secret("OPTIONAL_KEY", false)];
158        let ctx = MockCtx::new(&[]);
159        let statuses = check_secrets(&secrets, &ctx);
160        let missing = missing_required(&statuses);
161        assert!(missing.is_empty());
162    }
163
164    #[test]
165    fn mixed_required_and_optional() {
166        let secrets = vec![
167            secret("REQUIRED", true),
168            secret("OPTIONAL", false),
169            secret("ALSO_REQUIRED", true),
170        ];
171        let ctx = MockCtx::new(&[("REQUIRED", "yes")]);
172        let statuses = check_secrets(&secrets, &ctx);
173        let missing = missing_required(&statuses);
174        assert_eq!(missing.len(), 1);
175        assert_eq!(missing[0].key, "ALSO_REQUIRED");
176    }
177
178    #[test]
179    fn empty_secrets_list() {
180        let ctx = MockCtx::new(&[]);
181        let statuses = check_secrets(&[], &ctx);
182        assert!(statuses.is_empty());
183    }
184
185    #[test]
186    fn noop_context_returns_all_missing() {
187        let secrets = vec![secret("KEY", true)];
188        let statuses = check_secrets(&secrets, &NoopContext);
189        assert!(!statuses[0].present);
190    }
191
192    #[test]
193    fn format_error_with_descriptions() {
194        let statuses = [
195            SecretStatus {
196                key: "API_KEY".into(),
197                description: "OpenAI API key".into(),
198                required: true,
199                present: false,
200            },
201            SecretStatus {
202                key: "DB_URL".into(),
203                description: String::new(),
204                required: true,
205                present: false,
206            },
207        ];
208        let missing: Vec<&SecretStatus> = statuses.iter().collect();
209        let msg = format_missing_error(&missing);
210        assert!(msg.contains("API_KEY (OpenAI API key)"));
211        assert!(msg.contains("  - DB_URL"));
212        assert!(!msg.contains("DB_URL ("));
213        assert!(msg.contains("~/.config/bnto/.env"));
214    }
215
216    #[test]
217    fn secret_def_deserializes_with_defaults() {
218        let json = r#"{ "key": "API_KEY" }"#;
219        let s: SecretDef = serde_json::from_str(json).unwrap();
220        assert_eq!(s.key, "API_KEY");
221        assert!(s.required);
222        assert!(s.description.is_empty());
223    }
224
225    #[test]
226    fn secret_def_deserializes_full() {
227        let json = r#"{
228            "key": "OPENAI_API_KEY",
229            "description": "OpenAI API key for GPT-4",
230            "required": false
231        }"#;
232        let s: SecretDef = serde_json::from_str(json).unwrap();
233        assert_eq!(s.key, "OPENAI_API_KEY");
234        assert_eq!(s.description, "OpenAI API key for GPT-4");
235        assert!(!s.required);
236    }
237
238    #[test]
239    fn secret_def_round_trip() {
240        let original = secret_with_desc("KEY", "desc", true);
241        let json = serde_json::to_string(&original).unwrap();
242        let parsed: SecretDef = serde_json::from_str(&json).unwrap();
243        assert_eq!(original, parsed);
244    }
245
246    #[test]
247    fn empty_description_omitted_in_serialization() {
248        let s = secret("KEY", true);
249        let json = serde_json::to_string(&s).unwrap();
250        assert!(!json.contains("description"));
251    }
252}