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