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
284/// A host-generated secret declared by a pack's `greentic.generated-secrets.v1`
285/// extension (e.g. the webchat `jwt_signing_key`). Unlike a regular secret
286/// requirement, the user never enters it — the host seeds a missing required
287/// secret with a value produced per `policy`. See
288/// greentic-messaging-providers `docs/generated-runtime-secrets.md`.
289#[derive(Clone, Debug, PartialEq, Eq)]
290pub struct GeneratedSecret {
291    pub key: String,
292    pub required: bool,
293    /// Generation strategy; currently only `"random"`.
294    pub policy: String,
295    /// Output length for text encodings.
296    pub length: usize,
297    /// `raw_text` | `base64url` | `hex`.
298    pub encoding: String,
299    /// When false, an existing value is left untouched.
300    pub regenerate_if_present: bool,
301    /// Storage team segment from `scope.team` (e.g. `"_"` for tenant-level).
302    pub scope_team: Option<String>,
303}
304
305/// Read `greentic.generated-secrets.v1` declarations from a `.gtpack` manifest.
306/// Returns an empty vec when the pack declares none.
307pub fn load_generated_secrets_from_pack(pack_path: &Path) -> Result<Vec<GeneratedSecret>> {
308    let file = std::fs::File::open(pack_path)?;
309    let mut archive = zip::ZipArchive::new(file)?;
310    let mut out = Vec::new();
311    for index in 0..archive.len() {
312        let name = {
313            let entry = archive.by_index(index)?;
314            entry.name().to_string()
315        };
316        if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
317            continue;
318        }
319        let mut entry = archive.by_name(&name)?;
320        let mut bytes = Vec::new();
321        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
322        let value: CborValue = serde_cbor::from_slice(&bytes)?;
323        collect_generated_secrets_from_cbor(&value, &mut out);
324    }
325    Ok(out)
326}
327
328fn collect_generated_secrets_from_cbor(value: &CborValue, out: &mut Vec<GeneratedSecret>) {
329    match value {
330        CborValue::Array(values) => {
331            for value in values {
332                collect_generated_secrets_from_cbor(value, out);
333            }
334        }
335        CborValue::Map(map) => {
336            if let Some(CborValue::Text(kind)) = map.get(&CborValue::Text("kind".to_string()))
337                && kind == "greentic.generated-secrets.v1"
338                && let Some(CborValue::Map(inline)) =
339                    map.get(&CborValue::Text("inline".to_string()))
340                && let Some(CborValue::Array(secrets)) =
341                    inline.get(&CborValue::Text("secrets".to_string()))
342            {
343                for secret in secrets {
344                    if let CborValue::Map(secret_map) = secret
345                        && let Some(generated) = parse_generated_secret_map(secret_map)
346                    {
347                        out.push(generated);
348                    }
349                }
350            }
351            for value in map.values() {
352                collect_generated_secrets_from_cbor(value, out);
353            }
354        }
355        _ => {}
356    }
357}
358
359fn parse_generated_secret_map(map: &BTreeMap<CborValue, CborValue>) -> Option<GeneratedSecret> {
360    let get = |key: &str| map.get(&CborValue::Text(key.to_string()));
361    let key = match get("key") {
362        Some(CborValue::Text(value)) => value.clone(),
363        _ => return None,
364    };
365    let required = match get("required") {
366        Some(CborValue::Bool(value)) => *value,
367        _ => true,
368    };
369    let policy = match get("policy") {
370        Some(CborValue::Text(value)) => value.clone(),
371        _ => "random".to_string(),
372    };
373    let length = match get("length") {
374        Some(CborValue::Integer(value)) if *value > 0 => *value as usize,
375        _ => 32,
376    };
377    let encoding = match get("encoding") {
378        Some(CborValue::Text(value)) => value.clone(),
379        _ => "raw_text".to_string(),
380    };
381    let regenerate_if_present = matches!(get("regenerate_if_present"), Some(CborValue::Bool(true)));
382    let scope_team = match get("scope") {
383        Some(CborValue::Map(scope)) => match scope.get(&CborValue::Text("team".to_string())) {
384            Some(CborValue::Text(team)) => Some(team.clone()),
385            _ => None,
386        },
387        _ => None,
388    };
389    Some(GeneratedSecret {
390        key,
391        required,
392        policy,
393        length,
394        encoding,
395        regenerate_if_present,
396        scope_team,
397    })
398}
399
400/// Produce a value for a host-generated secret. `random` policy fills the value
401/// with cryptographically-random bytes encoded per `encoding`; any other policy
402/// is treated as `random` (the only strategy defined today).
403pub fn generate_secret_value(_policy: &str, length: usize, encoding: &str) -> String {
404    use base64::Engine as _;
405    use rand::Rng as _;
406
407    let count = if length == 0 { 32 } else { length };
408    let mut bytes = vec![0u8; count];
409    rand::rng().fill_bytes(&mut bytes);
410    match encoding {
411        "base64url" => base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes),
412        "hex" => bytes.iter().map(|byte| format!("{byte:02x}")).collect(),
413        // `raw_text` (default): map each random byte to a URL-safe alphanumeric
414        // char so the value is `length` printable characters.
415        _ => {
416            const CHARSET: &[u8] =
417                b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
418            bytes
419                .iter()
420                .map(|byte| CHARSET[(*byte as usize) % CHARSET.len()] as char)
421                .collect()
422        }
423    }
424}
425
426fn dedup_requirements(reqs: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
427    let mut by_key = BTreeMap::new();
428    for req in reqs {
429        by_key.entry(req.key.clone()).or_insert(req);
430    }
431    by_key.into_values().collect()
432}
433
434fn collect_secret_requirements_from_cbor(value: &CborValue, out: &mut Vec<PackSecretRequirement>) {
435    match value {
436        CborValue::Array(values) => {
437            for value in values {
438                collect_secret_requirements_from_cbor(value, out);
439            }
440        }
441        CborValue::Map(map) => {
442            if let Some(req) = parse_secret_requirement_map(map) {
443                out.push(req);
444            }
445            for value in map.values() {
446                collect_secret_requirements_from_cbor(value, out);
447            }
448        }
449        _ => {}
450    }
451}
452
453fn parse_secret_requirement_map(
454    map: &BTreeMap<CborValue, CborValue>,
455) -> Option<PackSecretRequirement> {
456    let key = map_get_text(map, "key")?;
457    let has_secret_shape = map.contains_key(&CborValue::Text("required".to_string()))
458        || map.contains_key(&CborValue::Text("scope".to_string()))
459        || map.contains_key(&CborValue::Text("format".to_string()))
460        || map.contains_key(&CborValue::Text("description".to_string()));
461    if !has_secret_shape {
462        return None;
463    }
464    Some(PackSecretRequirement {
465        key,
466        required: map_get_bool(map, "required").unwrap_or(true),
467        description: map_get_text(map, "description"),
468    })
469}
470
471fn map_get_text(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<String> {
472    map.get(&CborValue::Text(key.to_string()))
473        .and_then(|value| match value {
474            CborValue::Text(text) => Some(text.clone()),
475            _ => None,
476        })
477}
478
479fn map_get_bool(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<bool> {
480    map.get(&CborValue::Text(key.to_string()))
481        .and_then(|value| match value {
482            CborValue::Bool(flag) => Some(*flag),
483            _ => None,
484        })
485}
486
487/// Open a `DevStore` from a bundle root path (convenience).
488pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
489    let store_path = ensure_path(bundle_root)?;
490    DevStore::with_path(&store_path).map_err(|err| {
491        anyhow!(
492            "failed to open dev secrets store {}: {err}",
493            store_path.display()
494        )
495    })
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use std::io::Write;
502    use zip::write::SimpleFileOptions;
503
504    fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
505        let file = std::fs::File::create(path)?;
506        let mut zip = zip::ZipWriter::new(file);
507        zip.start_file(
508            "assets/secret-requirements.json",
509            SimpleFileOptions::default(),
510        )?;
511        zip.write_all(req_json.as_bytes())?;
512        zip.finish()?;
513        Ok(())
514    }
515
516    #[test]
517    fn ensure_path_creates_parent_directories() {
518        let temp = tempfile::tempdir().expect("tempdir");
519        let bundle = temp.path().join("bundle");
520        std::fs::create_dir_all(&bundle).expect("bundle dir");
521        let path = ensure_path(&bundle).expect("ensure path");
522        assert!(path.ends_with(".greentic/dev/.dev.secrets.env"));
523        assert!(path.parent().expect("parent").exists());
524    }
525
526    #[test]
527    fn loads_generated_secrets_from_manifest_cbor() {
528        use serde_cbor::Value as C;
529        let entry = |k: &str, v: C| (C::Text(k.to_string()), v);
530        let secret = C::Map(BTreeMap::from([
531            entry("key", C::Text("jwt_signing_key".into())),
532            entry("required", C::Bool(true)),
533            entry("policy", C::Text("random".into())),
534            entry("length", C::Integer(20)),
535            entry("encoding", C::Text("raw_text".into())),
536            entry("regenerate_if_present", C::Bool(false)),
537            entry(
538                "scope",
539                C::Map(BTreeMap::from([entry("team", C::Text("_".into()))])),
540            ),
541        ]));
542        let ext = C::Map(BTreeMap::from([
543            entry("kind", C::Text("greentic.generated-secrets.v1".into())),
544            entry(
545                "inline",
546                C::Map(BTreeMap::from([entry("secrets", C::Array(vec![secret]))])),
547            ),
548        ]));
549        let manifest = C::Map(BTreeMap::from([entry("extensions", C::Array(vec![ext]))]));
550        let bytes = serde_cbor::to_vec(&manifest).expect("encode manifest");
551
552        let temp = tempfile::tempdir().expect("tempdir");
553        let pack = temp.path().join("p.gtpack");
554        let file = std::fs::File::create(&pack).expect("create pack");
555        let mut zip = zip::ZipWriter::new(file);
556        zip.start_file("manifest.cbor", SimpleFileOptions::default())
557            .expect("start file");
558        zip.write_all(&bytes).expect("write");
559        zip.finish().expect("finish");
560
561        let secrets = load_generated_secrets_from_pack(&pack).expect("load");
562        assert_eq!(secrets.len(), 1);
563        let secret = &secrets[0];
564        assert_eq!(secret.key, "jwt_signing_key");
565        assert!(secret.required);
566        assert_eq!(secret.policy, "random");
567        assert_eq!(secret.length, 20);
568        assert_eq!(secret.encoding, "raw_text");
569        assert!(!secret.regenerate_if_present);
570        assert_eq!(secret.scope_team.as_deref(), Some("_"));
571    }
572
573    #[test]
574    fn generate_secret_value_respects_length_and_encoding() {
575        let raw = generate_secret_value("random", 20, "raw_text");
576        assert_eq!(raw.len(), 20);
577        assert!(raw.chars().all(|c| c.is_ascii_alphanumeric()));
578
579        let hex = generate_secret_value("random", 16, "hex");
580        assert_eq!(hex.len(), 32); // 16 random bytes → 32 hex chars
581        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
582
583        assert!(!generate_secret_value("random", 24, "base64url").is_empty());
584        // Cryptographic randomness: two draws must differ.
585        assert_ne!(
586            generate_secret_value("random", 32, "raw_text"),
587            generate_secret_value("random", 32, "raw_text")
588        );
589    }
590
591    #[test]
592    fn find_existing_with_override_prefers_override() {
593        let temp = tempfile::tempdir().expect("tempdir");
594        let bundle = temp.path().join("bundle");
595        std::fs::create_dir_all(&bundle).expect("bundle dir");
596        let override_file = temp.path().join("custom.env");
597        std::fs::write(&override_file, "KEY=value\n").expect("write override");
598
599        let found = find_existing_with_override(&bundle, Some(&override_file));
600        assert_eq!(found.as_deref(), Some(override_file.as_path()));
601    }
602
603    #[test]
604    fn find_existing_finds_default_locations() {
605        let temp = tempfile::tempdir().expect("tempdir");
606        let bundle = temp.path().join("bundle");
607        let store_path = bundle.join(STORE_RELATIVE);
608        std::fs::create_dir_all(store_path.parent().expect("parent")).expect("create dirs");
609        std::fs::write(&store_path, "K=V\n").expect("write store");
610
611        let found = find_existing_with_override(&bundle, None).expect("found");
612        assert_eq!(found, store_path);
613    }
614
615    #[test]
616    fn load_secret_keys_from_pack_reads_requirements() {
617        let temp = tempfile::tempdir().expect("tempdir");
618        let pack = temp.path().join("provider.gtpack");
619        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
620            .expect("write pack");
621
622        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
623        assert_eq!(
624            keys,
625            vec!["API_SECRET".to_string(), "BOT_TOKEN".to_string()]
626        );
627    }
628
629    #[test]
630    fn load_secret_keys_from_pack_reads_cbor_manifest_requirements() {
631        let temp = tempfile::tempdir().expect("tempdir");
632        let pack = temp.path().join("provider.gtpack");
633        let file = std::fs::File::create(&pack).expect("create pack");
634        let mut zip = zip::ZipWriter::new(file);
635        zip.start_file("manifest.cbor", SimpleFileOptions::default())
636            .expect("start entry");
637        let manifest = serde_json::json!({
638            "components": [
639                {
640                    "host": {
641                        "secrets": {
642                            "required": [
643                                {
644                                    "key": "auth.param.get_weather.key",
645                                    "required": true,
646                                    "description": "Weather key",
647                                    "scope": {"env": "runtime", "tenant": "runtime"},
648                                    "format": "text"
649                                }
650                            ]
651                        }
652                    }
653                }
654            ]
655        });
656        let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
657        zip.write_all(&bytes).expect("write manifest");
658        zip.finish().expect("finish zip");
659
660        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
661        assert_eq!(keys, vec!["auth.param.get_weather.key".to_string()]);
662
663        let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
664        assert_eq!(reqs.len(), 1);
665        assert_eq!(reqs[0].description.as_deref(), Some("Weather key"));
666    }
667
668    #[test]
669    fn load_secret_keys_from_pack_returns_empty_without_requirements() {
670        let temp = tempfile::tempdir().expect("tempdir");
671        let pack = temp.path().join("provider.gtpack");
672        let file = std::fs::File::create(&pack).expect("create pack");
673        let mut zip = zip::ZipWriter::new(file);
674        zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
675            .expect("start entry");
676        zip.write_all(b"questions: []\n").expect("write setup");
677        zip.finish().expect("finish zip");
678
679        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
680        assert!(keys.is_empty());
681    }
682
683    #[tokio::test]
684    async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
685        let temp = tempfile::tempdir().expect("tempdir");
686        let bundle = temp.path().join("bundle");
687        std::fs::create_dir_all(&bundle).expect("bundle dir");
688        let pack = temp.path().join("provider.gtpack");
689        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
690
691        let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
692        setup
693            .ensure_pack_secrets(&pack, "messaging-telegram")
694            .await
695            .expect("ensure secrets");
696
697        let uri = canonical_secret_uri(
698            "dev",
699            "tenant-a",
700            Some("core"),
701            "messaging-telegram",
702            "BOT_TOKEN",
703        );
704        let value = setup.store().get(&uri).await.expect("seeded value");
705        let value = String::from_utf8(value).expect("utf8");
706        assert!(
707            value.contains("placeholder for secrets://"),
708            "unexpected placeholder value: {value}"
709        );
710    }
711
712    #[tokio::test]
713    async fn ensure_pack_secrets_uses_seed_values_when_available() {
714        let temp = tempfile::tempdir().expect("tempdir");
715        let bundle = temp.path().join("bundle");
716        std::fs::create_dir_all(&bundle).expect("bundle dir");
717        let seed_uri = canonical_secret_uri(
718            "dev",
719            "tenant-a",
720            Some("core"),
721            "messaging-telegram",
722            "BOT_TOKEN",
723        );
724        let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
725            entries: vec![SeedEntry {
726                uri: seed_uri.clone(),
727                format: SecretFormat::Text,
728                value: SeedValue::Text {
729                    text: "seeded-secret".to_string(),
730                },
731                description: Some("test seed".to_string()),
732            }],
733        })
734        .expect("serialize seeds");
735        std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
736
737        let pack = temp.path().join("provider.gtpack");
738        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
739
740        let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
741        setup
742            .ensure_pack_secrets(&pack, "messaging-telegram")
743            .await
744            .expect("ensure secrets");
745
746        let value = setup.store().get(&seed_uri).await.expect("seeded value");
747        let value = String::from_utf8(value).expect("utf8");
748        assert_eq!(value, "seeded-secret");
749    }
750}