Skip to main content

greentic_setup/
secrets.rs

1//! Dev secrets store management for bundle setup.
2//!
3//! Provides helpers for locating the dev secrets file and
4//! [`SecretsSetup`] for ensuring pack secrets are seeded.
5
6use std::collections::BTreeMap;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Result, anyhow};
11use greentic_secrets_lib::core::Error as SecretError;
12use greentic_secrets_lib::{
13    ApplyOptions, DevStore, SecretFormat, SecretsStore, SeedDoc, SeedEntry, SeedValue, apply_seed,
14};
15use serde_cbor::Value as CborValue;
16use tracing::{debug, info};
17
18use crate::canonical_secret_uri;
19
20// ── Dev store path helpers ──────────────────────────────────────────────────
21
22const STORE_RELATIVE: &str = ".greentic/dev/.dev.secrets.env";
23const STORE_STATE_RELATIVE: &str = ".greentic/state/dev/.dev.secrets.env";
24const OVERRIDE_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
25
26/// Returns a path explicitly configured via `$GREENTIC_DEV_SECRETS_PATH`.
27pub fn override_path() -> Option<PathBuf> {
28    std::env::var(OVERRIDE_ENV).ok().map(PathBuf::from)
29}
30
31/// Checks for an existing dev store inside the bundle root.
32pub fn find_existing(bundle_root: &Path) -> Option<PathBuf> {
33    find_existing_with_override(bundle_root, override_path().as_deref())
34}
35
36/// Looks for an existing dev store using an override path before consulting default candidates.
37pub fn find_existing_with_override(
38    bundle_root: &Path,
39    override_path: Option<&Path>,
40) -> Option<PathBuf> {
41    if let Some(path) = override_path
42        && path.exists()
43    {
44        return Some(path.to_path_buf());
45    }
46    candidate_paths(bundle_root)
47        .into_iter()
48        .find(|candidate| candidate.exists())
49}
50
51/// Ensures the default dev store path exists (creating parent directories) before returning it.
52pub fn ensure_path(bundle_root: &Path) -> Result<PathBuf> {
53    if let Some(path) = override_path() {
54        ensure_parent(&path)?;
55        return Ok(path);
56    }
57    let path = bundle_root.join(STORE_RELATIVE);
58    ensure_parent(&path)?;
59    Ok(path)
60}
61
62/// Returns the default dev store path without creating anything.
63pub fn default_path(bundle_root: &Path) -> PathBuf {
64    bundle_root.join(STORE_RELATIVE)
65}
66
67fn candidate_paths(bundle_root: &Path) -> [PathBuf; 2] {
68    [
69        bundle_root.join(STORE_RELATIVE),
70        bundle_root.join(STORE_STATE_RELATIVE),
71    ]
72}
73
74fn ensure_parent(path: &Path) -> Result<()> {
75    if let Some(parent) = path.parent() {
76        std::fs::create_dir_all(parent)?;
77    }
78    Ok(())
79}
80
81// ── SecretsSetup ────────────────────────────────────────────────────────────
82
83/// Single entry-point for secrets initialization and resolution.
84///
85/// Opens exactly one dev store per instance and ensures every required secret
86/// discovered from packs is canonicalized and registered.
87pub struct SecretsSetup {
88    store: DevStore,
89    store_path: PathBuf,
90    env: String,
91    tenant: String,
92    team: Option<String>,
93    seeds: HashMap<String, SeedEntry>,
94}
95
96impl SecretsSetup {
97    pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
98        let store_path = ensure_path(bundle_root)?;
99        info!(path = %store_path.display(), "secrets: using dev store backend");
100        let store = DevStore::with_path(&store_path).map_err(|err| {
101            anyhow!(
102                "failed to open dev secrets store {}: {err}",
103                store_path.display()
104            )
105        })?;
106        let seeds = load_seed_entries(bundle_root)?;
107        Ok(Self {
108            store,
109            store_path,
110            env: env.to_string(),
111            tenant: tenant.to_string(),
112            team: team.map(|v| v.to_string()),
113            seeds,
114        })
115    }
116
117    /// Path to the dev store file on disk.
118    pub fn store_path(&self) -> &Path {
119        &self.store_path
120    }
121
122    /// Reference to the underlying `DevStore`.
123    pub fn store(&self) -> &DevStore {
124        &self.store
125    }
126
127    /// Ensure all required secrets for a pack exist in the dev store.
128    ///
129    /// Reads `assets/secret-requirements.json` from the pack and seeds any
130    /// missing keys from `seeds.yaml` or with a placeholder.
131    pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
132        let keys = load_secret_keys_from_pack(pack_path)?;
133        if keys.is_empty() {
134            return Ok(());
135        }
136
137        let mut missing = Vec::new();
138        for key in keys {
139            let uri = canonical_secret_uri(
140                &self.env,
141                &self.tenant,
142                self.team.as_deref(),
143                provider_id,
144                &key,
145            );
146            debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
147            match self.store.get(&uri).await {
148                Ok(_) => continue,
149                Err(SecretError::NotFound { .. }) => {
150                    let source = if self.seeds.contains_key(&uri) {
151                        "seeds.yaml"
152                    } else {
153                        "placeholder"
154                    };
155                    debug!(uri = %uri, source, "seeding missing secret");
156                    missing.push(
157                        self.seeds
158                            .get(&uri)
159                            .cloned()
160                            .unwrap_or_else(|| placeholder_entry(uri)),
161                    );
162                }
163                Err(err) => {
164                    return Err(anyhow!("failed to read secret {uri}: {err}"));
165                }
166            }
167        }
168
169        if missing.is_empty() {
170            return Ok(());
171        }
172        let report = apply_seed(
173            &self.store,
174            &SeedDoc { entries: missing },
175            ApplyOptions::default(),
176        )
177        .await;
178        if !report.failed.is_empty() {
179            return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
180        }
181        Ok(())
182    }
183}
184
185// ── Helpers ─────────────────────────────────────────────────────────────────
186
187fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
188    for candidate in seed_paths(bundle_root) {
189        if candidate.exists() {
190            let contents = std::fs::read_to_string(&candidate)?;
191            let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
192            return Ok(doc
193                .entries
194                .into_iter()
195                .map(|entry| (entry.uri.clone(), entry))
196                .collect());
197        }
198    }
199    Ok(HashMap::new())
200}
201
202fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
203    [
204        bundle_root.join("seeds.yaml"),
205        bundle_root.join("state").join("seeds.yaml"),
206    ]
207}
208
209fn placeholder_entry(uri: String) -> SeedEntry {
210    SeedEntry {
211        uri: uri.clone(),
212        format: SecretFormat::Text,
213        value: SeedValue::Text {
214            text: format!("placeholder for {uri}"),
215        },
216        description: Some("auto-applied placeholder".to_string()),
217    }
218}
219
220/// Load secret requirement keys from a `.gtpack` archive.
221///
222/// Tries `assets/secret-requirements.json` first, then falls back to
223/// CBOR manifest extraction.
224pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
225    Ok(load_secret_requirements_from_pack(pack_path)?
226        .into_iter()
227        .map(|req| req.key)
228        .collect())
229}
230
231/// Rich secret requirements extracted from a `.gtpack` archive.
232pub fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
233    let file = std::fs::File::open(pack_path)?;
234    let mut archive = zip::ZipArchive::new(file)?;
235
236    for entry_name in &[
237        "assets/secret-requirements.json",
238        "assets/secret_requirements.json",
239        "secret-requirements.json",
240        "secret_requirements.json",
241    ] {
242        match archive.by_name(entry_name) {
243            Ok(reader) => {
244                let reqs: Vec<PackSecretRequirement> = serde_json::from_reader(reader)?;
245                return Ok(dedup_requirements(reqs));
246            }
247            Err(zip::result::ZipError::FileNotFound) => continue,
248            Err(err) => return Err(err.into()),
249        }
250    }
251
252    let mut reqs = Vec::new();
253    for index in 0..archive.len() {
254        let name = {
255            let entry = archive.by_index(index)?;
256            entry.name().to_string()
257        };
258        if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
259            continue;
260        }
261        let mut entry = archive.by_name(&name)?;
262        let mut bytes = Vec::new();
263        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
264        let value: CborValue = serde_cbor::from_slice(&bytes)?;
265        collect_secret_requirements_from_cbor(&value, &mut reqs);
266    }
267
268    Ok(dedup_requirements(reqs))
269}
270
271#[derive(Clone, Debug, serde::Deserialize)]
272pub struct PackSecretRequirement {
273    pub key: String,
274    #[serde(default = "default_required")]
275    pub required: bool,
276    #[serde(default)]
277    pub description: Option<String>,
278}
279
280fn default_required() -> bool {
281    true
282}
283
284fn dedup_requirements(reqs: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
285    let mut by_key = BTreeMap::new();
286    for req in reqs {
287        by_key.entry(req.key.clone()).or_insert(req);
288    }
289    by_key.into_values().collect()
290}
291
292fn collect_secret_requirements_from_cbor(value: &CborValue, out: &mut Vec<PackSecretRequirement>) {
293    match value {
294        CborValue::Array(values) => {
295            for value in values {
296                collect_secret_requirements_from_cbor(value, out);
297            }
298        }
299        CborValue::Map(map) => {
300            if let Some(req) = parse_secret_requirement_map(map) {
301                out.push(req);
302            }
303            for value in map.values() {
304                collect_secret_requirements_from_cbor(value, out);
305            }
306        }
307        _ => {}
308    }
309}
310
311fn parse_secret_requirement_map(
312    map: &BTreeMap<CborValue, CborValue>,
313) -> Option<PackSecretRequirement> {
314    let key = map_get_text(map, "key")?;
315    let has_secret_shape = map.contains_key(&CborValue::Text("required".to_string()))
316        || map.contains_key(&CborValue::Text("scope".to_string()))
317        || map.contains_key(&CborValue::Text("format".to_string()))
318        || map.contains_key(&CborValue::Text("description".to_string()));
319    if !has_secret_shape {
320        return None;
321    }
322    Some(PackSecretRequirement {
323        key,
324        required: map_get_bool(map, "required").unwrap_or(true),
325        description: map_get_text(map, "description"),
326    })
327}
328
329fn map_get_text(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<String> {
330    map.get(&CborValue::Text(key.to_string()))
331        .and_then(|value| match value {
332            CborValue::Text(text) => Some(text.clone()),
333            _ => None,
334        })
335}
336
337fn map_get_bool(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<bool> {
338    map.get(&CborValue::Text(key.to_string()))
339        .and_then(|value| match value {
340            CborValue::Bool(flag) => Some(*flag),
341            _ => None,
342        })
343}
344
345/// Open a `DevStore` from a bundle root path (convenience).
346pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
347    let store_path = ensure_path(bundle_root)?;
348    DevStore::with_path(&store_path).map_err(|err| {
349        anyhow!(
350            "failed to open dev secrets store {}: {err}",
351            store_path.display()
352        )
353    })
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::io::Write;
360    use zip::write::SimpleFileOptions;
361
362    fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
363        let file = std::fs::File::create(path)?;
364        let mut zip = zip::ZipWriter::new(file);
365        zip.start_file(
366            "assets/secret-requirements.json",
367            SimpleFileOptions::default(),
368        )?;
369        zip.write_all(req_json.as_bytes())?;
370        zip.finish()?;
371        Ok(())
372    }
373
374    #[test]
375    fn ensure_path_creates_parent_directories() {
376        let temp = tempfile::tempdir().expect("tempdir");
377        let bundle = temp.path().join("bundle");
378        std::fs::create_dir_all(&bundle).expect("bundle dir");
379        let path = ensure_path(&bundle).expect("ensure path");
380        assert!(path.ends_with(".greentic/dev/.dev.secrets.env"));
381        assert!(path.parent().expect("parent").exists());
382    }
383
384    #[test]
385    fn find_existing_with_override_prefers_override() {
386        let temp = tempfile::tempdir().expect("tempdir");
387        let bundle = temp.path().join("bundle");
388        std::fs::create_dir_all(&bundle).expect("bundle dir");
389        let override_file = temp.path().join("custom.env");
390        std::fs::write(&override_file, "KEY=value\n").expect("write override");
391
392        let found = find_existing_with_override(&bundle, Some(&override_file));
393        assert_eq!(found.as_deref(), Some(override_file.as_path()));
394    }
395
396    #[test]
397    fn find_existing_finds_default_locations() {
398        let temp = tempfile::tempdir().expect("tempdir");
399        let bundle = temp.path().join("bundle");
400        let store_path = bundle.join(STORE_RELATIVE);
401        std::fs::create_dir_all(store_path.parent().expect("parent")).expect("create dirs");
402        std::fs::write(&store_path, "K=V\n").expect("write store");
403
404        let found = find_existing_with_override(&bundle, None).expect("found");
405        assert_eq!(found, store_path);
406    }
407
408    #[test]
409    fn load_secret_keys_from_pack_reads_requirements() {
410        let temp = tempfile::tempdir().expect("tempdir");
411        let pack = temp.path().join("provider.gtpack");
412        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
413            .expect("write pack");
414
415        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
416        assert_eq!(
417            keys,
418            vec!["API_SECRET".to_string(), "BOT_TOKEN".to_string()]
419        );
420    }
421
422    #[test]
423    fn load_secret_keys_from_pack_reads_cbor_manifest_requirements() {
424        let temp = tempfile::tempdir().expect("tempdir");
425        let pack = temp.path().join("provider.gtpack");
426        let file = std::fs::File::create(&pack).expect("create pack");
427        let mut zip = zip::ZipWriter::new(file);
428        zip.start_file("manifest.cbor", SimpleFileOptions::default())
429            .expect("start entry");
430        let manifest = serde_json::json!({
431            "components": [
432                {
433                    "host": {
434                        "secrets": {
435                            "required": [
436                                {
437                                    "key": "auth.param.get_weather.key",
438                                    "required": true,
439                                    "description": "Weather key",
440                                    "scope": {"env": "runtime", "tenant": "runtime"},
441                                    "format": "text"
442                                }
443                            ]
444                        }
445                    }
446                }
447            ]
448        });
449        let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
450        zip.write_all(&bytes).expect("write manifest");
451        zip.finish().expect("finish zip");
452
453        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
454        assert_eq!(keys, vec!["auth.param.get_weather.key".to_string()]);
455
456        let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
457        assert_eq!(reqs.len(), 1);
458        assert_eq!(reqs[0].description.as_deref(), Some("Weather key"));
459    }
460
461    #[test]
462    fn load_secret_keys_from_pack_returns_empty_without_requirements() {
463        let temp = tempfile::tempdir().expect("tempdir");
464        let pack = temp.path().join("provider.gtpack");
465        let file = std::fs::File::create(&pack).expect("create pack");
466        let mut zip = zip::ZipWriter::new(file);
467        zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
468            .expect("start entry");
469        zip.write_all(b"questions: []\n").expect("write setup");
470        zip.finish().expect("finish zip");
471
472        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
473        assert!(keys.is_empty());
474    }
475
476    #[tokio::test]
477    async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
478        let temp = tempfile::tempdir().expect("tempdir");
479        let bundle = temp.path().join("bundle");
480        std::fs::create_dir_all(&bundle).expect("bundle dir");
481        let pack = temp.path().join("provider.gtpack");
482        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
483
484        let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
485        setup
486            .ensure_pack_secrets(&pack, "messaging-telegram")
487            .await
488            .expect("ensure secrets");
489
490        let uri = canonical_secret_uri(
491            "dev",
492            "tenant-a",
493            Some("core"),
494            "messaging-telegram",
495            "BOT_TOKEN",
496        );
497        let value = setup.store().get(&uri).await.expect("seeded value");
498        let value = String::from_utf8(value).expect("utf8");
499        assert!(
500            value.contains("placeholder for secrets://"),
501            "unexpected placeholder value: {value}"
502        );
503    }
504
505    #[tokio::test]
506    async fn ensure_pack_secrets_uses_seed_values_when_available() {
507        let temp = tempfile::tempdir().expect("tempdir");
508        let bundle = temp.path().join("bundle");
509        std::fs::create_dir_all(&bundle).expect("bundle dir");
510        let seed_uri = canonical_secret_uri(
511            "dev",
512            "tenant-a",
513            Some("core"),
514            "messaging-telegram",
515            "BOT_TOKEN",
516        );
517        let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
518            entries: vec![SeedEntry {
519                uri: seed_uri.clone(),
520                format: SecretFormat::Text,
521                value: SeedValue::Text {
522                    text: "seeded-secret".to_string(),
523                },
524                description: Some("test seed".to_string()),
525            }],
526        })
527        .expect("serialize seeds");
528        std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
529
530        let pack = temp.path().join("provider.gtpack");
531        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
532
533        let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
534        setup
535            .ensure_pack_secrets(&pack, "messaging-telegram")
536            .await
537            .expect("ensure secrets");
538
539        let value = setup.store().get(&seed_uri).await.expect("seeded value");
540        let value = String::from_utf8(value).expect("utf8");
541        assert_eq!(value, "seeded-secret");
542    }
543}