Skip to main content

greentic_deployer/
runtime_secrets.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    env, fmt,
4    fs::File,
5    io::{Read, Seek, SeekFrom},
6    path::{Path, PathBuf},
7};
8
9use greentic_secrets_lib::{
10    DevStore, GeneratedSecretRequirement, GeneratedSecretScope, SecretsStore, TEAM_PLACEHOLDER,
11    canonical_secret_name, canonical_secret_store_key, generated_scope_team, normalize_team,
12};
13use serde::Deserialize;
14use sha2::{Digest, Sha256};
15use zip::{ZipArchive, result::ZipError};
16
17use crate::config::{DeployerConfig, Provider};
18use crate::contract::DeployerCapability;
19use crate::environment::{EnvironmentStore, LocalFsStore};
20use crate::error::{DeployerError, Result};
21use greentic_deploy_spec::{EnvId, Environment};
22
23const DEV_SECRETS_PATH_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
24const SECRET_ASSET_PATHS: &[&str] = &[
25    "assets/secret-requirements.json",
26    "assets/secret_requirements.json",
27    "secret-requirements.json",
28    "secret_requirements.json",
29];
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct RuntimeSecretRequirement {
33    pub uri: String,
34    pub provider_id: String,
35    pub key: String,
36    pub required: bool,
37    pub default_value: Option<String>,
38    /// Canonicalized alternate names a previously-seeded value may live under.
39    /// Resolution checks these URIs too, mirroring greentic-start so a value
40    /// stored under a legacy/aliased key is still found and promoted.
41    pub aliases: Vec<String>,
42    /// Generation policy when the secret is system-generated; `None` =
43    /// operator-supplied. Carried (parsed into the shared `greentic-secrets`
44    /// model) so the deployer is *aware* which secrets are system-minted — used
45    /// today for a clearer missing-secret diagnostic; the active mint/promote
46    /// pass is a deferred follow-up.
47    pub generated: Option<GeneratedSecretRequirement>,
48    pub source: PathBuf,
49}
50
51#[derive(Clone, PartialEq, Eq)]
52pub struct SecretValue(String);
53
54impl SecretValue {
55    pub fn expose(&self) -> &str {
56        &self.0
57    }
58}
59
60impl From<String> for SecretValue {
61    fn from(value: String) -> Self {
62        Self(value)
63    }
64}
65
66impl fmt::Debug for SecretValue {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.write_str("<redacted>")
69    }
70}
71
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct ResolvedRuntimeSecret {
74    pub requirement: RuntimeSecretRequirement,
75    pub value: SecretValue,
76    pub source: SecretValueSource,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum SecretValueSource {
81    Env { key: String },
82    DevStore { path: PathBuf },
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct MissingRuntimeSecret {
87    pub requirement: RuntimeSecretRequirement,
88    pub checked_sources: Vec<String>,
89}
90
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct RuntimeSecretResolution {
93    pub resolved: Vec<ResolvedRuntimeSecret>,
94    pub missing: Vec<MissingRuntimeSecret>,
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct PromotedRuntimeSecret {
99    pub uri: String,
100    pub remote_name: String,
101}
102
103#[derive(Clone, Debug, Default, PartialEq, Eq)]
104pub struct PromoteRuntimeSecretsReport {
105    pub promoted: Vec<PromotedRuntimeSecret>,
106    pub skipped: Vec<String>,
107}
108
109#[derive(Clone, Debug)]
110pub struct RuntimeSecretContext {
111    pub bundle_root: PathBuf,
112    pub pack_paths: Vec<PathBuf>,
113    pub environment: String,
114    pub tenant: String,
115    pub team: Option<String>,
116    /// Dev-store roots beyond `bundle_root` to also search for secret values —
117    /// e.g. the operator env directory (`~/.greentic/environments/<env>`) where
118    /// `op messaging add` and `gtc setup` write. Cloud-apply populates this so
119    /// per-endpoint webhook secrets and setup-minted generated secrets resolve;
120    /// other callers leave it empty.
121    pub extra_dev_store_roots: Vec<PathBuf>,
122}
123
124/// Shared cloud-apply secret inputs: resolves the bundle root + pack paths,
125/// best-effort loads the operator `Environment`, and returns the resolution
126/// context plus the pack-declared and per-endpoint (webhook) requirements.
127/// Endpoint requirements are deduped against pack URIs HERE — the single place
128/// that "pack wins on URI collision" policy lives — so the promote path and the
129/// env-map both see exactly one remote name per URI. `Ok(None)` when there is
130/// no bundle root to scan; callers map that to their own empty result.
131struct CloudSecretInputs {
132    ctx: RuntimeSecretContext,
133    pack_requirements: Vec<RuntimeSecretRequirement>,
134    endpoint_requirements: Vec<RuntimeSecretRequirement>,
135}
136
137fn collect_cloud_secret_inputs(config: &DeployerConfig) -> Result<Option<CloudSecretInputs>> {
138    let Some(bundle_root) = config.bundle_root.clone().or_else(|| {
139        config
140            .pack_path
141            .as_deref()
142            .and_then(infer_bundle_root_from_pack_path)
143    }) else {
144        return Ok(None);
145    };
146
147    let mut pack_paths: Vec<PathBuf> = config.pack_path.clone().into_iter().collect();
148    if let Some(provider_pack) = config.provider_pack.as_ref() {
149        pack_paths.push(provider_pack.clone());
150    }
151    pack_paths.extend(discover_bundle_pack_paths(&bundle_root)?);
152    pack_paths = dedup_paths(pack_paths);
153
154    let loaded_env = load_environment_for_config(config)?;
155    let extra_dev_store_roots = loaded_env
156        .as_ref()
157        .map(|(_, env_dir)| vec![env_dir.clone()])
158        .unwrap_or_default();
159    let ctx = RuntimeSecretContext {
160        bundle_root,
161        pack_paths,
162        environment: config.environment.clone(),
163        tenant: config.tenant.clone(),
164        team: None,
165        extra_dev_store_roots,
166    };
167
168    let pack_requirements = collect_requirements(&ctx)?;
169    let mut endpoint_requirements = match loaded_env.as_ref() {
170        Some((env, _)) => collect_endpoint_webhook_requirements(env)?,
171        None => Vec::new(),
172    };
173    // Pack-declared requirements win on any URI collision: drop a per-endpoint
174    // requirement whose URI a pack already declares so promotion and the env-map
175    // never disagree on which remote name backs a URI.
176    if !endpoint_requirements.is_empty() {
177        let pack_uris: BTreeSet<&str> = pack_requirements.iter().map(|r| r.uri.as_str()).collect();
178        endpoint_requirements.retain(|r| !pack_uris.contains(r.uri.as_str()));
179    }
180
181    Ok(Some(CloudSecretInputs {
182        ctx,
183        pack_requirements,
184        endpoint_requirements,
185    }))
186}
187
188pub async fn resolve_for_cloud_apply(
189    config: &DeployerConfig,
190) -> Result<Option<RuntimeSecretResolution>> {
191    if !matches!(
192        config.provider,
193        Provider::Aws | Provider::Azure | Provider::Gcp
194    ) || config.capability != DeployerCapability::Apply
195        || !config.execute_local
196    {
197        return Ok(None);
198    }
199    let Some(CloudSecretInputs {
200        ctx,
201        mut pack_requirements,
202        endpoint_requirements,
203    }) = collect_cloud_secret_inputs(config)?
204    else {
205        return Ok(None);
206    };
207    // Endpoint requirements are already deduped against pack URIs, so merging is
208    // a plain extend. Per-endpoint webhook secrets aren't pack-declared, so this
209    // is how they get resolved from the env dev-store and promoted.
210    pack_requirements.extend(endpoint_requirements);
211    if pack_requirements.is_empty() {
212        return Ok(None);
213    }
214
215    let resolution = resolve_runtime_secrets(&ctx, &pack_requirements).await;
216    if !resolution.missing.is_empty() {
217        return Err(DeployerError::Config(format_missing_runtime_secrets(
218            &resolution.missing,
219        )));
220    }
221    Ok(Some(resolution))
222}
223
224fn load_environment_for_config(config: &DeployerConfig) -> Result<Option<(Environment, PathBuf)>> {
225    // Not a valid env id, or no per-user store root to look under → no
226    // operator environment to enumerate; the pack-only path is unaffected.
227    // A non-default `--store-root` is not visible from `DeployerConfig`, so
228    // such deploys also land here (documented limitation).
229    let Ok(env_id) = EnvId::try_from(config.environment.as_str()) else {
230        return Ok(None);
231    };
232    let Some(root) = LocalFsStore::default_root() else {
233        return Ok(None);
234    };
235    load_environment_from_store(&LocalFsStore::new(root), &env_id)
236}
237
238/// Load an environment, failing CLOSED once we know it exists: a present-but
239/// unreadable `environment.json` (or an `env_dir` failure) must surface, not
240/// be silently treated as "no env" — that would drop required endpoint
241/// secrets from promotion with no deploy-time signal.
242fn load_environment_from_store(
243    store: &LocalFsStore,
244    env_id: &EnvId,
245) -> Result<Option<(Environment, PathBuf)>> {
246    if !store
247        .exists(env_id)
248        .map_err(|e| DeployerError::Config(format!("environment store: {e}")))?
249    {
250        return Ok(None);
251    }
252    let env = store
253        .load(env_id)
254        .map_err(|e| DeployerError::Config(format!("load environment {env_id}: {e}")))?;
255    let env_dir = store
256        .env_dir(env_id)
257        .map_err(|e| DeployerError::Config(format!("environment dir {env_id}: {e}")))?;
258    Ok(Some((env, env_dir)))
259}
260
261const WEBHOOK_SECRET_KEY: &str = "webhook_secret";
262
263/// Synthesize a runtime-secret requirement for each per-endpoint webhook secret
264/// recorded on the environment. The value already lives in the env dev-store
265/// (minted by `op messaging add`/`rotate`); cloud-apply must promote it so the
266/// runtime — which reads it by the same `secrets://…` URI — finds it in the
267/// cloud secret manager. These are never pack-declared, which is the gap this
268/// closes. `provider_id` embeds the (lowercased) endpoint id so each webhook
269/// promotes to a distinct cloud secret name.
270fn collect_endpoint_webhook_requirements(
271    env: &Environment,
272) -> Result<Vec<RuntimeSecretRequirement>> {
273    let mut out = Vec::new();
274    for endpoint in &env.messaging_endpoints {
275        let Some(secret_ref) = endpoint.webhook_secret_ref.as_ref() else {
276            continue;
277        };
278        let uri = secret_ref
279            .to_store_uri()
280            .map_err(|e| {
281                DeployerError::Config(format!(
282                    "messaging endpoint {} webhook_secret_ref: {e}",
283                    endpoint.endpoint_id
284                ))
285            })?
286            .to_string();
287        let eid_lower = endpoint.endpoint_id.to_string().to_lowercase();
288        out.push(RuntimeSecretRequirement {
289            uri,
290            provider_id: format!("messaging-{eid_lower}"),
291            key: WEBHOOK_SECRET_KEY.to_string(),
292            required: true,
293            default_value: None,
294            aliases: Vec::new(),
295            generated: None,
296            source: PathBuf::from("environment.json"),
297        });
298    }
299    Ok(out)
300}
301
302/// The cloud secret-manager resource name a requirement promotes to, scoped to
303/// the active cloud provider's naming rules. `None` for non-cloud providers.
304fn cloud_remote_name(
305    provider: Provider,
306    prefix: &str,
307    requirement: &RuntimeSecretRequirement,
308) -> Option<String> {
309    match provider {
310        Provider::Aws => Some(cloud_secret_name(
311            prefix,
312            &requirement.provider_id,
313            &requirement.key,
314        )),
315        Provider::Azure => Some(flat_cloud_secret_name(
316            prefix,
317            &requirement.provider_id,
318            &requirement.key,
319            127,
320        )),
321        Provider::Gcp => Some(flat_cloud_secret_name(
322            prefix,
323            &requirement.provider_id,
324            &requirement.key,
325            255,
326        )),
327        _ => None,
328    }
329}
330
331fn build_cloud_env_map(
332    provider: Provider,
333    prefix: &str,
334    pack_requirements: &[RuntimeSecretRequirement],
335    endpoint_requirements: &[RuntimeSecretRequirement],
336    resolved_uris: &BTreeSet<String>,
337) -> BTreeMap<String, String> {
338    let mut env_map = BTreeMap::new();
339    for requirement in pack_requirements {
340        if !requirement.required && !resolved_uris.contains(&requirement.uri) {
341            continue;
342        }
343        let Some(remote_name) = cloud_remote_name(provider, prefix, requirement) else {
344            continue;
345        };
346        env_map.insert(requirement.uri.clone(), remote_name.clone());
347        env_map.insert(requirement.key.clone(), remote_name);
348    }
349    // Per-endpoint webhook secrets key the env-map by URI ONLY (every
350    // endpoint's `key` is the literal `webhook_secret`, so a bare-key alias
351    // would collide across endpoints). The `contains_key` guard is defensive:
352    // `collect_cloud_secret_inputs` already drops endpoint URIs a pack declares,
353    // but keeping it makes this pure helper correct on any input (pack wins).
354    for requirement in endpoint_requirements {
355        if env_map.contains_key(&requirement.uri) {
356            continue;
357        }
358        if !requirement.required && !resolved_uris.contains(&requirement.uri) {
359            continue;
360        }
361        let Some(remote_name) = cloud_remote_name(provider, prefix, requirement) else {
362            continue;
363        };
364        env_map.insert(requirement.uri.clone(), remote_name);
365    }
366    env_map
367}
368
369pub fn default_cloud_secret_prefix(environment: &str, tenant: &str, team: Option<&str>) -> String {
370    let team = normalize_team(team);
371    format!(
372        "greentic/{environment}/{tenant}/{}",
373        team.as_deref().unwrap_or(TEAM_PLACEHOLDER)
374    )
375}
376
377pub fn collect_requirements(ctx: &RuntimeSecretContext) -> Result<Vec<RuntimeSecretRequirement>> {
378    let mut by_uri = BTreeMap::new();
379    for pack_path in &ctx.pack_paths {
380        if !pack_path.exists() {
381            continue;
382        }
383        let provider_id = provider_id_from_pack_path(pack_path);
384        for req in load_secret_requirements_from_pack(pack_path)? {
385            let key = canonical_secret_name(&req.key);
386            let generated = req
387                .generated
388                .as_ref()
389                .map(AssetGeneratedSecret::to_requirement);
390            // A system-generated secret is minted under the team its scope
391            // resolves to (`generated_scope_team`) — NOT the context team — so
392            // the deployer reads/promotes it from exactly where the runtime
393            // seeded it. Operator-supplied secrets stay on the context team.
394            let team = match &generated {
395                Some(generated) => generated_scope_team(generated, ctx.team.as_deref()),
396                None => ctx.team.as_deref(),
397            };
398            let uri = canonical_secret_uri(&ctx.environment, &ctx.tenant, team, &provider_id, &key);
399            let aliases = req
400                .aliases
401                .iter()
402                .map(|alias| canonical_secret_name(alias))
403                .collect();
404            by_uri
405                .entry(uri.clone())
406                .or_insert(RuntimeSecretRequirement {
407                    uri,
408                    provider_id: provider_id.clone(),
409                    key,
410                    required: req.required,
411                    default_value: req.default_value,
412                    aliases,
413                    generated,
414                    source: pack_path.clone(),
415                });
416        }
417    }
418    Ok(by_uri.into_values().collect())
419}
420
421/// Secret requirements a built bundle declares, for the env-manifest
422/// wizard's "ask only what's needed" secrets step.
423///
424/// `bundle_root` is the bundle workspace directory (the parent of the
425/// `.gtbundle` artifact), where `packs/` and `providers/` live. Each pack's
426/// `secret-requirements.json` is read and scoped to `environment`/`tenant`
427/// (team `_`) via the same [`collect_requirements`] the cloud-apply path
428/// uses — so the wizard derives exactly the paths the apply engine later
429/// writes. Returns an empty vec when the bundle declares no secrets (or has
430/// not been built yet — `packs/` absent).
431pub fn bundle_secret_requirements(
432    bundle_root: &Path,
433    environment: &str,
434    tenant: &str,
435) -> Result<Vec<RuntimeSecretRequirement>> {
436    let pack_paths = discover_bundle_pack_paths(bundle_root)?;
437    let ctx = RuntimeSecretContext {
438        bundle_root: bundle_root.to_path_buf(),
439        pack_paths,
440        environment: environment.to_string(),
441        tenant: tenant.to_string(),
442        team: None,
443        extra_dev_store_roots: Vec::new(),
444    };
445    collect_requirements(&ctx)
446}
447
448/// The dev-store manifest `path` (`<tenant>/<team>/<pack>/<name>`) carried
449/// by a `secrets://<environment>/...` URI — the inverse of
450/// [`canonical_secret_uri`]'s prefix. `None` when `uri` is not a
451/// `secrets://` URI scoped to `environment`.
452pub fn manifest_secret_path(uri: &str, environment: &str) -> Option<String> {
453    uri.strip_prefix(&format!("secrets://{environment}/"))
454        .map(str::to_string)
455}
456
457pub fn runtime_secret_env_map_for_cloud(
458    config: &DeployerConfig,
459) -> Result<BTreeMap<String, String>> {
460    if !matches!(
461        config.provider,
462        Provider::Aws | Provider::Azure | Provider::Gcp
463    ) {
464        return Ok(BTreeMap::new());
465    }
466    let Some(CloudSecretInputs {
467        ctx,
468        pack_requirements,
469        endpoint_requirements,
470    }) = collect_cloud_secret_inputs(config)?
471    else {
472        return Ok(BTreeMap::new());
473    };
474    let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
475
476    // Unify env-map generation with the resolution that the cloud-secret
477    // promotion path uses. `resolve_runtime_secrets` consults env vars AND
478    // every dev secrets store on disk, so optional secrets backed only by
479    // the DevStore are included in the env_map (Codex review F1: previously
480    // the env-only filter silently skipped DevStore-backed optionals while
481    // the AWS/GCP/Azure apply paths still promoted them, producing
482    // runtime auth failures because Terraform was never given the URI).
483    let all_requirements: Vec<RuntimeSecretRequirement> = pack_requirements
484        .iter()
485        .chain(endpoint_requirements.iter())
486        .cloned()
487        .collect();
488    let resolution = block_on_async_resolution(&ctx, &all_requirements);
489    let resolved_uris: BTreeSet<String> = resolution
490        .resolved
491        .into_iter()
492        .map(|r| r.requirement.uri)
493        .collect();
494
495    let env_map = build_cloud_env_map(
496        config.provider,
497        &prefix,
498        &pack_requirements,
499        &endpoint_requirements,
500        &resolved_uris,
501    );
502    Ok(env_map)
503}
504
505/// Run `resolve_runtime_secrets` from a sync context, regardless of the
506/// caller's runtime.
507///
508/// The deployer's production call chain is `run_cloud_backend` →
509/// `run_backend_operation` (which builds a **current-thread** runtime via
510/// `Builder::new_current_thread().block_on(apply::run)`) → several sync
511/// helpers → `runtime_secret_env_map_for_cloud` → here. We therefore CANNOT
512/// use `tokio::task::block_in_place` (it panics on a current-thread runtime)
513/// nor `Handle::current().block_on` (re-entrant block_on on the same
514/// current-thread runtime panics).
515///
516/// Instead, hop to a dedicated OS thread that owns its own current-thread
517/// runtime. `std::thread::scope` lets the closure borrow `ctx`/`requirements`
518/// without a `'static` bound; the inner runtime is fully independent of the
519/// caller's, so this works whether the caller is on a current-thread runtime,
520/// a multi-thread runtime, or no runtime at all.
521fn block_on_async_resolution(
522    ctx: &RuntimeSecretContext,
523    requirements: &[RuntimeSecretRequirement],
524) -> RuntimeSecretResolution {
525    std::thread::scope(|scope| {
526        scope
527            .spawn(|| {
528                tokio::runtime::Builder::new_current_thread()
529                    .enable_all()
530                    .build()
531                    .expect("build runtime for runtime-secret resolution")
532                    .block_on(resolve_runtime_secrets(ctx, requirements))
533            })
534            .join()
535            .expect("runtime-secret resolution thread panicked")
536    })
537}
538
539pub async fn resolve_runtime_secrets(
540    ctx: &RuntimeSecretContext,
541    requirements: &[RuntimeSecretRequirement],
542) -> RuntimeSecretResolution {
543    let store_paths = dev_store_paths(&ctx.bundle_root, &ctx.extra_dev_store_roots);
544    let mut resolved = Vec::new();
545    let mut missing = Vec::new();
546
547    for requirement in requirements {
548        let mut checked_sources = Vec::new();
549        // Candidate store URIs: the primary, then any aliases (a value seeded
550        // under a legacy/aliased name still satisfies the requirement, mirroring
551        // greentic-start). Alias URIs must share the primary's resolved scope:
552        // a generated secret lives under `generated_scope_team`, not the context
553        // team (the same derivation `collect_requirements` used for the primary).
554        let team = match &requirement.generated {
555            Some(generated) => generated_scope_team(generated, ctx.team.as_deref()),
556            None => ctx.team.as_deref(),
557        };
558        let candidate_uris: Vec<String> = std::iter::once(requirement.uri.clone())
559            .chain(requirement.aliases.iter().map(|alias| {
560                canonical_secret_uri(
561                    &ctx.environment,
562                    &ctx.tenant,
563                    team,
564                    &requirement.provider_id,
565                    alias,
566                )
567            }))
568            .collect();
569
570        let mut found = None;
571        'candidates: for uri in &candidate_uris {
572            if let Some(env_key) = canonical_secret_store_key(uri) {
573                checked_sources.push(format!("env {env_key}"));
574                if let Ok(value) = env::var(&env_key)
575                    && !value.is_empty()
576                {
577                    if is_placeholder_secret_value(&value, uri) {
578                        checked_sources
579                            .push(format!("env {env_key} (auto-seeded placeholder, ignored)"));
580                    } else {
581                        found = Some((SecretValueSource::Env { key: env_key }, value));
582                        break 'candidates;
583                    }
584                }
585            }
586
587            for path in &store_paths {
588                checked_sources.push(path.display().to_string());
589                if !path.exists() {
590                    continue;
591                }
592                if let Ok(store) = DevStore::with_path(path)
593                    && let Ok(bytes) = store.get(uri).await
594                    && let Ok(value) = String::from_utf8(bytes)
595                    && !value.is_empty()
596                {
597                    // `gtc setup --non-interactive` writes raw `${VAR}`
598                    // placeholders into the dev secrets store. Expand them here
599                    // against the process env so the promoted cloud secret
600                    // carries the actual value, not the placeholder string. If
601                    // the env var is unset, treat the dev-store entry as
602                    // unresolved and keep looking.
603                    if let Some(env_key) = extract_env_placeholder(&value) {
604                        checked_sources.push(format!("env ${{{env_key}}} (from dev store)"));
605                        match env::var(&env_key) {
606                            Ok(expanded)
607                                if !expanded.is_empty()
608                                    && !is_placeholder_secret_value(&expanded, uri) =>
609                            {
610                                found = Some((
611                                    SecretValueSource::DevStore { path: path.clone() },
612                                    expanded,
613                                ));
614                                break 'candidates;
615                            }
616                            _ => continue,
617                        }
618                    }
619                    // A `greentic-start`-seeded placeholder is not a real
620                    // secret. Treat it as unresolved and keep searching: if no
621                    // real value turns up, a required secret fails the deploy
622                    // (it lands in `missing`); an optional one is skipped, so a
623                    // value an operator pre-seeded directly in the cloud secret
624                    // manager survives instead of being clobbered.
625                    if is_placeholder_secret_value(&value, uri) {
626                        checked_sources.push(format!(
627                            "{} (auto-seeded placeholder, ignored)",
628                            path.display()
629                        ));
630                        continue;
631                    }
632                    found = Some((SecretValueSource::DevStore { path: path.clone() }, value));
633                    break 'candidates;
634                }
635            }
636        }
637
638        if let Some((source, value)) = found {
639            resolved.push(ResolvedRuntimeSecret {
640                requirement: requirement.clone(),
641                value: SecretValue(value),
642                source,
643            });
644        } else if requirement.required {
645            missing.push(MissingRuntimeSecret {
646                requirement: requirement.clone(),
647                checked_sources,
648            });
649        }
650    }
651
652    RuntimeSecretResolution { resolved, missing }
653}
654
655pub fn format_missing_runtime_secrets(missing: &[MissingRuntimeSecret]) -> String {
656    let mut out = String::from("missing required runtime secrets:\n");
657    for entry in missing {
658        out.push_str(&format!("  - {}\n", entry.requirement.uri));
659        if entry.requirement.generated.is_some() {
660            out.push_str(
661                "    (system-generated — run `gtc setup` / provisioning to mint it before deploy)\n",
662            );
663        }
664        out.push_str("    checked:\n");
665        for source in &entry.checked_sources {
666            out.push_str(&format!("      - {source}\n"));
667        }
668    }
669    out
670}
671
672pub fn cloud_secret_name(prefix: &str, provider_id: &str, key: &str) -> String {
673    format!(
674        "{}/{}/{}",
675        prefix.trim_matches('/'),
676        canonical_secret_name(provider_id),
677        canonical_secret_name(key)
678    )
679}
680
681pub fn flat_cloud_secret_name(
682    prefix: &str,
683    provider_id: &str,
684    key: &str,
685    max_len: usize,
686) -> String {
687    let raw = format!("{}-{}-{}", prefix.trim_matches('/'), provider_id, key);
688    let mut normalized = String::with_capacity(raw.len());
689    let mut prev_dash = false;
690    for ch in raw.chars() {
691        let next = match ch {
692            'A'..='Z' => ch.to_ascii_lowercase(),
693            'a'..='z' | '0'..='9' => ch,
694            '-' => '-',
695            '_' | '/' | '.' | ' ' => '-',
696            _ => continue,
697        };
698        if next == '-' {
699            if prev_dash {
700                continue;
701            }
702            prev_dash = true;
703        } else {
704            prev_dash = false;
705        }
706        normalized.push(next);
707    }
708    let normalized = normalized.trim_matches('-');
709    if normalized.len() <= max_len {
710        return normalized.to_string();
711    }
712
713    let mut hasher = Sha256::new();
714    hasher.update(normalized.as_bytes());
715    let digest = hex::encode(hasher.finalize());
716    let suffix = format!("-{}", &digest[..12]);
717    let keep = max_len.saturating_sub(suffix.len());
718    format!("{}{}", normalized[..keep].trim_matches('-'), suffix)
719}
720
721/// Build a canonical runtime secret store URI
722/// (`secrets://<env>/<tenant>/<team|_>/<provider>/<name>`).
723///
724/// Team normalization (`default`/empty → `_`) and name normalization both come
725/// from `greentic-secrets` so this can never drift from the runtime reader. The
726/// `provider` segment is taken verbatim — pack ids carry hyphens the runtime
727/// preserves. `canonical_secret_store_key`, `canonical_secret_name`, and
728/// `normalize_team`/`TEAM_PLACEHOLDER` are likewise the shared library
729/// definitions (imported above).
730pub fn canonical_secret_uri(
731    env: &str,
732    tenant: &str,
733    team: Option<&str>,
734    provider: &str,
735    key: &str,
736) -> String {
737    let team = normalize_team(team);
738    format!(
739        "secrets://{}/{}/{}/{}/{}",
740        env,
741        tenant,
742        team.as_deref().unwrap_or(TEAM_PLACEHOLDER),
743        provider,
744        canonical_secret_name(key)
745    )
746}
747
748fn dev_store_paths(bundle_root: &Path, extra_roots: &[PathBuf]) -> Vec<PathBuf> {
749    let mut paths = Vec::new();
750    if let Some(path) = env::var_os(DEV_SECRETS_PATH_ENV) {
751        paths.push(PathBuf::from(path));
752    }
753    for root in std::iter::once(bundle_root).chain(extra_roots.iter().map(PathBuf::as_path)) {
754        paths.push(root.join(".greentic/dev/.dev.secrets.env"));
755        paths.push(root.join(".greentic/state/dev/.dev.secrets.env"));
756    }
757
758    let mut seen = BTreeSet::new();
759    paths
760        .into_iter()
761        .filter(|path| seen.insert(path.clone()))
762        .collect()
763}
764
765fn discover_bundle_pack_paths(bundle_root: &Path) -> Result<Vec<PathBuf>> {
766    let mut out = Vec::new();
767    collect_pack_paths_from_dir(&bundle_root.join("packs"), &mut out)?;
768    collect_pack_paths_from_dir(&bundle_root.join("providers"), &mut out)?;
769    out.sort();
770    Ok(out)
771}
772
773fn collect_pack_paths_from_dir(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
774    if !dir.exists() {
775        return Ok(());
776    }
777    for entry in std::fs::read_dir(dir)? {
778        let entry = entry?;
779        let path = entry.path();
780        if path.extension().and_then(|value| value.to_str()) == Some("gtpack") {
781            out.push(path);
782            continue;
783        }
784        if path.is_dir() {
785            if path.join("pack.yaml").exists() || path.join("manifest.cbor").exists() {
786                out.push(path);
787            } else {
788                collect_pack_paths_from_dir(&path, out)?;
789            }
790        }
791    }
792    Ok(())
793}
794
795fn dedup_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
796    let mut seen = BTreeSet::new();
797    paths
798        .into_iter()
799        .filter(|path| seen.insert(path.clone()))
800        .collect()
801}
802
803fn provider_id_from_pack_path(pack_path: &Path) -> String {
804    pack_path
805        .file_stem()
806        .and_then(|value| value.to_str())
807        .map(str::trim)
808        .filter(|value| !value.is_empty())
809        .map(ToOwned::to_owned)
810        .unwrap_or_else(|| "provider".to_string())
811}
812
813fn infer_bundle_root_from_pack_path(pack_path: &Path) -> Option<PathBuf> {
814    let mut current = if pack_path.is_dir() {
815        Some(pack_path)
816    } else {
817        pack_path.parent()
818    };
819    while let Some(path) = current {
820        if path.file_name().and_then(|value| value.to_str()) == Some("packs") {
821            return path.parent().map(Path::to_path_buf);
822        }
823        if path.join("bundle.yaml").exists() {
824            return Some(path.to_path_buf());
825        }
826        current = path.parent();
827    }
828    None
829}
830
831fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
832    if pack_path.is_dir() {
833        return load_secret_requirements_from_dir(pack_path);
834    }
835    if !is_probably_zip(pack_path)? {
836        return load_secret_requirements_from_tar(pack_path);
837    }
838    load_secret_requirements_from_zip(pack_path)
839}
840
841fn is_probably_zip(path: &Path) -> Result<bool> {
842    let mut file = File::open(path)?;
843    let mut magic = [0_u8; 4];
844    let read = file.read(&mut magic)?;
845    Ok(read == magic.len() && magic == [0x50, 0x4b, 0x03, 0x04])
846}
847
848fn is_probably_tar(path: &Path) -> Result<bool> {
849    let mut file = File::open(path)?;
850    file.seek(SeekFrom::Start(257))?;
851    let mut magic = [0_u8; 5];
852    let read = file.read(&mut magic)?;
853    Ok(read == magic.len() && magic == *b"ustar")
854}
855
856fn load_secret_requirements_from_dir(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
857    let mut requirements = Vec::new();
858    for asset in SECRET_ASSET_PATHS {
859        let path = pack_path.join(asset);
860        if path.exists() {
861            let contents = std::fs::read_to_string(&path)?;
862            requirements.extend(parse_requirements(&contents, &path)?);
863        }
864    }
865    let setup_yaml = pack_path.join("assets/setup.yaml");
866    if setup_yaml.exists() {
867        let contents = std::fs::read_to_string(&setup_yaml)?;
868        requirements.extend(parse_setup_secret_requirements(&contents, &setup_yaml)?);
869    }
870    Ok(dedup_requirements(requirements))
871}
872
873fn load_secret_requirements_from_zip(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
874    let file = File::open(pack_path)?;
875    let mut archive = match ZipArchive::new(file) {
876        Ok(archive) => archive,
877        Err(_) => return Ok(Vec::new()),
878    };
879    let mut requirements = Vec::new();
880    for asset in SECRET_ASSET_PATHS {
881        match archive.by_name(asset) {
882            Ok(mut entry) => {
883                let mut contents = String::new();
884                entry.read_to_string(&mut contents)?;
885                requirements.extend(parse_requirements(&contents, Path::new(asset))?);
886            }
887            Err(ZipError::FileNotFound) => continue,
888            Err(err) => return Err(DeployerError::Other(err.to_string())),
889        }
890    }
891    if let Ok(mut entry) = archive.by_name("assets/setup.yaml") {
892        let mut contents = String::new();
893        entry.read_to_string(&mut contents)?;
894        requirements.extend(parse_setup_secret_requirements(
895            &contents,
896            Path::new("assets/setup.yaml"),
897        )?);
898    }
899    Ok(dedup_requirements(requirements))
900}
901
902fn load_secret_requirements_from_tar(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
903    if !is_probably_tar(pack_path)? {
904        return Ok(Vec::new());
905    }
906    let file = File::open(pack_path)?;
907    let mut archive = tar::Archive::new(file);
908    let entries = match archive.entries() {
909        Ok(entries) => entries,
910        Err(_) => return Ok(Vec::new()),
911    };
912    let mut requirements = Vec::new();
913    for entry in entries {
914        let mut entry = match entry {
915            Ok(entry) => entry,
916            Err(_) => continue,
917        };
918        let path = match entry.path() {
919            Ok(path) => path.into_owned(),
920            Err(_) => continue,
921        };
922        let Some(path_str) = path.to_str() else {
923            continue;
924        };
925        if SECRET_ASSET_PATHS.contains(&path_str) {
926            let mut contents = String::new();
927            entry.read_to_string(&mut contents)?;
928            requirements.extend(parse_requirements(&contents, &path)?);
929        } else if path_str == "assets/setup.yaml" {
930            let mut contents = String::new();
931            entry.read_to_string(&mut contents)?;
932            requirements.extend(parse_setup_secret_requirements(&contents, &path)?);
933        }
934    }
935    Ok(dedup_requirements(requirements))
936}
937
938fn parse_requirements(contents: &str, path: &Path) -> Result<Vec<PackSecretRequirement>> {
939    serde_json::from_str(contents).map_err(|err| {
940        DeployerError::Config(format!(
941            "parse secret requirements from {}: {err}",
942            path.display()
943        ))
944    })
945}
946
947#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
948struct PackSecretRequirement {
949    key: String,
950    #[serde(default)]
951    aliases: Vec<String>,
952    #[serde(default = "default_required")]
953    required: bool,
954    #[serde(default)]
955    default_value: Option<String>,
956    #[serde(default)]
957    generated: Option<AssetGeneratedSecret>,
958}
959
960/// The `generated` block of a pack `secret-requirements.json` entry — the
961/// deployer's own reader for the shared generation model (Option A: parsers
962/// stay per-repo). Optional fields take the same asset-path defaults
963/// greentic-start applies (length 32, `base64url`, team scope) so a pack mints
964/// identically wherever it's read; [`AssetGeneratedSecret::to_requirement`]
965/// lifts it into the canonical [`GeneratedSecretRequirement`].
966#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
967struct AssetGeneratedSecret {
968    policy: Option<String>,
969    length: Option<usize>,
970    encoding: Option<String>,
971    scope: Option<AssetGeneratedSecretScope>,
972    #[serde(default)]
973    regenerate_if_present: Option<bool>,
974}
975
976#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
977struct AssetGeneratedSecretScope {
978    level: Option<String>,
979    team: Option<String>,
980}
981
982impl AssetGeneratedSecret {
983    fn to_requirement(&self) -> GeneratedSecretRequirement {
984        GeneratedSecretRequirement {
985            policy: self.policy.clone().unwrap_or_else(|| "random".to_string()),
986            length: self.length.unwrap_or(32),
987            encoding: self
988                .encoding
989                .clone()
990                .unwrap_or_else(|| "base64url".to_string()),
991            scope: GeneratedSecretScope {
992                level: self
993                    .scope
994                    .as_ref()
995                    .and_then(|scope| scope.level.clone())
996                    .unwrap_or_else(|| "team".to_string()),
997                team: self.scope.as_ref().and_then(|scope| scope.team.clone()),
998            },
999            regenerate_if_present: self.regenerate_if_present.unwrap_or(false),
1000        }
1001    }
1002}
1003
1004#[derive(Debug, Deserialize)]
1005struct SetupSpec {
1006    #[serde(default)]
1007    questions: Vec<SetupQuestion>,
1008}
1009
1010#[derive(Debug, Deserialize)]
1011struct SetupQuestion {
1012    name: String,
1013    #[serde(default)]
1014    secret_key: Option<String>,
1015    #[serde(default)]
1016    default: Option<String>,
1017    #[serde(default)]
1018    secret: bool,
1019    #[serde(default)]
1020    required: bool,
1021}
1022
1023fn parse_setup_secret_requirements(
1024    contents: &str,
1025    path: &Path,
1026) -> Result<Vec<PackSecretRequirement>> {
1027    let setup: SetupSpec = serde_yaml_bw::from_str(contents).map_err(|err| {
1028        DeployerError::Config(format!(
1029            "parse setup secrets from {}: {err}",
1030            path.display()
1031        ))
1032    })?;
1033    Ok(setup
1034        .questions
1035        .into_iter()
1036        .filter(|question| question.secret)
1037        .map(|question| PackSecretRequirement {
1038            key: question.secret_key.unwrap_or(question.name),
1039            aliases: Vec::new(),
1040            required: question.required,
1041            default_value: question.default,
1042            generated: None,
1043        })
1044        .collect())
1045}
1046
1047fn dedup_requirements(requirements: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
1048    let mut by_key = BTreeMap::new();
1049    for requirement in requirements {
1050        let key = canonical_secret_name(&requirement.key);
1051        by_key
1052            .entry(key)
1053            .and_modify(|existing: &mut PackSecretRequirement| {
1054                existing.required |= requirement.required;
1055                if existing.default_value.is_none() {
1056                    existing.default_value = requirement.default_value.clone();
1057                }
1058                if existing.aliases.is_empty() {
1059                    existing.aliases = requirement.aliases.clone();
1060                }
1061                if existing.generated.is_none() {
1062                    existing.generated = requirement.generated.clone();
1063                }
1064            })
1065            .or_insert(requirement);
1066    }
1067    by_key.into_values().collect()
1068}
1069
1070// Parse a whole-string `${VAR}` placeholder and return `VAR`.
1071// `greentic-setup` persists unresolved env-var references in the dev secrets
1072// store when a non-interactive run cannot prompt. Without expansion here those
1073// placeholders would propagate to the cloud secrets store verbatim and break
1074// providers that try to use the value (e.g. state-redis treating `${REDIS_URL}`
1075// as a connection string).
1076fn extract_env_placeholder(value: &str) -> Option<String> {
1077    let trimmed = value.trim();
1078    let inner = trimmed.strip_prefix("${")?.strip_suffix('}')?;
1079    if inner.is_empty() || inner.contains(|c: char| c.is_whitespace() || c == '$' || c == '{') {
1080        return None;
1081    }
1082    Some(inner.to_string())
1083}
1084
1085/// Whether `value` (the value resolved for `uri`) is a placeholder
1086/// `greentic-start` auto-seeds into the dev store for un-provisioned secrets
1087/// (`secrets_setup::placeholder_text_for_uri`): `ollama-placeholder` for
1088/// `api_key`-shaped URIs, `placeholder for <uri>` for everything else. These
1089/// are NOT real credentials — promoting one to a cloud secret manager would
1090/// both ship a non-working value AND clobber any real value an operator
1091/// pre-seeded there, so the cloud-apply resolver treats them as unresolved.
1092///
1093/// Both forms are matched **exactly**, never by prefix: `ollama-placeholder`
1094/// is a globally-unique sentinel, and `placeholder for <uri>` is compared
1095/// against *this* candidate's `uri` so an arbitrary operator value that merely
1096/// begins with `placeholder for ` is not misclassified as unresolved.
1097///
1098/// NOTE: the marker strings are duplicated from `greentic-start`
1099/// (`secrets_setup.rs`) and must stay in sync. A shared sentinel in the
1100/// foundation crate is the deeper fix (tracked with the secrets-lib scaffold
1101/// follow-up), out of scope for this surgical change.
1102fn is_placeholder_secret_value(value: &str, uri: &str) -> bool {
1103    let trimmed = value.trim();
1104    trimmed == "ollama-placeholder"
1105        || trimmed
1106            .strip_prefix("placeholder for ")
1107            .is_some_and(|rest| rest == uri)
1108}
1109
1110fn default_required() -> bool {
1111    true
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117    use greentic_deploy_spec::MessagingEndpointId;
1118
1119    #[test]
1120    fn extract_env_placeholder_matches_whole_string_dollar_brace_form() {
1121        assert_eq!(
1122            extract_env_placeholder("${REDIS_URL}").as_deref(),
1123            Some("REDIS_URL")
1124        );
1125        assert_eq!(
1126            extract_env_placeholder("${OPENAI_API_KEY}").as_deref(),
1127            Some("OPENAI_API_KEY")
1128        );
1129        assert_eq!(
1130            extract_env_placeholder("  ${PUBLIC_BASE_URL}  ").as_deref(),
1131            Some("PUBLIC_BASE_URL"),
1132            "surrounding whitespace is allowed"
1133        );
1134    }
1135
1136    #[test]
1137    fn extract_env_placeholder_rejects_partial_or_malformed_patterns() {
1138        assert_eq!(extract_env_placeholder("redis://host:6379/0"), None);
1139        assert_eq!(extract_env_placeholder("prefix-${VAR}"), None);
1140        assert_eq!(extract_env_placeholder("${VAR}-suffix"), None);
1141        assert_eq!(extract_env_placeholder("${}"), None);
1142        assert_eq!(extract_env_placeholder("${VAR WITH SPACE}"), None);
1143        assert_eq!(extract_env_placeholder("${NESTED${INNER}}"), None);
1144    }
1145
1146    #[test]
1147    fn canonical_env_key_matches_start_runtime_shape() {
1148        assert_eq!(
1149            canonical_secret_store_key("secrets://dev/demo/_/openai/api_key").as_deref(),
1150            Some("GREENTIC_SECRET__DEV__DEMO_____OPENAI__API_KEY")
1151        );
1152    }
1153
1154    #[test]
1155    fn cloud_secret_name_is_stable_and_normalized() {
1156        assert_eq!(
1157            cloud_secret_name(
1158                "greentic/dev/demo/_",
1159                "messaging-telegram",
1160                "TELEGRAM_BOT_TOKEN"
1161            ),
1162            "greentic/dev/demo/_/messaging_telegram/telegram_bot_token"
1163        );
1164    }
1165
1166    #[test]
1167    fn requirement_uri_preserves_pack_provider_id_hyphens() {
1168        let dir = tempfile::tempdir().unwrap();
1169        let pack_dir = dir.path().join("packs/messaging-webchat-gui/assets");
1170        std::fs::create_dir_all(&pack_dir).unwrap();
1171        std::fs::write(
1172            pack_dir.join("secret-requirements.json"),
1173            r#"[{"key":"jwt_signing_key","required":true}]"#,
1174        )
1175        .unwrap();
1176
1177        let ctx = RuntimeSecretContext {
1178            bundle_root: dir.path().to_path_buf(),
1179            pack_paths: vec![dir.path().join("packs/messaging-webchat-gui")],
1180            environment: "dev".into(),
1181            tenant: "demo".into(),
1182            team: None,
1183            extra_dev_store_roots: Vec::new(),
1184        };
1185
1186        let requirements = collect_requirements(&ctx).unwrap();
1187        assert_eq!(requirements.len(), 1);
1188        assert_eq!(requirements[0].provider_id, "messaging-webchat-gui");
1189        assert_eq!(
1190            requirements[0].uri,
1191            "secrets://dev/demo/_/messaging-webchat-gui/jwt_signing_key"
1192        );
1193        assert_eq!(
1194            cloud_secret_name(
1195                "greentic/dev/demo/_",
1196                &requirements[0].provider_id,
1197                &requirements[0].key
1198            ),
1199            "greentic/dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1200        );
1201    }
1202
1203    #[test]
1204    fn bundle_secret_requirements_reads_packs_dir_scoped_to_tenant() {
1205        // Mirrors the env-manifest wizard: a built bundle workspace with a
1206        // provider pack under `packs/` whose `secret-requirements.json`
1207        // declares one key. The wizard derives exactly the manifest secret
1208        // path the apply engine later writes.
1209        let dir = tempfile::tempdir().unwrap();
1210        let pack_dir = dir.path().join("packs/messaging-telegram");
1211        std::fs::create_dir_all(pack_dir.join("assets")).unwrap();
1212        // `pack.yaml` marks the directory as a pack so `discover_bundle_pack_paths`
1213        // picks it up (real bundles ship `.gtpack` archives; a marked dir is the
1214        // lighter-weight equivalent for the test).
1215        std::fs::write(pack_dir.join("pack.yaml"), "id: messaging-telegram\n").unwrap();
1216        std::fs::write(
1217            pack_dir.join("assets/secret-requirements.json"),
1218            r#"[{"key":"TELEGRAM_BOT_TOKEN","required":true}]"#,
1219        )
1220        .unwrap();
1221
1222        let reqs = bundle_secret_requirements(dir.path(), "local", "legal").unwrap();
1223        assert_eq!(reqs.len(), 1);
1224        assert_eq!(
1225            reqs[0].uri,
1226            "secrets://local/legal/_/messaging-telegram/telegram_bot_token"
1227        );
1228        // …and the inverse strips the env prefix back to the manifest path.
1229        assert_eq!(
1230            manifest_secret_path(&reqs[0].uri, "local").as_deref(),
1231            Some("legal/_/messaging-telegram/telegram_bot_token")
1232        );
1233    }
1234
1235    #[test]
1236    fn bundle_secret_requirements_empty_when_unbuilt() {
1237        // No `packs/` dir (bundle not built yet) → no requirements, no error.
1238        let dir = tempfile::tempdir().unwrap();
1239        let reqs = bundle_secret_requirements(dir.path(), "local", "legal").unwrap();
1240        assert!(reqs.is_empty());
1241    }
1242
1243    #[test]
1244    fn manifest_secret_path_rejects_foreign_env_or_scheme() {
1245        assert_eq!(
1246            manifest_secret_path("secrets://prod/legal/_/p/tok", "local"),
1247            None,
1248            "different env is not stripped"
1249        );
1250        assert_eq!(
1251            manifest_secret_path("https://example.com/x", "local"),
1252            None,
1253            "non-secrets scheme is rejected"
1254        );
1255    }
1256
1257    #[test]
1258    fn flat_secret_name_limits_length_with_digest() {
1259        let name = flat_cloud_secret_name(
1260            "greentic/dev/demo/default",
1261            "very-long-provider-name",
1262            "THIS_IS_A_VERY_LONG_SECRET_NAME",
1263            40,
1264        );
1265        assert!(name.len() <= 40);
1266        assert!(name.starts_with("greentic-dev-demo-default"));
1267    }
1268
1269    #[test]
1270    fn infers_bundle_root_from_pack_path_under_packs_dir() {
1271        let path = Path::new("/tmp/demo-bundle/packs/app.gtpack");
1272        assert_eq!(
1273            infer_bundle_root_from_pack_path(path).as_deref(),
1274            Some(Path::new("/tmp/demo-bundle"))
1275        );
1276    }
1277
1278    #[test]
1279    fn skips_non_zip_gtpack_when_scanning_secret_requirements() {
1280        let dir = tempfile::tempdir().unwrap();
1281        let pack = dir.path().join("aws.gtpack");
1282        std::fs::write(&pack, b"not a zip").unwrap();
1283        let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1284        assert!(reqs.is_empty());
1285    }
1286
1287    #[test]
1288    fn reads_secret_requirements_from_tar_gtpack() {
1289        let dir = tempfile::tempdir().unwrap();
1290        let pack = dir.path().join("provider.gtpack");
1291        let file = File::create(&pack).unwrap();
1292        let mut builder = tar::Builder::new(file);
1293        let contents = br#"[{"key":"API_TOKEN","required":true}]"#;
1294        let mut header = tar::Header::new_gnu();
1295        header.set_path("assets/secret-requirements.json").unwrap();
1296        header.set_size(contents.len() as u64);
1297        header.set_cksum();
1298        builder
1299            .append(&header, contents.as_slice())
1300            .expect("append tar entry");
1301        builder.finish().unwrap();
1302
1303        let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1304        assert_eq!(reqs.len(), 1);
1305        assert_eq!(reqs[0].key, "API_TOKEN");
1306    }
1307
1308    #[test]
1309    fn reads_secret_requirements_from_setup_yaml() {
1310        let dir = tempfile::tempdir().unwrap();
1311        let pack = dir.path().join("pack");
1312        std::fs::create_dir_all(pack.join("assets")).unwrap();
1313        std::fs::write(
1314            pack.join("assets/setup.yaml"),
1315            r#"
1316questions:
1317  - name: api_key
1318    secret: true
1319    required: true
1320  - name: display_name
1321    secret: false
1322"#,
1323        )
1324        .unwrap();
1325
1326        let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1327        assert_eq!(reqs.len(), 1);
1328        assert_eq!(reqs[0].key, "api_key");
1329        assert!(reqs[0].required);
1330    }
1331
1332    #[test]
1333    fn discovers_provider_pack_paths() {
1334        let dir = tempfile::tempdir().unwrap();
1335        std::fs::create_dir_all(dir.path().join("providers/messaging")).unwrap();
1336        std::fs::write(
1337            dir.path()
1338                .join("providers/messaging/messaging-webchat-gui.gtpack"),
1339            b"",
1340        )
1341        .unwrap();
1342
1343        let paths = discover_bundle_pack_paths(dir.path()).unwrap();
1344        assert_eq!(paths.len(), 1);
1345        assert!(paths[0].ends_with("messaging-webchat-gui.gtpack"));
1346    }
1347
1348    #[tokio::test]
1349    async fn resolve_runtime_secrets_no_longer_falls_back_to_setup_answers() {
1350        // Pre-B12a, `setup-answers.json` was a secondary source when the dev
1351        // store missed. After B12a, the dev store is the only source — a stale
1352        // plaintext value at the legacy sink must NOT resolve.
1353        let dir = tempfile::tempdir().unwrap();
1354        let answers_dir = dir.path().join("state/config/demo-pack");
1355        std::fs::create_dir_all(&answers_dir).unwrap();
1356        std::fs::write(
1357            answers_dir.join("setup-answers.json"),
1358            r#"{"api_key":"STALE-PLAINTEXT-MUST-NOT-LEAK"}"#,
1359        )
1360        .unwrap();
1361        let ctx = RuntimeSecretContext {
1362            bundle_root: dir.path().to_path_buf(),
1363            pack_paths: Vec::new(),
1364            environment: "dev".into(),
1365            tenant: "demo".into(),
1366            team: None,
1367            extra_dev_store_roots: Vec::new(),
1368        };
1369        let requirement = RuntimeSecretRequirement {
1370            uri: canonical_secret_uri("dev", "demo", None, "demo_pack", "api_key"),
1371            provider_id: "demo_pack".into(),
1372            key: "api_key".into(),
1373            required: true,
1374            default_value: None,
1375            aliases: Vec::new(),
1376            generated: None,
1377            source: dir.path().join("packs/demo-pack.gtpack"),
1378        };
1379
1380        let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
1381        assert!(
1382            resolution.resolved.is_empty(),
1383            "no source means no resolution"
1384        );
1385        assert_eq!(resolution.missing.len(), 1);
1386        assert!(
1387            !resolution.missing[0]
1388                .checked_sources
1389                .iter()
1390                .any(|s| s.contains("setup-answers")),
1391            "setup-answers must not appear in checked_sources after B12a",
1392        );
1393    }
1394
1395    fn build_skips_unresolved_optional_fixture(
1396        bundle_root: &Path,
1397    ) -> (PathBuf, std::path::PathBuf) {
1398        let packs_dir = bundle_root.join("packs");
1399        let config_dir = bundle_root.join("state/config/demo-app");
1400        std::fs::create_dir_all(packs_dir.join("demo-app/assets")).unwrap();
1401        std::fs::create_dir_all(&config_dir).unwrap();
1402        std::fs::write(
1403            packs_dir.join("demo-app/assets/setup.yaml"),
1404            r#"
1405questions:
1406  - name: api_key
1407    secret: true
1408    required: false
1409  - name: oauth_client_secret
1410    secret: true
1411    required: false
1412  - name: jwt_signing_key
1413    secret: true
1414    required: true
1415"#,
1416        )
1417        .unwrap();
1418        // A stale plaintext in setup-answers.json MUST NOT make api_key
1419        // appear in env_map — the setup-answers fallback is gone (B12a).
1420        std::fs::write(
1421            config_dir.join("setup-answers.json"),
1422            r#"{"api_key":"STALE-PLAINTEXT-MUST-NOT-LEAK"}"#,
1423        )
1424        .unwrap();
1425        (packs_dir.clone(), packs_dir.join("demo-app"))
1426    }
1427
1428    fn deployer_config_for_fixture(bundle_root: &Path, pack_path: PathBuf) -> DeployerConfig {
1429        DeployerConfig {
1430            capability: DeployerCapability::Apply,
1431            provider: Provider::Aws,
1432            strategy: "iac-only".into(),
1433            tenant: "demo".into(),
1434            environment: "dev".into(),
1435            pack_path: Some(pack_path),
1436            bundle_root: Some(bundle_root.to_path_buf()),
1437            providers_dir: PathBuf::from("providers/deployer"),
1438            packs_dir: PathBuf::from("packs"),
1439            provider_pack: None,
1440            pack_ref: None,
1441            distributor_url: None,
1442            distributor_token: None,
1443            preview: false,
1444            dry_run: false,
1445            execute_local: true,
1446            output: crate::config::OutputFormat::Json,
1447            greentic: greentic_config::ConfigResolver::new()
1448                .load()
1449                .unwrap()
1450                .config,
1451            provenance: greentic_config::ProvenanceMap::new(),
1452            config_warnings: Vec::new(),
1453            deploy_pack_id_override: None,
1454            deploy_flow_id_override: None,
1455            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1456            bundle_digest: Some(
1457                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
1458            ),
1459            repo_registry_base: None,
1460            store_registry_base: None,
1461        }
1462    }
1463
1464    #[test]
1465    fn runtime_secret_env_map_skips_unresolved_optional_secrets() {
1466        let dir = tempfile::tempdir().unwrap();
1467        let bundle_root = dir.path();
1468        let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
1469        let config = deployer_config_for_fixture(bundle_root, pack_path);
1470
1471        let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
1472        // Required secret is always included.
1473        assert!(env_map.contains_key("secrets://dev/demo/_/demo-app/jwt_signing_key"));
1474        // Optionals with no source in env or DevStore are skipped — the
1475        // stale plaintext in setup-answers.json must not contribute.
1476        assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/api_key"));
1477        assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/oauth_client_secret"));
1478        assert!(!env_map.contains_key("oauth_client_secret"));
1479    }
1480
1481    fn seed_devstore_api_key(bundle_root: &Path) {
1482        use greentic_secrets_lib::{DevStore, SecretFormat};
1483        let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
1484        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1485        let store = DevStore::with_path(&store_path).unwrap();
1486        // Seed via an isolated current-thread runtime — no ambient runtime
1487        // required, matching how the production sync path reaches the store.
1488        tokio::runtime::Builder::new_current_thread()
1489            .enable_all()
1490            .build()
1491            .unwrap()
1492            .block_on(async {
1493                store
1494                    .put(
1495                        "secrets://dev/demo/_/demo-app/api_key",
1496                        SecretFormat::Text,
1497                        b"from-dev-store",
1498                    )
1499                    .await
1500                    .unwrap();
1501            });
1502    }
1503
1504    fn assert_devstore_optional_in_env_map(env_map: &BTreeMap<String, String>) {
1505        assert!(
1506            env_map.contains_key("secrets://dev/demo/_/demo-app/api_key"),
1507            "optional secret with DevStore value MUST appear in env_map: {env_map:?}",
1508        );
1509        assert!(env_map.contains_key("secrets://dev/demo/_/demo-app/jwt_signing_key"));
1510        // Other optional with no source stays skipped.
1511        assert!(!env_map.contains_key("secrets://dev/demo/_/demo-app/oauth_client_secret"));
1512    }
1513
1514    #[test]
1515    fn runtime_secret_env_map_includes_optional_secrets_with_devstore_value() {
1516        // Codex F1 regression: an optional secret backed only by the dev
1517        // secrets store (no env var) MUST appear in the cloud env_map so
1518        // Terraform can wire it through to the runtime.
1519        let dir = tempfile::tempdir().unwrap();
1520        let bundle_root = dir.path();
1521        let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
1522        seed_devstore_api_key(bundle_root);
1523
1524        let config = deployer_config_for_fixture(bundle_root, pack_path);
1525        let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
1526        assert_devstore_optional_in_env_map(&env_map);
1527    }
1528
1529    // xhigh review C1 regression: the production cloud-deploy path runs
1530    // `runtime_secret_env_map_for_cloud` from *inside* a current-thread tokio
1531    // runtime (run_backend_operation builds `Builder::new_current_thread()`).
1532    // The previous `block_in_place` bridge panicked there. This test
1533    // reproduces that exact reentry shape and must not panic.
1534    #[test]
1535    fn runtime_secret_env_map_callable_from_within_current_thread_runtime() {
1536        let dir = tempfile::tempdir().unwrap();
1537        let bundle_root = dir.path();
1538        let (_packs_dir, pack_path) = build_skips_unresolved_optional_fixture(bundle_root);
1539        seed_devstore_api_key(bundle_root);
1540        let config = deployer_config_for_fixture(bundle_root, pack_path);
1541
1542        let rt = tokio::runtime::Builder::new_current_thread()
1543            .enable_all()
1544            .build()
1545            .unwrap();
1546        let env_map = rt
1547            .block_on(async { runtime_secret_env_map_for_cloud(&config) })
1548            .unwrap();
1549        assert_devstore_optional_in_env_map(&env_map);
1550    }
1551
1552    #[test]
1553    fn secret_value_debug_is_redacted() {
1554        let value = SecretValue("super-secret".to_string());
1555        assert_eq!(format!("{value:?}"), "<redacted>");
1556    }
1557
1558    #[tokio::test]
1559    async fn resolve_runtime_secrets_honors_alias_uris() {
1560        // A value seeded only under an alias URI must still satisfy the
1561        // requirement (mirrors greentic-start's alias handling).
1562        let dir = tempfile::tempdir().unwrap();
1563        let bundle_root = dir.path();
1564        let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
1565        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1566        let alias_uri = canonical_secret_uri("dev", "demo", None, "demo_pack", "legacy_jwt_key");
1567        {
1568            let store = DevStore::with_path(&store_path).unwrap();
1569            store
1570                .put(
1571                    &alias_uri,
1572                    greentic_secrets_lib::SecretFormat::Text,
1573                    b"aliased-value",
1574                )
1575                .await
1576                .unwrap();
1577        }
1578
1579        let ctx = RuntimeSecretContext {
1580            bundle_root: bundle_root.to_path_buf(),
1581            pack_paths: Vec::new(),
1582            environment: "dev".into(),
1583            tenant: "demo".into(),
1584            team: None,
1585            extra_dev_store_roots: Vec::new(),
1586        };
1587        let requirement = RuntimeSecretRequirement {
1588            uri: canonical_secret_uri("dev", "demo", None, "demo_pack", "jwt_signing_key"),
1589            provider_id: "demo_pack".into(),
1590            key: "jwt_signing_key".into(),
1591            required: true,
1592            default_value: None,
1593            aliases: vec!["legacy_jwt_key".into()],
1594            generated: None,
1595            source: bundle_root.join("packs/demo-pack.gtpack"),
1596        };
1597
1598        let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
1599        assert!(resolution.missing.is_empty(), "alias value must resolve");
1600        assert_eq!(resolution.resolved.len(), 1);
1601        assert_eq!(resolution.resolved[0].value.expose(), "aliased-value");
1602    }
1603
1604    #[test]
1605    fn collect_requirements_parses_generated_and_aliases() {
1606        // The deployer parses the pack's `generated`/`aliases` metadata into the
1607        // shared `greentic-secrets` model so it is aware which secrets are
1608        // system-minted (Option A: reader stays here, model is shared).
1609        let dir = tempfile::tempdir().unwrap();
1610        let pack_dir = dir.path().join("packs/messaging-webex/assets");
1611        std::fs::create_dir_all(&pack_dir).unwrap();
1612        std::fs::write(
1613            pack_dir.join("secret-requirements.json"),
1614            r#"[{"key":"webex_webhook_secret","aliases":["WEBEX_WEBHOOK_SECRET"],"required":true,"generated":{"policy":"random","length":20,"encoding":"raw_text","scope":{"level":"tenant","team":"_"}}}]"#,
1615        )
1616        .unwrap();
1617
1618        let ctx = RuntimeSecretContext {
1619            bundle_root: dir.path().to_path_buf(),
1620            pack_paths: vec![dir.path().join("packs/messaging-webex")],
1621            environment: "dev".into(),
1622            tenant: "demo".into(),
1623            team: None,
1624            extra_dev_store_roots: Vec::new(),
1625        };
1626        let reqs = collect_requirements(&ctx).unwrap();
1627        assert_eq!(reqs.len(), 1);
1628        let req = &reqs[0];
1629        assert_eq!(req.key, "webex_webhook_secret");
1630        // Aliases are canonicalized to match the store-key derivation.
1631        assert_eq!(req.aliases, vec!["webex_webhook_secret".to_string()]);
1632        let generated = req.generated.as_ref().expect("generated policy parsed");
1633        assert_eq!(generated.policy, "random");
1634        assert_eq!(generated.length, 20);
1635        assert_eq!(generated.encoding, "raw_text");
1636        assert_eq!(generated.scope.level, "tenant");
1637        assert_eq!(generated.scope.team.as_deref(), Some("_"));
1638    }
1639
1640    #[tokio::test]
1641    async fn generated_secret_resolves_from_its_declared_team_scope() {
1642        // A generated secret explicitly scoped to a real team is minted by the
1643        // runtime under that team (`generated_scope_team`), not the context
1644        // team (`_`). The deployer must derive the SAME team for both the
1645        // primary URI and the alias candidate URIs, or it searches `_` and
1646        // misses the value — the cloud-promotion gap this consolidation closes.
1647        let dir = tempfile::tempdir().unwrap();
1648        let bundle_root = dir.path();
1649        let pack_dir = bundle_root.join("packs/messaging-webex/assets");
1650        std::fs::create_dir_all(&pack_dir).unwrap();
1651        std::fs::write(
1652            pack_dir.join("secret-requirements.json"),
1653            r#"[{"key":"webex_webhook_secret","aliases":["legacy_webhook"],"required":true,"generated":{"policy":"random","length":20,"encoding":"raw_text","scope":{"level":"team","team":"legal"}}}]"#,
1654        )
1655        .unwrap();
1656
1657        let ctx = RuntimeSecretContext {
1658            bundle_root: bundle_root.to_path_buf(),
1659            pack_paths: vec![bundle_root.join("packs/messaging-webex")],
1660            environment: "dev".into(),
1661            tenant: "demo".into(),
1662            team: None,
1663            extra_dev_store_roots: Vec::new(),
1664        };
1665        let reqs = collect_requirements(&ctx).unwrap();
1666        assert_eq!(reqs.len(), 1);
1667        // Primary URI is scoped to the declared team `legal`, not `_`.
1668        assert!(
1669            reqs[0].uri.starts_with("secrets://dev/demo/legal/"),
1670            "generated secret must be team-scoped, got {}",
1671            reqs[0].uri,
1672        );
1673
1674        // Seed the value under the *alias* at the same team scope; resolution
1675        // must find it via the team-scoped alias candidate URI.
1676        let alias_uri = canonical_secret_uri(
1677            "dev",
1678            "demo",
1679            Some("legal"),
1680            &reqs[0].provider_id,
1681            "legacy_webhook",
1682        );
1683        let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
1684        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1685        {
1686            let store = DevStore::with_path(&store_path).unwrap();
1687            store
1688                .put(
1689                    &alias_uri,
1690                    greentic_secrets_lib::SecretFormat::Text,
1691                    b"team-scoped-value",
1692                )
1693                .await
1694                .unwrap();
1695        }
1696
1697        let resolution = resolve_runtime_secrets(&ctx, &reqs).await;
1698        assert!(
1699            resolution.missing.is_empty(),
1700            "team-scoped alias value must resolve: {:?}",
1701            resolution.missing,
1702        );
1703        assert_eq!(resolution.resolved.len(), 1);
1704        assert_eq!(resolution.resolved[0].value.expose(), "team-scoped-value");
1705    }
1706
1707    /// A required, operator-supplied-shaped requirement — the common test shape
1708    /// (no default, no aliases, not generated).
1709    fn req(uri: &str, provider_id: &str, key: &str) -> RuntimeSecretRequirement {
1710        RuntimeSecretRequirement {
1711            uri: uri.into(),
1712            provider_id: provider_id.into(),
1713            key: key.into(),
1714            required: true,
1715            default_value: None,
1716            aliases: Vec::new(),
1717            generated: None,
1718            source: PathBuf::from("environment.json"),
1719        }
1720    }
1721
1722    async fn seed_dev_store_value(bundle_root: &Path, uri: &str, value: &[u8]) {
1723        use greentic_secrets_lib::{DevStore, SecretFormat};
1724        let store_path = bundle_root.join(".greentic/state/dev/.dev.secrets.env");
1725        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1726        let store = DevStore::with_path(&store_path).unwrap();
1727        store.put(uri, SecretFormat::Text, value).await.unwrap();
1728    }
1729
1730    fn ctx_for(bundle_root: &Path, environment: &str, tenant: &str) -> RuntimeSecretContext {
1731        RuntimeSecretContext {
1732            bundle_root: bundle_root.to_path_buf(),
1733            pack_paths: Vec::new(),
1734            environment: environment.into(),
1735            tenant: tenant.into(),
1736            team: None,
1737            extra_dev_store_roots: Vec::new(),
1738        }
1739    }
1740
1741    #[test]
1742    fn is_placeholder_secret_value_matches_only_exact_start_markers() {
1743        let uri = "secrets://dev/demo/_/openai/api_key";
1744        // `ollama-placeholder` is a global sentinel (URI-independent), trimmed.
1745        assert!(is_placeholder_secret_value("ollama-placeholder", uri));
1746        assert!(is_placeholder_secret_value("  ollama-placeholder  ", uri));
1747        // `placeholder for <uri>` matches ONLY for its own URI.
1748        assert!(is_placeholder_secret_value(
1749            "placeholder for secrets://dev/demo/_/openai/api_key",
1750            uri
1751        ));
1752        assert!(
1753            !is_placeholder_secret_value("placeholder for secrets://dev/demo/_/other/key", uri),
1754            "the templated marker must not match a different URI"
1755        );
1756        // Genuine credentials (incl. a real value that merely *starts with*
1757        // `placeholder for `) and the unexpanded ${VAR} form must NOT match.
1758        assert!(!is_placeholder_secret_value(
1759            "placeholder for my passphrase",
1760            uri
1761        ));
1762        assert!(!is_placeholder_secret_value("sk-realkey-123", uri));
1763        assert!(!is_placeholder_secret_value("", uri));
1764        assert!(!is_placeholder_secret_value("${OPENAI_API_KEY}", uri));
1765        assert!(!is_placeholder_secret_value("ollama", uri));
1766    }
1767
1768    #[tokio::test]
1769    async fn resolve_treats_start_placeholder_as_unresolved_required_is_missing() {
1770        // A required secret whose only dev-store value is a greentic-start
1771        // placeholder must NOT resolve: the deploy fails loudly (the secret
1772        // lands in `missing`) instead of promoting `ollama-placeholder` to the
1773        // cloud secret manager.
1774        let dir = tempfile::tempdir().unwrap();
1775        let uri = "secrets://dev/demo/_/openai/api_key";
1776        seed_dev_store_value(dir.path(), uri, b"ollama-placeholder").await;
1777
1778        let ctx = ctx_for(dir.path(), "dev", "demo");
1779        let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "openai", "api_key")]).await;
1780
1781        assert!(
1782            resolution.resolved.is_empty(),
1783            "a placeholder must not resolve"
1784        );
1785        assert_eq!(resolution.missing.len(), 1);
1786        assert_eq!(resolution.missing[0].requirement.uri, uri);
1787        assert!(
1788            resolution.missing[0]
1789                .checked_sources
1790                .iter()
1791                .any(|s| s.contains("auto-seeded placeholder")),
1792            "the ignored placeholder source must be recorded for the operator: {:?}",
1793            resolution.missing[0].checked_sources,
1794        );
1795    }
1796
1797    #[tokio::test]
1798    async fn resolve_skips_optional_placeholder_so_cloud_value_survives() {
1799        // An OPTIONAL secret backed only by a placeholder is dropped (neither
1800        // resolved nor missing), so it is never promoted — a value an operator
1801        // pre-seeded directly in the cloud secret manager is left intact.
1802        let dir = tempfile::tempdir().unwrap();
1803        let uri = "secrets://dev/demo/_/openai/api_key";
1804        seed_dev_store_value(dir.path(), uri, b"ollama-placeholder").await;
1805
1806        let ctx = ctx_for(dir.path(), "dev", "demo");
1807        let mut requirement = req(uri, "openai", "api_key");
1808        requirement.required = false;
1809        let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
1810
1811        assert!(resolution.resolved.is_empty());
1812        assert!(
1813            resolution.missing.is_empty(),
1814            "an optional placeholder is skipped, not reported missing"
1815        );
1816    }
1817
1818    #[tokio::test]
1819    async fn resolve_ignores_uri_scoped_placeholder_for_marker() {
1820        // The `placeholder for <uri>` marker (start's non-api_key form) seeded
1821        // under its own URI is treated as unresolved.
1822        let dir = tempfile::tempdir().unwrap();
1823        let uri = "secrets://dev/demo/_/webhook/token";
1824        seed_dev_store_value(
1825            dir.path(),
1826            uri,
1827            b"placeholder for secrets://dev/demo/_/webhook/token",
1828        )
1829        .await;
1830
1831        let ctx = ctx_for(dir.path(), "dev", "demo");
1832        let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "webhook", "token")]).await;
1833
1834        assert!(resolution.resolved.is_empty());
1835        assert_eq!(resolution.missing.len(), 1);
1836    }
1837
1838    #[tokio::test]
1839    async fn resolve_keeps_real_value_that_merely_starts_with_placeholder_for() {
1840        // Codex F2 regression: a genuine secret value beginning with
1841        // "placeholder for " — but NOT equal to the exact `placeholder for
1842        // <uri>` marker — must still resolve. The matcher is exact, not a
1843        // prefix, so real operator bytes are never misclassified.
1844        let dir = tempfile::tempdir().unwrap();
1845        let uri = "secrets://dev/demo/_/app/passphrase";
1846        let real = b"placeholder for the vault, rotated monthly";
1847        seed_dev_store_value(dir.path(), uri, real).await;
1848
1849        let ctx = ctx_for(dir.path(), "dev", "demo");
1850        let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "app", "passphrase")]).await;
1851
1852        assert!(resolution.missing.is_empty());
1853        assert_eq!(resolution.resolved.len(), 1);
1854        assert_eq!(
1855            resolution.resolved[0].value.expose(),
1856            "placeholder for the vault, rotated monthly"
1857        );
1858    }
1859
1860    #[tokio::test]
1861    async fn resolve_still_accepts_a_real_value_after_placeholder_guard() {
1862        // Regression: the guard must not over-match a genuine credential.
1863        let dir = tempfile::tempdir().unwrap();
1864        let uri = "secrets://dev/demo/_/openai/api_key";
1865        seed_dev_store_value(dir.path(), uri, b"sk-realkey-123").await;
1866
1867        let ctx = ctx_for(dir.path(), "dev", "demo");
1868        let resolution = resolve_runtime_secrets(&ctx, &[req(uri, "openai", "api_key")]).await;
1869
1870        assert!(resolution.missing.is_empty());
1871        assert_eq!(resolution.resolved.len(), 1);
1872        assert_eq!(resolution.resolved[0].value.expose(), "sk-realkey-123");
1873    }
1874
1875    fn env_with_webhook_endpoint(
1876        env_id: &str,
1877        eid: MessagingEndpointId,
1878        with_ref: bool,
1879    ) -> Environment {
1880        use chrono::Utc;
1881        use greentic_deploy_spec::{
1882            EnvironmentHostConfig, MessagingEndpoint, SchemaVersion, SecretRef,
1883        };
1884        let eid_lower = eid.to_string().to_lowercase();
1885        let webhook_secret_ref = with_ref.then(|| {
1886            SecretRef::try_new(format!(
1887                "secret://{env_id}/default/_/messaging-{eid_lower}/webhook_secret"
1888            ))
1889            .unwrap()
1890        });
1891        let endpoint = MessagingEndpoint {
1892            schema: SchemaVersion::new(SchemaVersion::MESSAGING_ENDPOINT_V1),
1893            env_id: EnvId::try_from(env_id).unwrap(),
1894            endpoint_id: eid,
1895            provider_id: "tg-legal".into(),
1896            provider_type: "telegram".into(),
1897            display_name: "Legal Bot".into(),
1898            secret_refs: Vec::new(),
1899            webhook_secret_ref,
1900            linked_bundles: Vec::new(),
1901            welcome_flow: None,
1902            generation: 1,
1903            created_at: Utc::now(),
1904            updated_at: Utc::now(),
1905            updated_by: "operator://test".into(),
1906        };
1907        Environment {
1908            schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
1909            environment_id: EnvId::try_from(env_id).unwrap(),
1910            name: env_id.into(),
1911            host_config: EnvironmentHostConfig {
1912                env_id: EnvId::try_from(env_id).unwrap(),
1913                region: None,
1914                tenant_org_id: None,
1915                listen_addr: None,
1916                public_base_url: None,
1917                gui_enabled: None,
1918            },
1919            packs: Vec::new(),
1920            credentials_ref: None,
1921            bundles: Vec::new(),
1922            revisions: Vec::new(),
1923            traffic_splits: Vec::new(),
1924            messaging_endpoints: vec![endpoint],
1925            extensions: Vec::new(),
1926            revocation: Default::default(),
1927            retention: Default::default(),
1928            health: Default::default(),
1929        }
1930    }
1931
1932    #[test]
1933    fn endpoint_webhook_requirement_matches_runtime_read_uri() {
1934        let eid = MessagingEndpointId::new();
1935        let eid_lower = eid.to_string().to_lowercase();
1936        let env = env_with_webhook_endpoint("prod", eid, true);
1937
1938        let reqs = collect_endpoint_webhook_requirements(&env).unwrap();
1939        assert_eq!(reqs.len(), 1);
1940        let req = &reqs[0];
1941        // Deploy==runtime alignment: the URI the deployer promotes under MUST be
1942        // the exact `secrets://…` URI greentic-start derives from the endpoint's
1943        // webhook_secret_ref (tenant `default`, team `_`, lowercased eid). Drift
1944        // here means the runtime can't find the secret in the cloud manager.
1945        assert_eq!(
1946            req.uri,
1947            format!("secrets://prod/default/_/messaging-{eid_lower}/webhook_secret")
1948        );
1949        assert_eq!(req.provider_id, format!("messaging-{eid_lower}"));
1950        assert_eq!(req.key, "webhook_secret");
1951        assert!(req.required);
1952        assert!(req.generated.is_none());
1953    }
1954
1955    #[test]
1956    fn endpoint_without_webhook_ref_yields_no_requirement() {
1957        let env = env_with_webhook_endpoint("prod", MessagingEndpointId::new(), false);
1958        assert!(
1959            collect_endpoint_webhook_requirements(&env)
1960                .unwrap()
1961                .is_empty()
1962        );
1963    }
1964
1965    #[test]
1966    fn dev_store_paths_includes_extra_env_roots_deduped() {
1967        let bundle = PathBuf::from("/bundle");
1968        let env_dir = PathBuf::from("/env");
1969        let paths = dev_store_paths(&bundle, std::slice::from_ref(&env_dir));
1970        assert!(paths.contains(&bundle.join(".greentic/dev/.dev.secrets.env")));
1971        assert!(paths.contains(&bundle.join(".greentic/state/dev/.dev.secrets.env")));
1972        assert!(paths.contains(&env_dir.join(".greentic/dev/.dev.secrets.env")));
1973        assert!(paths.contains(&env_dir.join(".greentic/state/dev/.dev.secrets.env")));
1974
1975        // bundle_root == env_dir must not duplicate entries.
1976        let deduped = dev_store_paths(&bundle, std::slice::from_ref(&bundle));
1977        let unique: BTreeSet<_> = deduped.iter().cloned().collect();
1978        assert_eq!(deduped.len(), unique.len());
1979    }
1980
1981    #[test]
1982    fn cloud_remote_name_for_webhook_is_endpoint_scoped() {
1983        let req = req(
1984            "secrets://prod/default/_/messaging-abc/webhook_secret",
1985            "messaging-abc",
1986            WEBHOOK_SECRET_KEY,
1987        );
1988        let prefix = default_cloud_secret_prefix("prod", "acme", None);
1989        // `cloud_secret_name` canonicalizes the provider/key segments (hyphen →
1990        // underscore). This is the internal cloud-SM resource name; the runtime
1991        // still reads by the raw `secrets://…messaging-abc…` URI (the env-map key).
1992        assert_eq!(
1993            cloud_remote_name(Provider::Aws, &prefix, &req).unwrap(),
1994            "greentic/prod/acme/_/messaging_abc/webhook_secret"
1995        );
1996        // Distinct endpoints → distinct cloud names (no cross-endpoint collision).
1997        let mut req2 = req.clone();
1998        req2.provider_id = "messaging-xyz".into();
1999        assert_ne!(
2000            cloud_remote_name(Provider::Aws, &prefix, &req),
2001            cloud_remote_name(Provider::Aws, &prefix, &req2)
2002        );
2003    }
2004
2005    #[tokio::test]
2006    async fn resolve_finds_webhook_value_in_env_dir_dev_store() {
2007        // The webhook value lives in the env dev-store (where `op messaging add`
2008        // writes), NOT the bundle-root dev-store the pack-scan path reads.
2009        // resolve_runtime_secrets must consult the extra env root.
2010        let bundle = tempfile::tempdir().unwrap();
2011        let env_dir = tempfile::tempdir().unwrap();
2012        let uri = "secrets://prod/default/_/messaging-abc/webhook_secret";
2013        let store_path = env_dir.path().join(".greentic/dev/.dev.secrets.env");
2014        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
2015        {
2016            let store = DevStore::with_path(&store_path).unwrap();
2017            store
2018                .put(uri, greentic_secrets_lib::SecretFormat::Text, b"wh-value")
2019                .await
2020                .unwrap();
2021        }
2022
2023        let ctx = RuntimeSecretContext {
2024            bundle_root: bundle.path().to_path_buf(),
2025            pack_paths: Vec::new(),
2026            environment: "prod".into(),
2027            tenant: "demo".into(),
2028            team: None,
2029            extra_dev_store_roots: vec![env_dir.path().to_path_buf()],
2030        };
2031        let requirement = req(uri, "messaging-abc", WEBHOOK_SECRET_KEY);
2032        let resolution = resolve_runtime_secrets(&ctx, std::slice::from_ref(&requirement)).await;
2033        assert!(resolution.missing.is_empty(), "{:?}", resolution.missing);
2034        assert_eq!(resolution.resolved.len(), 1);
2035        assert_eq!(resolution.resolved[0].value.expose(), "wh-value");
2036    }
2037
2038    #[tokio::test]
2039    async fn resolve_misses_webhook_value_without_env_root() {
2040        // Same value present only in the env dev-store, but no extra env root →
2041        // the bundle-root-only search misses it. This is exactly the gap the
2042        // env-root wiring closes.
2043        let bundle = tempfile::tempdir().unwrap();
2044        let env_dir = tempfile::tempdir().unwrap();
2045        let uri = "secrets://prod/default/_/messaging-abc/webhook_secret";
2046        let store_path = env_dir.path().join(".greentic/dev/.dev.secrets.env");
2047        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
2048        {
2049            let store = DevStore::with_path(&store_path).unwrap();
2050            store
2051                .put(uri, greentic_secrets_lib::SecretFormat::Text, b"wh-value")
2052                .await
2053                .unwrap();
2054        }
2055        let ctx = RuntimeSecretContext {
2056            bundle_root: bundle.path().to_path_buf(),
2057            pack_paths: Vec::new(),
2058            environment: "prod".into(),
2059            tenant: "demo".into(),
2060            team: None,
2061            extra_dev_store_roots: Vec::new(),
2062        };
2063        let requirement = req(uri, "messaging-abc", WEBHOOK_SECRET_KEY);
2064        let resolution = resolve_runtime_secrets(&ctx, std::slice::from_ref(&requirement)).await;
2065        assert_eq!(resolution.missing.len(), 1);
2066    }
2067
2068    #[test]
2069    fn load_environment_from_store_returns_none_when_absent() {
2070        let dir = tempfile::tempdir().unwrap();
2071        let store = LocalFsStore::new(dir.path());
2072        let env_id = EnvId::try_from("prod").unwrap();
2073        assert!(
2074            load_environment_from_store(&store, &env_id)
2075                .unwrap()
2076                .is_none()
2077        );
2078    }
2079
2080    #[test]
2081    fn load_environment_from_store_fails_closed_on_corrupt_env() {
2082        let dir = tempfile::tempdir().unwrap();
2083        let store = LocalFsStore::new(dir.path());
2084        let env_id = EnvId::try_from("prod").unwrap();
2085        let env_path = dir.path().join("prod/environment.json");
2086        std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
2087        std::fs::write(&env_path, b"{ not valid json").unwrap();
2088        assert!(load_environment_from_store(&store, &env_id).is_err());
2089    }
2090
2091    #[test]
2092    fn build_cloud_env_map_pack_wins_on_uri_collision() {
2093        let uri = "secrets://prod/default/_/messaging-abc/webhook_secret".to_string();
2094        let pack = req(&uri, "packprov", "api_key");
2095        let ep_collide = req(&uri, "messaging-abc", WEBHOOK_SECRET_KEY);
2096        let ep_other = req(
2097            "secrets://prod/default/_/messaging-xyz/webhook_secret",
2098            "messaging-xyz",
2099            WEBHOOK_SECRET_KEY,
2100        );
2101        let resolved: BTreeSet<String> = [
2102            uri.clone(),
2103            "secrets://prod/default/_/messaging-xyz/webhook_secret".to_string(),
2104        ]
2105        .into_iter()
2106        .collect();
2107        let prefix = default_cloud_secret_prefix("prod", "acme", None);
2108        let map = build_cloud_env_map(
2109            Provider::Aws,
2110            &prefix,
2111            std::slice::from_ref(&pack),
2112            &[ep_collide, ep_other],
2113            &resolved,
2114        );
2115        // Collision URI keeps the PACK remote name (built from packprov/api_key),
2116        // not the endpoint's.
2117        assert_eq!(
2118            map.get(&uri).unwrap(),
2119            &cloud_remote_name(Provider::Aws, &prefix, &pack).unwrap()
2120        );
2121        assert!(map.contains_key("secrets://prod/default/_/messaging-xyz/webhook_secret"));
2122    }
2123}