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/// Dev-store path inside the shared environment store:
32///   `~/.greentic/environments/<env>/.greentic/dev/.dev.secrets.env`
33///
34/// This is the *same* file that `gtc op secrets` / `provider add` and the
35/// running env use, so bundle-path `setup`/`start` rendezvous with the env-path
36/// secrets across invocations regardless of any ephemeral extraction dir (it
37/// closes the bundle-vs-env split documented in `env-runtime-bundle-ops.md` §6).
38/// Returns `None` when the environment-store root can't be resolved (no
39/// `HOME`/`USERPROFILE`), letting callers fall back to a bundle-local path.
40pub fn env_store_dev_secrets_path(env: &str) -> Option<PathBuf> {
41    greentic_deployer::environment::LocalFsStore::default_root()
42        .map(|root| root.join(env).join(STORE_RELATIVE))
43}
44
45/// The explicitly-selected environment, or `None` when `$GREENTIC_ENV` is unset.
46///
47/// Bare callers (`ensure_path`/`find_existing`/`default_path`) only route to the
48/// shared env store when an env is *explicitly selected* via `$GREENTIC_ENV`.
49/// `gtc setup` and `gtc start` always set it before resolving secrets, so
50/// production lands on `~/.greentic/environments/<env>/…`; when it is unset
51/// (unit tests, ad-hoc library use) resolution stays on the legacy bundle-local
52/// store, which keeps those paths hermetic and non-polluting.
53fn selected_env() -> Option<String> {
54    std::env::var("GREENTIC_ENV")
55        .ok()
56        .filter(|value| !value.trim().is_empty())
57        .map(|raw| crate::resolve_env(Some(&raw)))
58}
59
60/// The path a *write* should target for an explicit env: the shared env store
61/// when resolvable, otherwise the legacy bundle-local path.
62fn write_path_for_env(bundle_root: &Path, env: &str) -> PathBuf {
63    env_store_dev_secrets_path(env).unwrap_or_else(|| bundle_root.join(STORE_RELATIVE))
64}
65
66/// The write path for a bare caller: the shared env store when an env is
67/// selected via `$GREENTIC_ENV`, else the legacy bundle-local path.
68fn bare_write_path(bundle_root: &Path) -> PathBuf {
69    selected_env()
70        .and_then(|env| env_store_dev_secrets_path(&env))
71        .unwrap_or_else(|| bundle_root.join(STORE_RELATIVE))
72}
73
74/// Read-preference order: the shared env store (when an env is selected), then
75/// legacy bundle-local candidates (for already-configured bundle directories).
76fn read_candidate_paths(bundle_root: &Path) -> Vec<PathBuf> {
77    let mut out = Vec::new();
78    if let Some(env_store) = selected_env().and_then(|env| env_store_dev_secrets_path(&env)) {
79        out.push(env_store);
80    }
81    out.push(bundle_root.join(STORE_RELATIVE));
82    out.push(bundle_root.join(STORE_STATE_RELATIVE));
83    out
84}
85
86/// Checks for an existing dev store: override, then env store, then bundle-local.
87pub fn find_existing(bundle_root: &Path) -> Option<PathBuf> {
88    find_existing_with_override(bundle_root, override_path().as_deref())
89}
90
91/// Looks for an existing dev store using an override path before consulting the
92/// shared env store and then legacy bundle-local candidates.
93pub fn find_existing_with_override(
94    bundle_root: &Path,
95    override_path: Option<&Path>,
96) -> Option<PathBuf> {
97    if let Some(path) = override_path
98        && path.exists()
99    {
100        return Some(path.to_path_buf());
101    }
102    read_candidate_paths(bundle_root)
103        .into_iter()
104        .find(|candidate| candidate.exists())
105}
106
107/// Ensures the default dev store path exists (creating parent directories) before
108/// returning it. Routes to the shared env store when `$GREENTIC_ENV` is set.
109pub fn ensure_path(bundle_root: &Path) -> Result<PathBuf> {
110    if let Some(path) = override_path() {
111        ensure_parent(&path)?;
112        return Ok(path);
113    }
114    let path = bare_write_path(bundle_root);
115    ensure_parent(&path)?;
116    Ok(path)
117}
118
119/// Like [`ensure_path`], but with an explicit environment — always the shared
120/// env store (when resolvable). The correct key when the caller already resolved
121/// `<env>` (e.g. [`SecretsSetup`]), independent of `$GREENTIC_ENV`.
122pub fn ensure_path_for_env(bundle_root: &Path, env: &str) -> Result<PathBuf> {
123    if let Some(path) = override_path() {
124        ensure_parent(&path)?;
125        return Ok(path);
126    }
127    let path = write_path_for_env(bundle_root, env);
128    ensure_parent(&path)?;
129    Ok(path)
130}
131
132/// Returns the default dev store path without creating anything.
133pub fn default_path(bundle_root: &Path) -> PathBuf {
134    override_path().unwrap_or_else(|| bare_write_path(bundle_root))
135}
136
137fn ensure_parent(path: &Path) -> Result<()> {
138    if let Some(parent) = path.parent() {
139        std::fs::create_dir_all(parent)?;
140    }
141    Ok(())
142}
143
144// ── SecretsSetup ────────────────────────────────────────────────────────────
145
146/// Single entry-point for secrets initialization and resolution.
147///
148/// Opens exactly one dev store per instance and ensures every required secret
149/// discovered from packs is canonicalized and registered.
150pub struct SecretsSetup {
151    store: DevStore,
152    store_path: PathBuf,
153    env: String,
154    tenant: String,
155    team: Option<String>,
156    seeds: HashMap<String, SeedEntry>,
157}
158
159impl SecretsSetup {
160    pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
161        // Env-explicit, NOT `$GREENTIC_ENV`-gated. This is a write path: gating on
162        // the ambient env meant setup persisted into the bundle-local store while
163        // the runtime read the env store, so provisioned secrets went "missing" at
164        // runtime (the whole point of the env-store seam fix). `env` is already a
165        // parameter here — use it.
166        let store_path = ensure_path_for_env(bundle_root, env)?;
167        info!(path = %store_path.display(), "secrets: using dev store backend");
168        let store = DevStore::with_path(&store_path).map_err(|err| {
169            anyhow!(
170                "failed to open dev secrets store {}: {err}",
171                store_path.display()
172            )
173        })?;
174        let seeds = load_seed_entries(bundle_root)?;
175        Ok(Self {
176            store,
177            store_path,
178            env: env.to_string(),
179            tenant: tenant.to_string(),
180            team: team.map(|v| v.to_string()),
181            seeds,
182        })
183    }
184
185    /// Path to the dev store file on disk.
186    pub fn store_path(&self) -> &Path {
187        &self.store_path
188    }
189
190    /// Reference to the underlying `DevStore`.
191    pub fn store(&self) -> &DevStore {
192        &self.store
193    }
194
195    /// Ensure all required secrets for a pack exist in the dev store.
196    ///
197    /// Reads `assets/secret-requirements.json` from the pack and seeds any
198    /// missing keys from `seeds.yaml` or with a placeholder.
199    pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
200        let keys = load_secret_keys_from_pack(pack_path)?;
201        if keys.is_empty() {
202            return Ok(());
203        }
204
205        let mut missing = Vec::new();
206        for key in keys {
207            let uri = canonical_secret_uri(
208                &self.env,
209                &self.tenant,
210                self.team.as_deref(),
211                provider_id,
212                &key,
213            );
214            debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
215            match self.store.get(&uri).await {
216                Ok(_) => continue,
217                Err(SecretError::NotFound { .. }) => {
218                    let source = if self.seeds.contains_key(&uri) {
219                        "seeds.yaml"
220                    } else {
221                        "placeholder"
222                    };
223                    debug!(uri = %uri, source, "seeding missing secret");
224                    missing.push(
225                        self.seeds
226                            .get(&uri)
227                            .cloned()
228                            .unwrap_or_else(|| placeholder_entry(uri)),
229                    );
230                }
231                Err(err) => {
232                    return Err(anyhow!("failed to read secret {uri}: {err}"));
233                }
234            }
235        }
236
237        if missing.is_empty() {
238            return Ok(());
239        }
240        let report = apply_seed(
241            &self.store,
242            &SeedDoc { entries: missing },
243            ApplyOptions::default(),
244        )
245        .await;
246        if !report.failed.is_empty() {
247            return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
248        }
249        Ok(())
250    }
251}
252
253// ── Helpers ─────────────────────────────────────────────────────────────────
254
255fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
256    for candidate in seed_paths(bundle_root) {
257        if candidate.exists() {
258            let contents = std::fs::read_to_string(&candidate)?;
259            let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
260            return Ok(doc
261                .entries
262                .into_iter()
263                .map(|entry| (entry.uri.clone(), entry))
264                .collect());
265        }
266    }
267    Ok(HashMap::new())
268}
269
270fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
271    [
272        bundle_root.join("seeds.yaml"),
273        bundle_root.join("state").join("seeds.yaml"),
274    ]
275}
276
277fn placeholder_entry(uri: String) -> SeedEntry {
278    SeedEntry {
279        uri: uri.clone(),
280        format: SecretFormat::Text,
281        value: SeedValue::Text {
282            text: format!("placeholder for {uri}"),
283        },
284        description: Some("auto-applied placeholder".to_string()),
285    }
286}
287
288/// Load secret requirement keys from a `.gtpack` archive.
289///
290/// Tries `assets/secret-requirements.json` first, then falls back to
291/// CBOR manifest extraction.
292pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
293    Ok(load_secret_requirements_from_pack(pack_path)?
294        .into_iter()
295        .map(|req| req.key)
296        .collect())
297}
298
299/// Rich secret requirements extracted from a `.gtpack` archive.
300pub fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
301    let file = std::fs::File::open(pack_path)?;
302    let mut archive = zip::ZipArchive::new(file)?;
303
304    for entry_name in &[
305        "assets/secret-requirements.json",
306        "assets/secret_requirements.json",
307        "secret-requirements.json",
308        "secret_requirements.json",
309    ] {
310        match archive.by_name(entry_name) {
311            Ok(reader) => {
312                let reqs: Vec<PackSecretRequirement> = serde_json::from_reader(reader)?;
313                return Ok(dedup_requirements(reqs));
314            }
315            Err(zip::result::ZipError::FileNotFound) => continue,
316            Err(err) => return Err(err.into()),
317        }
318    }
319
320    let mut reqs = Vec::new();
321    for index in 0..archive.len() {
322        let name = {
323            let entry = archive.by_index(index)?;
324            entry.name().to_string()
325        };
326        if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
327            continue;
328        }
329        let mut entry = archive.by_name(&name)?;
330        let mut bytes = Vec::new();
331        std::io::Read::read_to_end(&mut entry, &mut bytes)?;
332        let value: CborValue = serde_cbor::from_slice(&bytes)?;
333        collect_secret_requirements_from_cbor(&value, &mut reqs);
334    }
335
336    Ok(dedup_requirements(reqs))
337}
338
339#[derive(Clone, Debug, serde::Deserialize)]
340pub struct PackSecretRequirement {
341    pub key: String,
342    #[serde(default = "default_required")]
343    pub required: bool,
344    #[serde(default)]
345    pub description: Option<String>,
346}
347
348fn default_required() -> bool {
349    true
350}
351
352fn dedup_requirements(reqs: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
353    let mut by_key = BTreeMap::new();
354    for req in reqs {
355        by_key.entry(req.key.clone()).or_insert(req);
356    }
357    by_key.into_values().collect()
358}
359
360fn collect_secret_requirements_from_cbor(value: &CborValue, out: &mut Vec<PackSecretRequirement>) {
361    match value {
362        CborValue::Array(values) => {
363            for value in values {
364                collect_secret_requirements_from_cbor(value, out);
365            }
366        }
367        CborValue::Map(map) => {
368            if let Some(req) = parse_secret_requirement_map(map) {
369                out.push(req);
370            }
371            for value in map.values() {
372                collect_secret_requirements_from_cbor(value, out);
373            }
374        }
375        _ => {}
376    }
377}
378
379fn parse_secret_requirement_map(
380    map: &BTreeMap<CborValue, CborValue>,
381) -> Option<PackSecretRequirement> {
382    let key = map_get_text(map, "key")?;
383    let has_secret_shape = map.contains_key(&CborValue::Text("required".to_string()))
384        || map.contains_key(&CborValue::Text("scope".to_string()))
385        || map.contains_key(&CborValue::Text("format".to_string()))
386        || map.contains_key(&CborValue::Text("description".to_string()));
387    if !has_secret_shape {
388        return None;
389    }
390    Some(PackSecretRequirement {
391        key,
392        required: map_get_bool(map, "required").unwrap_or(true),
393        description: map_get_text(map, "description"),
394    })
395}
396
397fn map_get_text(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<String> {
398    map.get(&CborValue::Text(key.to_string()))
399        .and_then(|value| match value {
400            CborValue::Text(text) => Some(text.clone()),
401            _ => None,
402        })
403}
404
405fn map_get_bool(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<bool> {
406    map.get(&CborValue::Text(key.to_string()))
407        .and_then(|value| match value {
408            CborValue::Bool(flag) => Some(*flag),
409            _ => None,
410        })
411}
412
413/// Open a `DevStore` keyed to an explicit `env` — ALWAYS the shared env store,
414/// the exact file the greentic-start serve path reads. Use this on WRITE paths
415/// that already know the env, so a setup-provisioned secret lands where the
416/// runtime looks, never in the `$GREENTIC_ENV`-gated bundle-local store.
417pub fn open_dev_store_for_env(bundle_root: &Path, env: &str) -> Result<DevStore> {
418    let store_path = ensure_path_for_env(bundle_root, env)?;
419    DevStore::with_path(&store_path).map_err(|err| {
420        anyhow!(
421            "failed to open dev secrets store {}: {err}",
422            store_path.display()
423        )
424    })
425}
426
427/// Open a `DevStore` from a bundle root path (convenience). Prefer
428/// [`open_dev_store_for_env`] on write paths — this bare form gates on
429/// `$GREENTIC_ENV` and can diverge from the serve reader.
430pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
431    let store_path = ensure_path(bundle_root)?;
432    DevStore::with_path(&store_path).map_err(|err| {
433        anyhow!(
434            "failed to open dev secrets store {}: {err}",
435            store_path.display()
436        )
437    })
438}
439
440/// Test-only isolation for the dev secrets store.
441///
442/// Needed because the write path deliberately resolves to the SHARED env store
443/// (`~/.greentic/environments/<env>/…` via `LocalFsStore::default_root()`), which
444/// is the whole point of the env-store seam — setup and the runtime must meet in
445/// one file. In tests that means store writes escape the temp dir and land in the
446/// developer's real `~/.greentic`, so tests pollute real state and race each
447/// other over one file.
448///
449/// `GREENTIC_DEV_SECRETS_PATH` is consulted before any other resolution, so
450/// pointing it at a temp path isolates a test completely. It is process-global,
451/// so acquiring it also serialises the tests that use it.
452#[cfg(test)]
453pub(crate) mod test_support {
454    use std::path::Path;
455    use std::sync::{Mutex, MutexGuard, OnceLock};
456
457    /// THE process-wide env lock for this crate's tests.
458    ///
459    /// Must be the ONLY such lock: `lib.rs` previously kept its own `ENV_LOCK`
460    /// for `GREENTIC_ENV`/`GREENTIC_DISABLE_DEV_ALIAS`, so its tests mutated
461    /// those vars under one mutex while secrets tests read them under another —
462    /// two locks give no mutual exclusion, which is exactly how the store tests
463    /// raced. Everything that touches process-global env in tests takes this.
464    pub(crate) fn env_lock() -> MutexGuard<'static, ()> {
465        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
466        LOCK.get_or_init(|| Mutex::new(()))
467            .lock()
468            .unwrap_or_else(|poisoned| poisoned.into_inner())
469    }
470
471    /// Points the dev-store override at a path for as long as it is held, then
472    /// restores whatever was there before. Hold it for the whole test body.
473    pub(crate) struct StoreOverride {
474        _guard: MutexGuard<'static, ()>,
475        previous: Option<String>,
476    }
477
478    impl StoreOverride {
479        pub(crate) fn at(path: &Path) -> Self {
480            let guard = env_lock();
481            let previous = std::env::var(super::OVERRIDE_ENV).ok();
482            // SAFETY: the process-global env is mutated only while holding
483            // `env_lock`, so no other test observes a torn value, and Drop
484            // restores the prior state.
485            unsafe { std::env::set_var(super::OVERRIDE_ENV, path) };
486            Self {
487                _guard: guard,
488                previous,
489            }
490        }
491
492        /// Isolate inside `dir`, using the conventional store filename.
493        pub(crate) fn in_dir(dir: &Path) -> Self {
494            Self::at(&dir.join(".dev.secrets.env"))
495        }
496    }
497
498    /// Serialises against [`StoreOverride`] WITHOUT changing anything.
499    ///
500    /// Needed by tests that assert path resolution in its natural state: they
501    /// read the same process-global var others set, so they must hold the lock or
502    /// they observe another test's override. Because `StoreOverride` restores the
503    /// previous value in `Drop` before releasing the lock, holding it here
504    /// guarantees the var is back to its pre-test state.
505    pub(crate) struct EnvLock(#[allow(dead_code)] MutexGuard<'static, ()>);
506
507    pub(crate) fn lock_env() -> EnvLock {
508        EnvLock(env_lock())
509    }
510
511    impl Drop for StoreOverride {
512        fn drop(&mut self) {
513            // SAFETY: still holding `env_lock` (dropped after this).
514            match self.previous.take() {
515                Some(value) => unsafe { std::env::set_var(super::OVERRIDE_ENV, value) },
516                None => unsafe { std::env::remove_var(super::OVERRIDE_ENV) },
517            }
518        }
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use std::io::Write;
526    use zip::write::SimpleFileOptions;
527
528    fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
529        let file = std::fs::File::create(path)?;
530        let mut zip = zip::ZipWriter::new(file);
531        zip.start_file(
532            "assets/secret-requirements.json",
533            SimpleFileOptions::default(),
534        )?;
535        zip.write_all(req_json.as_bytes())?;
536        zip.finish()?;
537        Ok(())
538    }
539
540    // NOTE on hermeticity: the dev-store resolvers read process-global env
541    // (`$GREENTIC_ENV`, `$GREENTIC_DEV_SECRETS_PATH`) and the home dir, which
542    // other tests in this crate also mutate. To stay race-free these unit tests
543    // avoid asserting on that global state; the shared-env-store *activation*
544    // path is covered end-to-end by the real-binary round-trip instead.
545
546    #[test]
547    fn env_store_path_has_expected_shape() {
548        // `env_store_dev_secrets_path(env)` lays out
549        // <home>/.greentic/environments/<env>/.greentic/dev/.dev.secrets.env.
550        // Assert the suffix, which is independent of the home value (race-free).
551        if let Some(path) = env_store_dev_secrets_path("some-env") {
552            assert!(
553                path.ends_with("environments/some-env/.greentic/dev/.dev.secrets.env"),
554                "unexpected env-store path: {}",
555                path.display()
556            );
557        }
558    }
559
560    /// Creates `<bundle>/.greentic/dev/.dev.secrets.env` and returns its path.
561    fn write_bundle_local_store(bundle: &Path) -> PathBuf {
562        let path = bundle.join(STORE_RELATIVE);
563        std::fs::create_dir_all(path.parent().expect("store parent")).expect("store dir");
564        std::fs::write(&path, "KEY=value\n").expect("write store");
565        path
566    }
567
568    #[test]
569    fn write_path_for_env_and_default_path_target_the_dev_store() {
570        let _env_lock = crate::secrets::test_support::lock_env();
571        // Both resolvers land on a `.dev.secrets.env` dev store, whether they
572        // route to the shared env store (home resolvable) or fall back to the
573        // bundle-local path. Assert the shared suffix — it holds either way and
574        // is independent of process-global `$GREENTIC_ENV` (race-free).
575        let temp = tempfile::tempdir().expect("tempdir");
576        let bundle = temp.path().join("bundle");
577
578        let write = write_path_for_env(&bundle, "some-env");
579        assert!(
580            write.ends_with(STORE_RELATIVE),
581            "unexpected write path: {}",
582            write.display()
583        );
584
585        let default = default_path(&bundle);
586        assert!(
587            default.ends_with(STORE_RELATIVE),
588            "unexpected default path: {}",
589            default.display()
590        );
591    }
592
593    /// Guards the webex_bot_token seam: setup once wrote the bundle-local store
594    /// while the runtime read the env store, so the secret went "missing". An
595    /// env-explicit WRITE must target the shared env store and never the
596    /// bundle-local one — independent of `$GREENTIC_ENV` (race-free: env is
597    /// passed explicitly). Do NOT weaken without a new secrets plan.
598    #[test]
599    fn write_path_for_env_targets_the_env_store_not_bundle_local() {
600        let temp = tempfile::tempdir().expect("tempdir");
601        let bundle = temp.path().join("bundle");
602        let Some(env_store) = env_store_dev_secrets_path("guard-env") else {
603            return; // no HOME → covered by the real-binary round-trip instead
604        };
605        assert_eq!(
606            write_path_for_env(&bundle, "guard-env"),
607            env_store,
608            "env-explicit write must target the env store",
609        );
610        assert!(
611            !write_path_for_env(&bundle, "guard-env").starts_with(&bundle),
612            "env-explicit write must never land in the bundle-local store",
613        );
614    }
615
616    #[test]
617    fn find_existing_locates_a_bundle_local_dev_store() {
618        // Serialise: these assert path resolution in its natural state and
619        // read the same process-global override other tests set.
620        let _env_lock = test_support::lock_env();
621        // With no `$GREENTIC_DEV_SECRETS_PATH` override, `find_existing` walks
622        // the candidate list and returns an existing dev store. Hermetic: the
623        // store lives in a tempdir, so the result exists and carries the dev
624        // store suffix regardless of `$GREENTIC_ENV`.
625        let temp = tempfile::tempdir().expect("tempdir");
626        let bundle = temp.path().join("bundle");
627        let store = bundle.join(STORE_RELATIVE);
628        std::fs::create_dir_all(store.parent().expect("store parent")).expect("dev dir");
629        std::fs::write(&store, "KEY=value\n").expect("write store");
630
631        let found = find_existing(&bundle).expect("find dev store");
632        assert!(
633            found.exists(),
634            "found path should exist: {}",
635            found.display()
636        );
637        assert!(
638            found.ends_with(STORE_RELATIVE),
639            "unexpected found path: {}",
640            found.display()
641        );
642    }
643
644    #[test]
645    fn find_existing_with_override_prefers_override() {
646        // Serialise: these assert path resolution in its natural state and
647        // read the same process-global override other tests set.
648        let _env_lock = test_support::lock_env();
649        let temp = tempfile::tempdir().expect("tempdir");
650        let bundle = temp.path().join("bundle");
651        std::fs::create_dir_all(&bundle).expect("bundle dir");
652        let override_file = temp.path().join("custom.env");
653        std::fs::write(&override_file, "KEY=value\n").expect("write override");
654
655        let found = find_existing_with_override(&bundle, Some(&override_file));
656        assert_eq!(found.as_deref(), Some(override_file.as_path()));
657    }
658
659    #[test]
660    fn find_existing_with_override_falls_back_when_override_is_absent() {
661        // Serialise: these assert path resolution in its natural state and
662        // read the same process-global override other tests set.
663        let _env_lock = test_support::lock_env();
664        let temp = tempfile::tempdir().expect("tempdir");
665        let bundle = temp.path().join("bundle");
666        std::fs::create_dir_all(&bundle).expect("bundle dir");
667        write_bundle_local_store(&bundle);
668        let missing_override = temp.path().join("does-not-exist.env");
669
670        // A non-existent override must not short-circuit the candidate walk.
671        // The concrete winner depends on `$GREENTIC_ENV`/home (process-global,
672        // mutated by other tests), so assert only what holds for every
673        // candidate: it resolves to an existing dev-store file.
674        for override_arg in [None, Some(missing_override.as_path())] {
675            let found = find_existing_with_override(&bundle, override_arg)
676                .expect("existing store must be discovered");
677            assert!(found.exists(), "resolved store does not exist: {found:?}");
678            assert!(
679                found.ends_with(".dev.secrets.env"),
680                "unexpected store file: {found:?}"
681            );
682        }
683    }
684
685    #[test]
686    fn find_existing_discovers_the_bundle_local_store() {
687        // Serialise: these assert path resolution in its natural state and
688        // read the same process-global override other tests set.
689        let _env_lock = test_support::lock_env();
690        let temp = tempfile::tempdir().expect("tempdir");
691        let bundle = temp.path().join("bundle");
692        std::fs::create_dir_all(&bundle).expect("bundle dir");
693        write_bundle_local_store(&bundle);
694
695        let found = find_existing(&bundle).expect("existing store must be discovered");
696        assert!(found.exists(), "resolved store does not exist: {found:?}");
697    }
698
699    #[test]
700    fn read_candidate_paths_ends_with_bundle_local_candidates() {
701        // Serialise: these assert path resolution in its natural state and
702        // read the same process-global override other tests set.
703        let _env_lock = test_support::lock_env();
704        let temp = tempfile::tempdir().expect("tempdir");
705        let bundle = temp.path().join("bundle");
706
707        let candidates = read_candidate_paths(&bundle);
708        // The optional leading entry is the shared env store, which only
709        // appears when `$GREENTIC_ENV` is set; the bundle-local pair is always
710        // present, in order, at the end.
711        assert!(
712            candidates.len() == 2 || candidates.len() == 3,
713            "unexpected candidates: {candidates:?}"
714        );
715        assert_eq!(
716            &candidates[candidates.len() - 2..],
717            &[
718                bundle.join(STORE_RELATIVE),
719                bundle.join(STORE_STATE_RELATIVE)
720            ]
721        );
722    }
723
724    #[test]
725    fn write_path_for_env_always_targets_a_dev_store_file() {
726        // Serialise: these assert path resolution in its natural state and
727        // read the same process-global override other tests set.
728        let _env_lock = test_support::lock_env();
729        let temp = tempfile::tempdir().expect("tempdir");
730        let bundle = temp.path().join("bundle");
731
732        // Both branches (shared env store / bundle-local fallback) end in the
733        // same relative store path, so the suffix is home-independent.
734        let path = write_path_for_env(&bundle, "some-env");
735        assert!(
736            path.ends_with(STORE_RELATIVE),
737            "unexpected write path: {}",
738            path.display()
739        );
740    }
741
742    #[test]
743    fn default_path_creates_nothing() {
744        // Serialise: these assert path resolution in its natural state and
745        // read the same process-global override other tests set.
746        let _env_lock = test_support::lock_env();
747        let temp = tempfile::tempdir().expect("tempdir");
748        let bundle = temp.path().join("bundle");
749
750        let path = default_path(&bundle);
751        assert!(
752            path.ends_with(".dev.secrets.env"),
753            "unexpected default path: {}",
754            path.display()
755        );
756        assert!(
757            !bundle.exists(),
758            "default_path must not touch the filesystem"
759        );
760    }
761
762    #[test]
763    fn load_secret_keys_from_pack_reads_requirements() {
764        let temp = tempfile::tempdir().expect("tempdir");
765        let pack = temp.path().join("provider.gtpack");
766        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
767            .expect("write pack");
768
769        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
770        assert_eq!(
771            keys,
772            vec!["API_SECRET".to_string(), "BOT_TOKEN".to_string()]
773        );
774    }
775
776    #[test]
777    fn load_secret_keys_from_pack_reads_cbor_manifest_requirements() {
778        let temp = tempfile::tempdir().expect("tempdir");
779        let pack = temp.path().join("provider.gtpack");
780        let file = std::fs::File::create(&pack).expect("create pack");
781        let mut zip = zip::ZipWriter::new(file);
782        zip.start_file("manifest.cbor", SimpleFileOptions::default())
783            .expect("start entry");
784        let manifest = serde_json::json!({
785            "components": [
786                {
787                    "host": {
788                        "secrets": {
789                            "required": [
790                                {
791                                    "key": "auth.param.get_weather.key",
792                                    "required": true,
793                                    "description": "Weather key",
794                                    "scope": {"env": "runtime", "tenant": "runtime"},
795                                    "format": "text"
796                                }
797                            ]
798                        }
799                    }
800                }
801            ]
802        });
803        let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
804        zip.write_all(&bytes).expect("write manifest");
805        zip.finish().expect("finish zip");
806
807        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
808        assert_eq!(keys, vec!["auth.param.get_weather.key".to_string()]);
809
810        let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
811        assert_eq!(reqs.len(), 1);
812        assert_eq!(reqs[0].description.as_deref(), Some("Weather key"));
813    }
814
815    #[test]
816    fn load_secret_keys_from_pack_returns_empty_without_requirements() {
817        let temp = tempfile::tempdir().expect("tempdir");
818        let pack = temp.path().join("provider.gtpack");
819        let file = std::fs::File::create(&pack).expect("create pack");
820        let mut zip = zip::ZipWriter::new(file);
821        zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
822            .expect("start entry");
823        zip.write_all(b"questions: []\n").expect("write setup");
824        zip.finish().expect("finish zip");
825
826        let keys = load_secret_keys_from_pack(&pack).expect("load keys");
827        assert!(keys.is_empty());
828    }
829
830    #[test]
831    fn cbor_manifest_scan_skips_maps_that_are_not_secret_requirements() {
832        let temp = tempfile::tempdir().expect("tempdir");
833        let pack = temp.path().join("provider.gtpack");
834        let file = std::fs::File::create(&pack).expect("create pack");
835        let mut zip = zip::ZipWriter::new(file);
836        zip.start_file("manifest.cbor", SimpleFileOptions::default())
837            .expect("start entry");
838        let manifest = serde_json::json!({
839            // Has a textual `key` but none of the secret-shaped companion
840            // fields — must not be mistaken for a secret requirement.
841            "routes": [{"key": "not-a-secret", "target": "node-a"}],
842            // `key` is not text, so the map is rejected outright.
843            "indexed": [{"key": 7, "required": true}],
844            // Secret-shaped, but `required` is not a bool: defaults to true.
845            "secrets": [{"key": "loose.secret", "required": "yes"}],
846        });
847        let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
848        zip.write_all(&bytes).expect("write manifest");
849        zip.finish().expect("finish zip");
850
851        let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
852        assert_eq!(reqs.len(), 1, "unexpected requirements: {reqs:?}");
853        assert_eq!(reqs[0].key, "loose.secret");
854        assert!(reqs[0].required, "non-bool `required` must default to true");
855        assert!(reqs[0].description.is_none());
856    }
857
858    #[tokio::test]
859    async fn ensure_pack_secrets_is_a_no_op_without_requirements() {
860        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
861        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
862        let temp = tempfile::tempdir().expect("tempdir");
863        let bundle = temp.path().join("bundle");
864        std::fs::create_dir_all(&bundle).expect("bundle dir");
865        let pack = temp.path().join("provider.gtpack");
866        write_pack_with_secret_requirements(&pack, "[]").expect("pack");
867
868        let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
869        setup
870            .ensure_pack_secrets(&pack, "messaging-telegram")
871            .await
872            .expect("ensure secrets");
873
874        assert!(
875            setup
876                .store_path()
877                .parent()
878                .is_some_and(|parent| parent.is_dir()),
879            "store parent must be created: {}",
880            setup.store_path().display()
881        );
882        assert!(
883            setup.store_path().ends_with(".dev.secrets.env"),
884            "unexpected store path: {}",
885            setup.store_path().display()
886        );
887    }
888
889    #[tokio::test]
890    async fn ensure_pack_secrets_is_a_noop_without_requirements() {
891        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
892        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
893        let temp = tempfile::tempdir().expect("tempdir");
894        let bundle = temp.path().join("bundle");
895        std::fs::create_dir_all(&bundle).expect("bundle dir");
896
897        // A pack with no secret-requirements → `ensure_pack_secrets` short-circuits.
898        let pack = temp.path().join("provider.gtpack");
899        let file = std::fs::File::create(&pack).expect("create pack");
900        let mut zip = zip::ZipWriter::new(file);
901        zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
902            .expect("start entry");
903        zip.write_all(b"questions: []\n").expect("write setup");
904        zip.finish().expect("finish zip");
905
906        let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", None).expect("setup");
907        // The accessor resolves to the opened dev store file.
908        assert!(setup.store_path().ends_with(".dev.secrets.env"));
909
910        setup
911            .ensure_pack_secrets(&pack, "messaging-telegram")
912            .await
913            .expect("noop for empty requirements");
914    }
915
916    #[tokio::test]
917    async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
918        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
919        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
920        let temp = tempfile::tempdir().expect("tempdir");
921        let bundle = temp.path().join("bundle");
922        std::fs::create_dir_all(&bundle).expect("bundle dir");
923        let pack = temp.path().join("provider.gtpack");
924        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
925
926        let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
927        setup
928            .ensure_pack_secrets(&pack, "messaging-telegram")
929            .await
930            .expect("ensure secrets");
931
932        let uri = canonical_secret_uri(
933            "dev",
934            "tenant-a",
935            Some("core"),
936            "messaging-telegram",
937            "BOT_TOKEN",
938        );
939        let value = setup.store().get(&uri).await.expect("seeded value");
940        let value = String::from_utf8(value).expect("utf8");
941        assert!(
942            value.contains("placeholder for secrets://"),
943            "unexpected placeholder value: {value}"
944        );
945    }
946
947    #[tokio::test]
948    async fn ensure_pack_secrets_uses_seed_values_when_available() {
949        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
950        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
951        let temp = tempfile::tempdir().expect("tempdir");
952        let bundle = temp.path().join("bundle");
953        std::fs::create_dir_all(&bundle).expect("bundle dir");
954        let seed_uri = canonical_secret_uri(
955            "dev",
956            "tenant-a",
957            Some("core"),
958            "messaging-telegram",
959            "BOT_TOKEN",
960        );
961        let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
962            entries: vec![SeedEntry {
963                uri: seed_uri.clone(),
964                format: SecretFormat::Text,
965                value: SeedValue::Text {
966                    text: "seeded-secret".to_string(),
967                },
968                description: Some("test seed".to_string()),
969            }],
970        })
971        .expect("serialize seeds");
972        std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
973
974        let pack = temp.path().join("provider.gtpack");
975        write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
976
977        let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
978        setup
979            .ensure_pack_secrets(&pack, "messaging-telegram")
980            .await
981            .expect("ensure secrets");
982
983        let value = setup.store().get(&seed_uri).await.expect("seeded value");
984        let value = String::from_utf8(value).expect("utf8");
985        assert_eq!(value, "seeded-secret");
986    }
987}