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 base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
10use greentic_secrets_lib::{
11    DevStore, SecretsStore, TEAM_PLACEHOLDER, canonical_secret_name, canonical_secret_store_key,
12    normalize_team,
13};
14use greentic_types::{ExtensionInline, decode_pack_manifest};
15use rand::RngExt as _;
16use serde::Deserialize;
17use serde_cbor::value::Value as CborValue;
18use serde_json::Value as JsonValue;
19use sha2::{Digest, Sha256};
20use zip::{ZipArchive, result::ZipError};
21
22use crate::config::{DeployerConfig, Provider};
23use crate::contract::DeployerCapability;
24use crate::error::{DeployerError, Result};
25
26const DEV_SECRETS_PATH_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
27const EXT_GENERATED_SECRETS_V1: &str = "greentic.generated-secrets.v1";
28const SECRET_ASSET_PATHS: &[&str] = &[
29    "assets/secret-requirements.json",
30    "assets/secret_requirements.json",
31    "secret-requirements.json",
32    "secret_requirements.json",
33];
34
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct RuntimeSecretRequirement {
37    pub uri: String,
38    pub provider_id: String,
39    pub key: String,
40    pub required: bool,
41    pub default_value: Option<String>,
42    pub generated: Option<GeneratedSecretRequirement>,
43    pub source: PathBuf,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct GeneratedSecretRequirement {
48    pub policy: String,
49    pub length: usize,
50    pub encoding: String,
51    pub scope: GeneratedSecretScope,
52    pub regenerate_if_present: bool,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct GeneratedSecretScope {
57    pub level: String,
58    pub team: Option<String>,
59}
60
61#[derive(Clone, PartialEq, Eq)]
62pub struct SecretValue(String);
63
64impl SecretValue {
65    pub fn new(value: String) -> Self {
66        Self(value)
67    }
68
69    pub fn expose(&self) -> &str {
70        &self.0
71    }
72}
73
74impl fmt::Debug for SecretValue {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str("<redacted>")
77    }
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct ResolvedRuntimeSecret {
82    pub requirement: RuntimeSecretRequirement,
83    pub value: SecretValue,
84    pub source: SecretValueSource,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum SecretValueSource {
89    Env { key: String },
90    DevStore { path: PathBuf },
91    SetupAnswers { path: PathBuf },
92    Generated,
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct MissingRuntimeSecret {
97    pub requirement: RuntimeSecretRequirement,
98    pub checked_sources: Vec<String>,
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct RuntimeSecretResolution {
103    pub resolved: Vec<ResolvedRuntimeSecret>,
104    pub missing: Vec<MissingRuntimeSecret>,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
108pub struct PromotedRuntimeSecret {
109    pub uri: String,
110    pub remote_name: String,
111}
112
113#[derive(Clone, Debug, Default, PartialEq, Eq)]
114pub struct PromoteRuntimeSecretsReport {
115    pub promoted: Vec<PromotedRuntimeSecret>,
116    pub skipped: Vec<String>,
117}
118
119#[derive(Clone, Debug)]
120pub struct RuntimeSecretContext {
121    pub bundle_root: PathBuf,
122    pub pack_paths: Vec<PathBuf>,
123    pub environment: String,
124    pub tenant: String,
125    pub team: Option<String>,
126}
127
128pub async fn resolve_for_cloud_apply(
129    config: &DeployerConfig,
130) -> Result<Option<RuntimeSecretResolution>> {
131    if !matches!(
132        config.provider,
133        Provider::Aws | Provider::Azure | Provider::Gcp
134    ) || config.capability != DeployerCapability::Apply
135        || !config.execute_local
136    {
137        return Ok(None);
138    }
139    let Some(bundle_root) = config
140        .bundle_root
141        .clone()
142        .or_else(|| infer_bundle_root_from_pack_path(&config.pack_path))
143    else {
144        return Ok(None);
145    };
146
147    let pack_paths = pack_paths_for_cloud_apply(config, &bundle_root)?;
148
149    let ctx = RuntimeSecretContext {
150        bundle_root,
151        pack_paths,
152        environment: config.environment.clone(),
153        tenant: config.tenant.clone(),
154        team: None,
155    };
156    let requirements = collect_requirements(&ctx)?;
157    if requirements.is_empty() {
158        return Ok(None);
159    }
160
161    let resolution = resolve_runtime_secrets(&ctx, &requirements).await;
162    if !resolution.missing.is_empty() {
163        return Err(DeployerError::Config(format_missing_runtime_secrets(
164            &resolution.missing,
165        )));
166    }
167    Ok(Some(resolution))
168}
169
170pub fn default_cloud_secret_prefix(environment: &str, tenant: &str, team: Option<&str>) -> String {
171    let team = normalize_team(team);
172    format!(
173        "greentic/{environment}/{tenant}/{}",
174        team.as_deref().unwrap_or(TEAM_PLACEHOLDER)
175    )
176}
177
178pub fn collect_requirements(ctx: &RuntimeSecretContext) -> Result<Vec<RuntimeSecretRequirement>> {
179    let mut by_uri = BTreeMap::new();
180    for pack_path in &ctx.pack_paths {
181        if !pack_path.exists() {
182            continue;
183        }
184        let provider_id = provider_id_from_pack_path(pack_path);
185        for req in load_secret_requirements_from_pack(pack_path)? {
186            let key = canonical_secret_name(&req.key);
187            let uri = canonical_secret_uri(
188                &ctx.environment,
189                &ctx.tenant,
190                requirement_team(req.generated.as_ref(), ctx.team.as_deref()),
191                &provider_id,
192                &key,
193            );
194            by_uri
195                .entry(uri.clone())
196                .or_insert(RuntimeSecretRequirement {
197                    uri,
198                    provider_id: provider_id.clone(),
199                    key,
200                    required: req.required,
201                    default_value: req.default_value,
202                    generated: req.generated,
203                    source: pack_path.clone(),
204                });
205        }
206    }
207    Ok(by_uri.into_values().collect())
208}
209
210fn pack_paths_for_cloud_apply(config: &DeployerConfig, bundle_root: &Path) -> Result<Vec<PathBuf>> {
211    let mut pack_paths = vec![config.pack_path.clone()];
212    if let Some(provider_pack) = config.provider_pack.as_ref() {
213        pack_paths.push(provider_pack.clone());
214    }
215    pack_paths.extend(
216        discover_bundle_pack_paths(bundle_root)?
217            .into_iter()
218            .filter(|path| include_pack_for_cloud_provider(config.provider, bundle_root, path)),
219    );
220    Ok(dedup_paths(pack_paths))
221}
222
223fn include_pack_for_cloud_provider(
224    provider: Provider,
225    bundle_root: &Path,
226    pack_path: &Path,
227) -> bool {
228    let Ok(relative) = pack_path.strip_prefix(bundle_root) else {
229        return true;
230    };
231    let mut components = relative.components();
232    let Some(std::path::Component::Normal(first)) = components.next() else {
233        return true;
234    };
235    let Some(std::path::Component::Normal(second)) = components.next() else {
236        return true;
237    };
238    if first != "providers" || second != "secrets" {
239        return true;
240    }
241    let Some(active_stem) = active_secrets_provider_pack_stem(provider) else {
242        return false;
243    };
244    provider_id_from_pack_path(pack_path) == active_stem
245}
246
247fn active_secrets_provider_pack_stem(provider: Provider) -> Option<&'static str> {
248    match provider {
249        Provider::Aws => Some("aws-sm"),
250        Provider::Gcp => Some("gcp-sm"),
251        Provider::Azure => Some("azure-kv"),
252        _ => None,
253    }
254}
255
256pub fn runtime_secret_env_map_for_cloud(
257    _config: &DeployerConfig,
258) -> Result<BTreeMap<String, String>> {
259    // Cloud runtimes now receive `state/config/platform/secrets-provider.json`.
260    // Runtime secrets are still resolved and promoted before apply, but they
261    // must not be exposed as `GREENTIC_SECRET__...` environment variables.
262    Ok(BTreeMap::new())
263}
264
265pub async fn resolve_runtime_secrets(
266    ctx: &RuntimeSecretContext,
267    requirements: &[RuntimeSecretRequirement],
268) -> RuntimeSecretResolution {
269    let store_paths = dev_store_paths(&ctx.bundle_root);
270    let mut resolved = Vec::new();
271    let mut missing = Vec::new();
272
273    for requirement in requirements {
274        let mut checked_sources = Vec::new();
275        if let Some(env_key) = canonical_secret_store_key(&requirement.uri) {
276            checked_sources.push(format!("env {env_key}"));
277            if let Ok(value) = env::var(&env_key)
278                && !value.is_empty()
279            {
280                resolved.push(ResolvedRuntimeSecret {
281                    requirement: requirement.clone(),
282                    value: SecretValue(value),
283                    source: SecretValueSource::Env { key: env_key },
284                });
285                continue;
286            }
287        }
288
289        let mut found = None;
290        for path in &store_paths {
291            checked_sources.push(path.display().to_string());
292            if !path.exists() {
293                continue;
294            }
295            if let Ok(store) = DevStore::with_path(path)
296                && let Ok(bytes) = store.get(&requirement.uri).await
297                && let Ok(value) = String::from_utf8(bytes)
298                && !value.is_empty()
299            {
300                // `gtc setup --non-interactive` writes raw `${VAR}` placeholders
301                // into the dev secrets store too — not just into setup-answers.
302                // Expand them here against the process env so the promoted
303                // cloud secret carries the actual value, not the placeholder
304                // string. If the env var is unset, treat the dev-store entry
305                // as unresolved and fall back to setup-answers (where the same
306                // expansion runs as a second chance) before marking missing.
307                if let Some(env_key) = extract_env_placeholder(&value) {
308                    checked_sources.push(format!("env ${{{env_key}}} (from dev store)"));
309                    match env::var(&env_key) {
310                        Ok(resolved) if !resolved.is_empty() => {
311                            found = Some((path.clone(), resolved));
312                            break;
313                        }
314                        _ => continue,
315                    }
316                }
317                found = Some((path.clone(), value));
318                break;
319            }
320        }
321
322        if let Some((path, value)) = found {
323            resolved.push(ResolvedRuntimeSecret {
324                requirement: requirement.clone(),
325                value: SecretValue(value),
326                source: SecretValueSource::DevStore { path },
327            });
328        } else if let Some((path, value)) =
329            resolve_from_setup_answers(&ctx.bundle_root, requirement, &mut checked_sources)
330        {
331            resolved.push(ResolvedRuntimeSecret {
332                requirement: requirement.clone(),
333                value: SecretValue(value),
334                source: SecretValueSource::SetupAnswers { path },
335            });
336        } else if let Some(generated) = &requirement.generated {
337            // Transitional fallback. `greentic setup` is now the single place
338            // that introduces secrets — including generated ones — into the
339            // local secrets manager, and `gtc start` only *moves* them to the
340            // target. A value should therefore already have resolved above.
341            // Generating here means an older bundle (built before setup-side
342            // generation) was deployed: warn loudly and generate so the deploy
343            // still succeeds, rather than synthesising a placeholder/default.
344            checked_sources.push("generated secret metadata".to_string());
345            match generated_secret_value(generated) {
346                Ok(value) => {
347                    tracing::warn!(
348                        secret_uri = %requirement.uri,
349                        secret_key = %requirement.key,
350                        "generated runtime secret was absent from the local secrets manager; \
351                         generating at deploy as a transitional fallback (setup should introduce it)"
352                    );
353                    resolved.push(ResolvedRuntimeSecret {
354                        requirement: requirement.clone(),
355                        value: SecretValue(value),
356                        source: SecretValueSource::Generated,
357                    });
358                }
359                Err(err) if requirement.required => {
360                    checked_sources.push(format!("generation failed: {err}"));
361                    tracing::warn!(
362                        secret_uri = %requirement.uri,
363                        secret_key = %requirement.key,
364                        "required runtime secret could not be generated"
365                    );
366                    missing.push(MissingRuntimeSecret {
367                        requirement: requirement.clone(),
368                        checked_sources,
369                    });
370                }
371                Err(_) => {}
372            }
373        } else if requirement.required {
374            tracing::warn!(
375                secret_uri = %requirement.uri,
376                secret_key = %requirement.key,
377                checked_sources = ?checked_sources,
378                "required runtime secret is not available in the local secrets manager"
379            );
380            missing.push(MissingRuntimeSecret {
381                requirement: requirement.clone(),
382                checked_sources,
383            });
384        } else {
385            // Optional secret (for example a value acquired at runtime such as
386            // an OAuth token) is absent from the local secrets manager. Warn
387            // and let it slide rather than promoting a synthesised value.
388            tracing::warn!(
389                secret_uri = %requirement.uri,
390                secret_key = %requirement.key,
391                "optional runtime secret not found in the local secrets manager; \
392                 leaving unset for runtime resolution"
393            );
394        }
395    }
396
397    RuntimeSecretResolution { resolved, missing }
398}
399
400pub fn format_missing_runtime_secrets(missing: &[MissingRuntimeSecret]) -> String {
401    let mut out = String::from("missing required runtime secrets:\n");
402    for entry in missing {
403        out.push_str(&format!("  - {}\n", entry.requirement.uri));
404        out.push_str("    checked:\n");
405        for source in &entry.checked_sources {
406            out.push_str(&format!("      - {source}\n"));
407        }
408    }
409    out
410}
411
412pub fn cloud_secret_name(prefix: &str, provider_id: &str, key: &str) -> String {
413    format!(
414        "{}/{}/{}",
415        prefix.trim_matches('/'),
416        canonical_secret_name(provider_id),
417        canonical_secret_name(key)
418    )
419}
420
421pub fn flat_cloud_secret_name(
422    prefix: &str,
423    provider_id: &str,
424    key: &str,
425    max_len: usize,
426) -> String {
427    let raw = format!("{}-{}-{}", prefix.trim_matches('/'), provider_id, key);
428    let mut normalized = String::with_capacity(raw.len());
429    let mut prev_dash = false;
430    for ch in raw.chars() {
431        let next = match ch {
432            'A'..='Z' => ch.to_ascii_lowercase(),
433            'a'..='z' | '0'..='9' => ch,
434            '-' => '-',
435            '_' | '/' | '.' | ' ' => '-',
436            _ => continue,
437        };
438        if next == '-' {
439            if prev_dash {
440                continue;
441            }
442            prev_dash = true;
443        } else {
444            prev_dash = false;
445        }
446        normalized.push(next);
447    }
448    let normalized = normalized.trim_matches('-');
449    if normalized.len() <= max_len {
450        return normalized.to_string();
451    }
452
453    let mut hasher = Sha256::new();
454    hasher.update(normalized.as_bytes());
455    let digest = hex::encode(hasher.finalize());
456    let suffix = format!("-{}", &digest[..12]);
457    let keep = max_len.saturating_sub(suffix.len());
458    format!("{}{}", normalized[..keep].trim_matches('-'), suffix)
459}
460
461pub fn canonical_secret_uri(
462    env: &str,
463    tenant: &str,
464    team: Option<&str>,
465    provider: &str,
466    key: &str,
467) -> String {
468    let team = normalize_team(team);
469    // Normalize the provider segment the same way as the key (and as the cloud
470    // secret name / env-bridge key already do), so a value written under a
471    // provider id like `messaging-webchat-gui` resolves when a component fetches
472    // it under `messaging.webchat-gui` — both collapse to `messaging_webchat_gui`.
473    format!(
474        "secrets://{}/{}/{}/{}/{}",
475        env,
476        tenant,
477        team.as_deref().unwrap_or(TEAM_PLACEHOLDER),
478        canonical_secret_name(provider),
479        canonical_secret_name(key)
480    )
481}
482
483fn requirement_team<'a>(
484    generated: Option<&'a GeneratedSecretRequirement>,
485    default_team: Option<&'a str>,
486) -> Option<&'a str> {
487    let Some(generated) = generated else {
488        return default_team;
489    };
490    if generated.scope.level.eq_ignore_ascii_case("tenant")
491        || generated.scope.team.as_deref() == Some("_")
492    {
493        return None;
494    }
495    generated.scope.team.as_deref().or(default_team)
496}
497
498fn dev_store_paths(bundle_root: &Path) -> Vec<PathBuf> {
499    let mut paths = Vec::new();
500    if let Some(path) = env::var_os(DEV_SECRETS_PATH_ENV) {
501        paths.push(PathBuf::from(path));
502    }
503    paths.push(bundle_root.join(".greentic/dev/.dev.secrets.env"));
504    paths.push(bundle_root.join(".greentic/state/dev/.dev.secrets.env"));
505
506    let mut seen = BTreeSet::new();
507    paths
508        .into_iter()
509        .filter(|path| seen.insert(path.clone()))
510        .collect()
511}
512
513fn discover_bundle_pack_paths(bundle_root: &Path) -> Result<Vec<PathBuf>> {
514    let mut out = Vec::new();
515    collect_pack_paths_from_dir(&bundle_root.join("packs"), &mut out)?;
516    collect_pack_paths_from_dir(&bundle_root.join("providers"), &mut out)?;
517    out.sort();
518    Ok(out)
519}
520
521fn collect_pack_paths_from_dir(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
522    if !dir.exists() {
523        return Ok(());
524    }
525    for entry in std::fs::read_dir(dir)? {
526        let entry = entry?;
527        let path = entry.path();
528        if path.extension().and_then(|value| value.to_str()) == Some("gtpack") {
529            out.push(path);
530            continue;
531        }
532        if path.is_dir() {
533            if path.join("pack.yaml").exists() || path.join("manifest.cbor").exists() {
534                out.push(path);
535            } else {
536                collect_pack_paths_from_dir(&path, out)?;
537            }
538        }
539    }
540    Ok(())
541}
542
543fn dedup_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
544    let mut seen = BTreeSet::new();
545    paths
546        .into_iter()
547        .filter(|path| seen.insert(path.clone()))
548        .collect()
549}
550
551fn provider_id_from_pack_path(pack_path: &Path) -> String {
552    pack_path
553        .file_stem()
554        .and_then(|value| value.to_str())
555        .map(str::trim)
556        .filter(|value| !value.is_empty())
557        .map(ToOwned::to_owned)
558        .unwrap_or_else(|| "provider".to_string())
559}
560
561fn config_id_from_pack_path(pack_path: &Path) -> Option<String> {
562    pack_path
563        .file_stem()
564        .and_then(|value| value.to_str())
565        .map(ToOwned::to_owned)
566}
567
568fn infer_bundle_root_from_pack_path(pack_path: &Path) -> Option<PathBuf> {
569    let mut current = if pack_path.is_dir() {
570        Some(pack_path)
571    } else {
572        pack_path.parent()
573    };
574    while let Some(path) = current {
575        if path.file_name().and_then(|value| value.to_str()) == Some("packs") {
576            return path.parent().map(Path::to_path_buf);
577        }
578        if path.join("bundle.yaml").exists() {
579            return Some(path.to_path_buf());
580        }
581        current = path.parent();
582    }
583    None
584}
585
586fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
587    if pack_path.is_dir() {
588        return load_secret_requirements_from_dir(pack_path);
589    }
590    if !is_probably_zip(pack_path)? {
591        return load_secret_requirements_from_tar(pack_path);
592    }
593    load_secret_requirements_from_zip(pack_path)
594}
595
596fn is_probably_zip(path: &Path) -> Result<bool> {
597    let mut file = File::open(path)?;
598    let mut magic = [0_u8; 4];
599    let read = file.read(&mut magic)?;
600    Ok(read == magic.len() && magic == [0x50, 0x4b, 0x03, 0x04])
601}
602
603fn is_probably_tar(path: &Path) -> Result<bool> {
604    let mut file = File::open(path)?;
605    file.seek(SeekFrom::Start(257))?;
606    let mut magic = [0_u8; 5];
607    let read = file.read(&mut magic)?;
608    Ok(read == magic.len() && magic == *b"ustar")
609}
610
611fn load_secret_requirements_from_dir(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
612    let mut requirements = load_generated_requirements_from_dir(pack_path)?;
613    for asset in SECRET_ASSET_PATHS {
614        let path = pack_path.join(asset);
615        if path.exists() {
616            let contents = std::fs::read_to_string(&path)?;
617            requirements.extend(parse_requirements(&contents, &path)?);
618        }
619    }
620    let setup_yaml = pack_path.join("assets/setup.yaml");
621    if setup_yaml.exists() {
622        let contents = std::fs::read_to_string(&setup_yaml)?;
623        requirements.extend(parse_setup_secret_requirements(&contents, &setup_yaml)?);
624    }
625    Ok(dedup_requirements(requirements))
626}
627
628fn load_secret_requirements_from_zip(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
629    let file = File::open(pack_path)?;
630    let mut archive = match ZipArchive::new(file) {
631        Ok(archive) => archive,
632        Err(_) => return Ok(Vec::new()),
633    };
634    let mut requirements = load_generated_requirements_from_zip(&mut archive)?;
635    for asset in SECRET_ASSET_PATHS {
636        match archive.by_name(asset) {
637            Ok(mut entry) => {
638                let mut contents = String::new();
639                entry.read_to_string(&mut contents)?;
640                requirements.extend(parse_requirements(&contents, Path::new(asset))?);
641            }
642            Err(ZipError::FileNotFound) => continue,
643            Err(err) => return Err(DeployerError::Other(err.to_string())),
644        }
645    }
646    if let Ok(mut entry) = archive.by_name("assets/setup.yaml") {
647        let mut contents = String::new();
648        entry.read_to_string(&mut contents)?;
649        requirements.extend(parse_setup_secret_requirements(
650            &contents,
651            Path::new("assets/setup.yaml"),
652        )?);
653    }
654    Ok(dedup_requirements(requirements))
655}
656
657fn load_secret_requirements_from_tar(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
658    if !is_probably_tar(pack_path)? {
659        return Ok(Vec::new());
660    }
661    let file = File::open(pack_path)?;
662    let mut archive = tar::Archive::new(file);
663    let entries = match archive.entries() {
664        Ok(entries) => entries,
665        Err(_) => return Ok(Vec::new()),
666    };
667    let mut requirements = Vec::new();
668    for entry in entries {
669        let mut entry = match entry {
670            Ok(entry) => entry,
671            Err(_) => continue,
672        };
673        let path = match entry.path() {
674            Ok(path) => path.into_owned(),
675            Err(_) => continue,
676        };
677        let Some(path_str) = path.to_str() else {
678            continue;
679        };
680        if SECRET_ASSET_PATHS.contains(&path_str) {
681            let mut contents = String::new();
682            entry.read_to_string(&mut contents)?;
683            requirements.extend(parse_requirements(&contents, &path)?);
684        } else if path_str == "assets/setup.yaml" {
685            let mut contents = String::new();
686            entry.read_to_string(&mut contents)?;
687            requirements.extend(parse_setup_secret_requirements(&contents, &path)?);
688        } else if path_str == "manifest.cbor" {
689            let mut bytes = Vec::new();
690            entry.read_to_end(&mut bytes)?;
691            requirements.extend(load_generated_requirements_from_manifest_cbor_bytes(
692                &bytes,
693            )?);
694        } else if path_str == "pack.manifest.json" {
695            let mut contents = String::new();
696            entry.read_to_string(&mut contents)?;
697            requirements.extend(load_generated_requirements_from_manifest_json_str(
698                &contents,
699            )?);
700        }
701    }
702    Ok(dedup_requirements(requirements))
703}
704
705fn parse_requirements(contents: &str, path: &Path) -> Result<Vec<PackSecretRequirement>> {
706    let path_display = path.display().to_string();
707    let requirements: Vec<AssetSecretRequirement> =
708        serde_json::from_str(contents).map_err(|err| {
709            DeployerError::Config(format!(
710                "parse secret requirements from {path_display}: {err}"
711            ))
712        })?;
713    Ok(requirements
714        .into_iter()
715        .filter_map(asset_requirement_to_pack_requirement)
716        .collect())
717}
718
719#[derive(Clone, Debug, PartialEq, Eq)]
720struct PackSecretRequirement {
721    key: String,
722    required: bool,
723    default_value: Option<String>,
724    generated: Option<GeneratedSecretRequirement>,
725}
726
727#[derive(Debug, Deserialize)]
728struct AssetSecretRequirement {
729    key: Option<String>,
730    name: Option<String>,
731    #[serde(default = "default_required")]
732    required: bool,
733    #[serde(default)]
734    default_value: Option<String>,
735    #[serde(default)]
736    generated: Option<AssetGeneratedSecret>,
737}
738
739#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
740struct AssetGeneratedSecret {
741    policy: Option<String>,
742    length: Option<usize>,
743    encoding: Option<String>,
744    scope: Option<AssetGeneratedSecretScope>,
745    #[serde(default)]
746    regenerate_if_present: Option<bool>,
747}
748
749#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
750struct AssetGeneratedSecretScope {
751    level: Option<String>,
752    team: Option<String>,
753}
754
755#[derive(Debug, Deserialize)]
756struct GeneratedSecretsExtension {
757    #[serde(default)]
758    secrets: Vec<GeneratedSecretEntry>,
759}
760
761#[derive(Debug, Deserialize)]
762struct GeneratedSecretEntry {
763    key: String,
764    #[serde(default = "default_required")]
765    required: bool,
766    policy: Option<String>,
767    length: Option<usize>,
768    encoding: Option<String>,
769    scope: Option<AssetGeneratedSecretScope>,
770    #[serde(default)]
771    regenerate_if_present: Option<bool>,
772}
773
774#[derive(Debug, Deserialize)]
775struct SetupSpec {
776    #[serde(default)]
777    questions: Vec<SetupQuestion>,
778}
779
780#[derive(Debug, Deserialize)]
781struct SetupQuestion {
782    name: String,
783    #[serde(default)]
784    secret_key: Option<String>,
785    #[serde(default)]
786    default: Option<String>,
787    #[serde(default)]
788    secret: bool,
789    #[serde(default)]
790    required: bool,
791}
792
793fn parse_setup_secret_requirements(
794    contents: &str,
795    path: &Path,
796) -> Result<Vec<PackSecretRequirement>> {
797    let path_display = path.display().to_string();
798    let setup: SetupSpec = serde_yaml_bw::from_str(contents).map_err(|err| {
799        DeployerError::Config(format!("parse setup secrets from {path_display}: {err}"))
800    })?;
801    Ok(setup
802        .questions
803        .into_iter()
804        .filter(|question| question.secret)
805        .map(|question| PackSecretRequirement {
806            key: question.secret_key.unwrap_or(question.name),
807            required: question.required,
808            default_value: question.default,
809            generated: None,
810        })
811        .collect())
812}
813
814fn dedup_requirements(requirements: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
815    let mut by_key = BTreeMap::new();
816    for requirement in requirements {
817        let key = canonical_secret_name(&requirement.key);
818        by_key
819            .entry(key)
820            .and_modify(|existing: &mut PackSecretRequirement| {
821                existing.required |= requirement.required;
822                if existing.default_value.is_none() {
823                    existing.default_value = requirement.default_value.clone();
824                }
825                if existing.generated.is_none() {
826                    existing.generated = requirement.generated.clone();
827                }
828            })
829            .or_insert(requirement);
830    }
831    by_key.into_values().collect()
832}
833
834fn asset_requirement_to_pack_requirement(
835    req: AssetSecretRequirement,
836) -> Option<PackSecretRequirement> {
837    let key = req.key.or(req.name)?;
838    Some(PackSecretRequirement {
839        key,
840        required: req.required,
841        default_value: req.default_value,
842        generated: req.generated.map(|generated| GeneratedSecretRequirement {
843            policy: generated.policy.unwrap_or_else(|| "random".to_string()),
844            length: generated.length.unwrap_or(32),
845            encoding: generated
846                .encoding
847                .unwrap_or_else(|| "base64url".to_string()),
848            scope: GeneratedSecretScope {
849                level: generated
850                    .scope
851                    .as_ref()
852                    .and_then(|scope| scope.level.clone())
853                    .unwrap_or_else(|| "team".to_string()),
854                team: generated.scope.and_then(|scope| scope.team),
855            },
856            regenerate_if_present: generated.regenerate_if_present.unwrap_or(false),
857        }),
858    })
859}
860
861fn load_generated_requirements_from_dir(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
862    let manifest_cbor = pack_path.join("manifest.cbor");
863    if manifest_cbor.exists() {
864        let bytes = std::fs::read(&manifest_cbor)?;
865        let requirements = load_generated_requirements_from_manifest_cbor_bytes(&bytes)?;
866        if !requirements.is_empty() {
867            return Ok(requirements);
868        }
869    }
870    let manifest_json = pack_path.join("pack.manifest.json");
871    if manifest_json.exists() {
872        let contents = std::fs::read_to_string(&manifest_json)?;
873        return load_generated_requirements_from_manifest_json_str(&contents);
874    }
875    Ok(Vec::new())
876}
877
878fn load_generated_requirements_from_zip<R: Read + Seek>(
879    archive: &mut ZipArchive<R>,
880) -> Result<Vec<PackSecretRequirement>> {
881    match archive.by_name("manifest.cbor") {
882        Ok(mut entry) => {
883            let mut bytes = Vec::new();
884            entry.read_to_end(&mut bytes)?;
885            let requirements = load_generated_requirements_from_manifest_cbor_bytes(&bytes)?;
886            if !requirements.is_empty() {
887                return Ok(requirements);
888            }
889        }
890        Err(ZipError::FileNotFound) => {}
891        Err(err) => return Err(DeployerError::Other(err.to_string())),
892    }
893    match archive.by_name("pack.manifest.json") {
894        Ok(mut entry) => {
895            let mut contents = String::new();
896            entry.read_to_string(&mut contents)?;
897            load_generated_requirements_from_manifest_json_str(&contents)
898        }
899        Err(ZipError::FileNotFound) => Ok(Vec::new()),
900        Err(err) => Err(DeployerError::Other(err.to_string())),
901    }
902}
903
904fn load_generated_requirements_from_manifest_cbor_bytes(
905    bytes: &[u8],
906) -> Result<Vec<PackSecretRequirement>> {
907    if let Ok(manifest) = decode_pack_manifest(bytes) {
908        let Some(value) = manifest
909            .extensions
910            .as_ref()
911            .and_then(|extensions| extensions.get(EXT_GENERATED_SECRETS_V1))
912            .and_then(|extension| extension.inline.as_ref())
913        else {
914            return Ok(Vec::new());
915        };
916        let ExtensionInline::Other(value) = value else {
917            return Ok(Vec::new());
918        };
919        return parse_generated_secrets_extension(value.clone());
920    }
921
922    let Ok(value) = serde_cbor::from_slice::<CborValue>(bytes) else {
923        return Ok(Vec::new());
924    };
925    let Some(inline) = cbor_generated_extension_inline(&value) else {
926        return Ok(Vec::new());
927    };
928    let json = cbor_to_json(inline)?;
929    parse_generated_secrets_extension(json)
930}
931
932fn load_generated_requirements_from_manifest_json_str(
933    contents: &str,
934) -> Result<Vec<PackSecretRequirement>> {
935    let manifest: serde_json::Value = serde_json::from_str(contents).map_err(|err| {
936        DeployerError::Config(format!("parse pack.manifest.json generated secrets: {err}"))
937    })?;
938    let Some(value) = manifest
939        .get("extensions")
940        .and_then(|extensions| extensions.get(EXT_GENERATED_SECRETS_V1))
941        .and_then(|extension| extension.get("inline"))
942    else {
943        return Ok(Vec::new());
944    };
945    parse_generated_secrets_extension(value.clone())
946}
947
948fn parse_generated_secrets_extension(
949    value: serde_json::Value,
950) -> Result<Vec<PackSecretRequirement>> {
951    let extension: GeneratedSecretsExtension = serde_json::from_value(value).map_err(|err| {
952        DeployerError::Config(format!("parse generated secrets extension: {err}"))
953    })?;
954    Ok(extension
955        .secrets
956        .into_iter()
957        .filter(|secret| secret.required)
958        .map(|secret| PackSecretRequirement {
959            key: secret.key,
960            required: true,
961            default_value: None,
962            generated: Some(GeneratedSecretRequirement {
963                policy: secret.policy.unwrap_or_else(|| "random".to_string()),
964                length: secret.length.unwrap_or(20),
965                encoding: secret.encoding.unwrap_or_else(|| "raw_text".to_string()),
966                scope: GeneratedSecretScope {
967                    level: secret
968                        .scope
969                        .as_ref()
970                        .and_then(|scope| scope.level.clone())
971                        .unwrap_or_else(|| "tenant".to_string()),
972                    team: secret.scope.and_then(|scope| scope.team),
973                },
974                regenerate_if_present: secret.regenerate_if_present.unwrap_or(false),
975            }),
976        })
977        .collect())
978}
979
980fn cbor_generated_extension_inline(value: &CborValue) -> Option<&CborValue> {
981    let CborValue::Map(map) = value else {
982        return None;
983    };
984    let extensions = cbor_map_get(map, "extensions")?;
985    let CborValue::Map(extensions) = extensions else {
986        return None;
987    };
988    let extension = cbor_map_get(extensions, EXT_GENERATED_SECRETS_V1)?;
989    let CborValue::Map(extension) = extension else {
990        return None;
991    };
992    cbor_map_get(extension, "inline")
993}
994
995fn cbor_map_get<'a>(map: &'a BTreeMap<CborValue, CborValue>, key: &str) -> Option<&'a CborValue> {
996    map.iter().find_map(|(candidate, value)| match candidate {
997        CborValue::Text(text) if text == key => Some(value),
998        _ => None,
999    })
1000}
1001
1002fn cbor_to_json(value: &CborValue) -> Result<serde_json::Value> {
1003    match value {
1004        CborValue::Null => Ok(serde_json::Value::Null),
1005        CborValue::Bool(value) => Ok(serde_json::Value::Bool(*value)),
1006        CborValue::Integer(value) => Ok(serde_json::Value::Number(
1007            serde_json::Number::from_i128(*value).ok_or_else(|| {
1008                DeployerError::Config("generated secrets integer is out of range".to_string())
1009            })?,
1010        )),
1011        CborValue::Float(value) => serde_json::Number::from_f64(*value)
1012            .map(serde_json::Value::Number)
1013            .ok_or_else(|| DeployerError::Config("generated secrets float is invalid".to_string())),
1014        CborValue::Bytes(_) => Err(DeployerError::Config(
1015            "generated secrets extension cannot contain bytes".to_string(),
1016        )),
1017        CborValue::Text(value) => Ok(serde_json::Value::String(value.clone())),
1018        CborValue::Array(values) => values
1019            .iter()
1020            .map(cbor_to_json)
1021            .collect::<Result<Vec<_>>>()
1022            .map(serde_json::Value::Array),
1023        CborValue::Map(map) => {
1024            let mut object = serde_json::Map::new();
1025            for (key, value) in map {
1026                let CborValue::Text(key) = key else {
1027                    return Err(DeployerError::Config(
1028                        "generated secrets extension object key must be a string".to_string(),
1029                    ));
1030                };
1031                object.insert(key.clone(), cbor_to_json(value)?);
1032            }
1033            Ok(serde_json::Value::Object(object))
1034        }
1035        _ => Err(DeployerError::Config(
1036            "generated secrets extension contains unsupported CBOR value".to_string(),
1037        )),
1038    }
1039}
1040
1041fn generated_secret_value(generated: &GeneratedSecretRequirement) -> Result<String> {
1042    if !generated.policy.eq_ignore_ascii_case("random") {
1043        return Err(DeployerError::Config(format!(
1044            "unsupported generated secret policy `{}`",
1045            generated.policy
1046        )));
1047    }
1048    let length = generated.length.max(1);
1049    match generated.encoding.as_str() {
1050        "raw_text" => Ok(random_ascii(length)),
1051        "base64url" => {
1052            let mut bytes = vec![0u8; length];
1053            rand::rng().fill(&mut bytes[..]);
1054            Ok(URL_SAFE_NO_PAD.encode(bytes))
1055        }
1056        "hex" => {
1057            let mut bytes = vec![0u8; length];
1058            rand::rng().fill(&mut bytes[..]);
1059            Ok(hex::encode(bytes))
1060        }
1061        other => Err(DeployerError::Config(format!(
1062            "unsupported generated secret encoding `{other}`"
1063        ))),
1064    }
1065}
1066
1067fn random_ascii(length: usize) -> String {
1068    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
1069    let mut bytes = vec![0u8; length];
1070    rand::rng().fill(&mut bytes[..]);
1071    bytes
1072        .into_iter()
1073        .map(|byte| ALPHABET[usize::from(byte) % ALPHABET.len()] as char)
1074        .collect()
1075}
1076
1077fn resolve_from_setup_answers(
1078    bundle_root: &Path,
1079    requirement: &RuntimeSecretRequirement,
1080    checked_sources: &mut Vec<String>,
1081) -> Option<(PathBuf, String)> {
1082    for config_id in setup_answer_config_id_candidates(&requirement.source) {
1083        let path = bundle_root
1084            .join("state/config")
1085            .join(config_id)
1086            .join("setup-answers.json");
1087        checked_sources.push(path.display().to_string());
1088        let contents = match std::fs::read_to_string(&path) {
1089            Ok(contents) => contents,
1090            Err(_) => continue,
1091        };
1092        let answers = match serde_json::from_str::<BTreeMap<String, JsonValue>>(&contents) {
1093            Ok(answers) => answers,
1094            Err(_) => continue,
1095        };
1096        for (key, value) in answers {
1097            if canonical_secret_name(&key) != requirement.key {
1098                continue;
1099            }
1100            if let Some(value) = value.as_str()
1101                && !value.is_empty()
1102            {
1103                if let Some(env_key) = extract_env_placeholder(value) {
1104                    checked_sources.push(format!("env ${{{env_key}}} (from setup-answers)"));
1105                    return match env::var(&env_key) {
1106                        Ok(resolved) if !resolved.is_empty() => Some((path, resolved)),
1107                        _ => None,
1108                    };
1109                }
1110                return Some((path, value.to_string()));
1111            }
1112        }
1113    }
1114    None
1115}
1116
1117fn setup_answer_config_id_candidates(pack_path: &Path) -> Vec<String> {
1118    let Some(config_id) = config_id_from_pack_path(pack_path) else {
1119        return Vec::new();
1120    };
1121    let mut candidates = vec![config_id.clone()];
1122    if let Some((base, _)) = config_id.split_once("-gtpack-sha-")
1123        && !base.is_empty()
1124    {
1125        candidates.push(base.to_string());
1126    }
1127    candidates
1128}
1129
1130// Parse a whole-string `${VAR}` placeholder and return `VAR`.
1131// `greentic-setup` persists unresolved env-var references in setup-answers.json
1132// when a non-interactive run cannot prompt. Without expansion here, those
1133// placeholders would propagate to the cloud secrets store verbatim and break
1134// providers that try to use the value (e.g. state-redis treating `${REDIS_URL}`
1135// as a connection string).
1136fn extract_env_placeholder(value: &str) -> Option<String> {
1137    let trimmed = value.trim();
1138    let inner = trimmed.strip_prefix("${")?.strip_suffix('}')?;
1139    if inner.is_empty() || inner.contains(|c: char| c.is_whitespace() || c == '$' || c == '{') {
1140        return None;
1141    }
1142    Some(inner.to_string())
1143}
1144
1145fn default_required() -> bool {
1146    true
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152
1153    #[test]
1154    fn extract_env_placeholder_matches_whole_string_dollar_brace_form() {
1155        assert_eq!(
1156            extract_env_placeholder("${REDIS_URL}").as_deref(),
1157            Some("REDIS_URL")
1158        );
1159        assert_eq!(
1160            extract_env_placeholder("${OPENAI_API_KEY}").as_deref(),
1161            Some("OPENAI_API_KEY")
1162        );
1163        assert_eq!(
1164            extract_env_placeholder("  ${PUBLIC_BASE_URL}  ").as_deref(),
1165            Some("PUBLIC_BASE_URL"),
1166            "surrounding whitespace is allowed"
1167        );
1168    }
1169
1170    #[test]
1171    fn extract_env_placeholder_rejects_partial_or_malformed_patterns() {
1172        assert_eq!(extract_env_placeholder("redis://host:6379/0"), None);
1173        assert_eq!(extract_env_placeholder("prefix-${VAR}"), None);
1174        assert_eq!(extract_env_placeholder("${VAR}-suffix"), None);
1175        assert_eq!(extract_env_placeholder("${}"), None);
1176        assert_eq!(extract_env_placeholder("${VAR WITH SPACE}"), None);
1177        assert_eq!(extract_env_placeholder("${NESTED${INNER}}"), None);
1178    }
1179
1180    #[test]
1181    fn setup_answer_config_id_candidates_include_gtpack_sha_base_alias() {
1182        let candidates = setup_answer_config_id_candidates(Path::new(
1183            "/tmp/packs/deep-research-demo-gtpack-sha-abc123.gtpack",
1184        ));
1185        assert_eq!(
1186            candidates,
1187            vec![
1188                "deep-research-demo-gtpack-sha-abc123".to_string(),
1189                "deep-research-demo".to_string(),
1190            ]
1191        );
1192    }
1193
1194    #[test]
1195    fn canonical_env_key_matches_start_runtime_shape() {
1196        assert_eq!(
1197            canonical_secret_store_key("secrets://dev/demo/_/openai/api_key").as_deref(),
1198            Some("GREENTIC_SECRET__DEV__DEMO_____OPENAI__API_KEY")
1199        );
1200    }
1201
1202    #[test]
1203    fn cloud_secret_name_is_stable_and_normalized() {
1204        assert_eq!(
1205            cloud_secret_name(
1206                "greentic/dev/demo/_",
1207                "messaging-telegram",
1208                "TELEGRAM_BOT_TOKEN"
1209            ),
1210            "greentic/dev/demo/_/messaging_telegram/telegram_bot_token"
1211        );
1212    }
1213
1214    #[test]
1215    fn requirement_uri_normalizes_provider_segment() {
1216        let dir = tempfile::tempdir().unwrap();
1217        let pack_dir = dir.path().join("packs/messaging-webchat-gui/assets");
1218        std::fs::create_dir_all(&pack_dir).unwrap();
1219        std::fs::write(
1220            pack_dir.join("secret-requirements.json"),
1221            r#"[{"key":"jwt_signing_key","required":true}]"#,
1222        )
1223        .unwrap();
1224
1225        let ctx = RuntimeSecretContext {
1226            bundle_root: dir.path().to_path_buf(),
1227            pack_paths: vec![dir.path().join("packs/messaging-webchat-gui")],
1228            environment: "dev".into(),
1229            tenant: "demo".into(),
1230            team: None,
1231        };
1232
1233        let requirements = collect_requirements(&ctx).unwrap();
1234        assert_eq!(requirements.len(), 1);
1235        assert_eq!(requirements[0].provider_id, "messaging-webchat-gui");
1236        assert_eq!(
1237            requirements[0].uri,
1238            "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1239        );
1240        assert_eq!(
1241            cloud_secret_name(
1242                "greentic/dev/demo/_",
1243                &requirements[0].provider_id,
1244                &requirements[0].key
1245            ),
1246            "greentic/dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1247        );
1248    }
1249
1250    #[test]
1251    fn collect_requirements_discovers_generated_secret_from_manifest_cbor_extension() {
1252        use greentic_types::{
1253            ExtensionInline, ExtensionRef, PackId, PackKind, PackManifest, PackSignatures,
1254            encode_pack_manifest,
1255        };
1256        use semver::Version;
1257        use serde_json::json;
1258        use std::io::Write;
1259        use zip::write::FileOptions;
1260
1261        let dir = tempfile::tempdir().unwrap();
1262        let pack = dir.path().join("packs/messaging-webchat-gui.gtpack");
1263        std::fs::create_dir_all(pack.parent().unwrap()).unwrap();
1264        let mut extensions = BTreeMap::new();
1265        extensions.insert(
1266            "greentic.generated-secrets.v1".to_string(),
1267            ExtensionRef {
1268                kind: "greentic.generated-secrets.v1".to_string(),
1269                version: "1".to_string(),
1270                digest: None,
1271                location: None,
1272                inline: Some(ExtensionInline::Other(json!({
1273                    "secrets": [{
1274                        "key": "jwt_signing_key",
1275                        "aliases": ["JWT_SIGNING_KEY"],
1276                        "required": true,
1277                        "policy": "random",
1278                        "length": 20,
1279                        "encoding": "raw_text",
1280                        "scope": {"level": "tenant", "team": "_"},
1281                        "regenerate_if_present": false
1282                    }]
1283                }))),
1284            },
1285        );
1286        let manifest = PackManifest {
1287            schema_version: "1".to_string(),
1288            pack_id: PackId::new("messaging-webchat-gui").unwrap(),
1289            name: None,
1290            version: Version::parse("0.0.0").unwrap(),
1291            kind: PackKind::Provider,
1292            publisher: "test".to_string(),
1293            components: Vec::new(),
1294            flows: Vec::new(),
1295            dependencies: Vec::new(),
1296            capabilities: Vec::new(),
1297            secret_requirements: Vec::new(),
1298            signatures: PackSignatures::default(),
1299            bootstrap: None,
1300            extensions: Some(extensions),
1301        };
1302        let file = File::create(&pack).unwrap();
1303        let mut zip = zip::ZipWriter::new(file);
1304        zip.start_file("manifest.cbor", FileOptions::<()>::default())
1305            .unwrap();
1306        zip.write_all(&encode_pack_manifest(&manifest).unwrap())
1307            .unwrap();
1308        zip.finish().unwrap();
1309
1310        let ctx = RuntimeSecretContext {
1311            bundle_root: dir.path().to_path_buf(),
1312            pack_paths: vec![pack],
1313            environment: "dev".into(),
1314            tenant: "demo".into(),
1315            team: Some("default".into()),
1316        };
1317
1318        let requirements = collect_requirements(&ctx).unwrap();
1319        assert_eq!(requirements.len(), 1);
1320        assert_eq!(
1321            requirements[0].uri,
1322            "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1323        );
1324        assert_eq!(requirements[0].key, "jwt_signing_key");
1325        assert!(requirements[0].generated.is_some());
1326    }
1327
1328    #[test]
1329    fn collect_requirements_discovers_generated_secret_from_pack_manifest_json_extension() {
1330        use std::io::Write;
1331        use zip::write::FileOptions;
1332
1333        let dir = tempfile::tempdir().unwrap();
1334        let pack = dir.path().join("packs/messaging-webchat-gui.gtpack");
1335        std::fs::create_dir_all(pack.parent().unwrap()).unwrap();
1336        let file = File::create(&pack).unwrap();
1337        let mut zip = zip::ZipWriter::new(file);
1338        zip.start_file("pack.manifest.json", FileOptions::<()>::default())
1339            .unwrap();
1340        zip.write_all(
1341            br#"{
1342                "extensions": {
1343                    "greentic.generated-secrets.v1": {
1344                        "inline": {
1345                            "secrets": [{
1346                                "key": "jwt_signing_key",
1347                                "policy": "random",
1348                                "length": 20,
1349                                "encoding": "raw_text",
1350                                "scope": {"level": "tenant", "team": "_"}
1351                            }]
1352                        }
1353                    }
1354                }
1355            }"#,
1356        )
1357        .unwrap();
1358        zip.finish().unwrap();
1359
1360        let ctx = RuntimeSecretContext {
1361            bundle_root: dir.path().to_path_buf(),
1362            pack_paths: vec![pack],
1363            environment: "dev".into(),
1364            tenant: "demo".into(),
1365            team: Some("default".into()),
1366        };
1367
1368        let requirements = collect_requirements(&ctx).unwrap();
1369        assert_eq!(requirements.len(), 1);
1370        assert_eq!(
1371            requirements[0].uri,
1372            "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key"
1373        );
1374        assert!(requirements[0].generated.is_some());
1375    }
1376
1377    #[test]
1378    fn runtime_secret_env_map_omits_generated_secret_env_aliases_for_cloud_binding() {
1379        use greentic_types::{
1380            ExtensionInline, ExtensionRef, PackId, PackKind, PackManifest, PackSignatures,
1381            encode_pack_manifest,
1382        };
1383        use semver::Version;
1384        use serde_json::json;
1385        use std::io::Write;
1386        use zip::write::FileOptions;
1387
1388        let dir = tempfile::tempdir().unwrap();
1389        let bundle_root = dir.path();
1390        let pack = bundle_root.join("packs/messaging-webchat-gui.gtpack");
1391        std::fs::create_dir_all(pack.parent().unwrap()).unwrap();
1392        let mut extensions = BTreeMap::new();
1393        extensions.insert(
1394            "greentic.generated-secrets.v1".to_string(),
1395            ExtensionRef {
1396                kind: "greentic.generated-secrets.v1".to_string(),
1397                version: "1".to_string(),
1398                digest: None,
1399                location: None,
1400                inline: Some(ExtensionInline::Other(json!({
1401                    "secrets": [{
1402                        "key": "jwt_signing_key",
1403                        "policy": "random",
1404                        "length": 20,
1405                        "encoding": "raw_text",
1406                        "scope": {"level": "tenant", "team": "_"}
1407                    }]
1408                }))),
1409            },
1410        );
1411        let manifest = PackManifest {
1412            schema_version: "1".to_string(),
1413            pack_id: PackId::new("messaging-webchat-gui").unwrap(),
1414            name: None,
1415            version: Version::parse("0.0.0").unwrap(),
1416            kind: PackKind::Provider,
1417            publisher: "test".to_string(),
1418            components: Vec::new(),
1419            flows: Vec::new(),
1420            dependencies: Vec::new(),
1421            capabilities: Vec::new(),
1422            secret_requirements: Vec::new(),
1423            signatures: PackSignatures::default(),
1424            bootstrap: None,
1425            extensions: Some(extensions),
1426        };
1427        let file = File::create(&pack).unwrap();
1428        let mut zip = zip::ZipWriter::new(file);
1429        zip.start_file("manifest.cbor", FileOptions::<()>::default())
1430            .unwrap();
1431        zip.write_all(&encode_pack_manifest(&manifest).unwrap())
1432            .unwrap();
1433        zip.finish().unwrap();
1434
1435        let config = DeployerConfig {
1436            capability: DeployerCapability::Apply,
1437            provider: Provider::Aws,
1438            strategy: "iac-only".into(),
1439            tenant: "demo".into(),
1440            environment: "dev".into(),
1441            pack_path: pack.clone(),
1442            bundle_root: Some(bundle_root.to_path_buf()),
1443            providers_dir: PathBuf::from("providers/deployer"),
1444            packs_dir: PathBuf::from("packs"),
1445            provider_pack: None,
1446            pack_ref: None,
1447            distributor_url: None,
1448            distributor_token: None,
1449            preview: false,
1450            dry_run: false,
1451            execute_local: true,
1452            output: crate::config::OutputFormat::Json,
1453            greentic: greentic_config::ConfigResolver::new()
1454                .load()
1455                .unwrap()
1456                .config,
1457            provenance: greentic_config::ProvenanceMap::new(),
1458            config_warnings: Vec::new(),
1459            deploy_pack_id_override: None,
1460            deploy_flow_id_override: None,
1461            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1462            bundle_digest: Some(
1463                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
1464            ),
1465            repo_registry_base: None,
1466            store_registry_base: None,
1467        };
1468
1469        let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
1470        assert!(env_map.is_empty());
1471    }
1472
1473    #[tokio::test]
1474    async fn cloud_apply_resolution_generates_generated_secret_without_setup_answer() {
1475        let dir = tempfile::tempdir().unwrap();
1476        let pack = dir.path().join("packs/messaging-webchat-gui");
1477        std::fs::create_dir_all(pack.join("assets")).unwrap();
1478        std::fs::write(
1479            pack.join("assets/secret-requirements.json"),
1480            r#"[{
1481                "key":"jwt_signing_key",
1482                "required":true,
1483                "generated":{
1484                    "policy":"random",
1485                    "length":20,
1486                    "encoding":"raw_text",
1487                    "scope":{"level":"tenant","team":"_"},
1488                    "regenerate_if_present":false
1489                }
1490            }]"#,
1491        )
1492        .unwrap();
1493
1494        let ctx = RuntimeSecretContext {
1495            bundle_root: dir.path().to_path_buf(),
1496            pack_paths: vec![pack],
1497            environment: "dev".into(),
1498            tenant: "demo".into(),
1499            team: Some("default".into()),
1500        };
1501        let requirements = collect_requirements(&ctx).unwrap();
1502        let resolution = resolve_runtime_secrets(&ctx, &requirements).await;
1503
1504        assert!(resolution.missing.is_empty());
1505        assert_eq!(resolution.resolved.len(), 1);
1506        assert_eq!(resolution.resolved[0].value.expose().len(), 20);
1507        assert!(matches!(
1508            resolution.resolved[0].source,
1509            SecretValueSource::Generated
1510        ));
1511    }
1512
1513    #[tokio::test]
1514    async fn cloud_apply_resolution_slides_optional_secret_without_local_value() {
1515        // An optional secret with only a setup.yaml default must NOT promote
1516        // that default to the cloud secrets manager (the silent-wrong-value
1517        // path is gone). With no value in the local secrets manager it is
1518        // neither resolved nor reported missing — it is left to slide.
1519        let dir = tempfile::tempdir().unwrap();
1520        let pack = dir.path().join("packs/deep-research-demo");
1521        std::fs::create_dir_all(pack.join("assets")).unwrap();
1522        std::fs::write(
1523            pack.join("assets/setup.yaml"),
1524            r#"
1525questions:
1526  - name: api_key_secret
1527    secret_key: api_key_secret
1528    secret: true
1529    required: false
1530    default: ollama-placeholder
1531"#,
1532        )
1533        .unwrap();
1534
1535        let ctx = RuntimeSecretContext {
1536            bundle_root: dir.path().to_path_buf(),
1537            pack_paths: vec![pack],
1538            environment: "dev".into(),
1539            tenant: "demo".into(),
1540            team: None,
1541        };
1542        let requirements = collect_requirements(&ctx).unwrap();
1543        let resolution = resolve_runtime_secrets(&ctx, &requirements).await;
1544
1545        assert!(
1546            resolution.resolved.is_empty(),
1547            "optional secret must not resolve from a setup.yaml default: {:?}",
1548            resolution.resolved
1549        );
1550        assert!(
1551            resolution.missing.is_empty(),
1552            "optional secret must not be reported missing"
1553        );
1554    }
1555
1556    #[tokio::test]
1557    async fn cloud_apply_resolution_moves_generated_secret_from_local_store() {
1558        // When setup has already introduced the generated secret into the local
1559        // store, the deployer MOVES that exact value (DevStore source) rather
1560        // than generating a fresh, divergent one.
1561        use greentic_secrets_lib::{DevStore, SecretFormat};
1562        let dir = tempfile::tempdir().unwrap();
1563        let pack = dir.path().join("packs/messaging-webchat-gui");
1564        std::fs::create_dir_all(pack.join("assets")).unwrap();
1565        std::fs::write(
1566            pack.join("assets/secret-requirements.json"),
1567            r#"[{
1568                "key":"jwt_signing_key",
1569                "required":true,
1570                "generated":{
1571                    "policy":"random",
1572                    "length":20,
1573                    "encoding":"raw_text",
1574                    "scope":{"level":"tenant","team":"_"},
1575                    "regenerate_if_present":false
1576                }
1577            }]"#,
1578        )
1579        .unwrap();
1580
1581        let uri = "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key";
1582        let store_path = dir.path().join(".greentic/state/dev/.dev.secrets.env");
1583        std::fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1584        let store = DevStore::with_path(&store_path).unwrap();
1585        store
1586            .put(uri, SecretFormat::Text, b"setup-introduced-key")
1587            .await
1588            .unwrap();
1589
1590        let ctx = RuntimeSecretContext {
1591            bundle_root: dir.path().to_path_buf(),
1592            pack_paths: vec![pack],
1593            environment: "dev".into(),
1594            tenant: "demo".into(),
1595            team: None,
1596        };
1597        let requirements = collect_requirements(&ctx).unwrap();
1598        let resolution = resolve_runtime_secrets(&ctx, &requirements).await;
1599
1600        assert!(resolution.missing.is_empty());
1601        assert_eq!(resolution.resolved.len(), 1);
1602        assert_eq!(
1603            resolution.resolved[0].value.expose(),
1604            "setup-introduced-key",
1605            "the local-store value must be moved, not regenerated"
1606        );
1607        assert!(matches!(
1608            resolution.resolved[0].source,
1609            SecretValueSource::DevStore { .. }
1610        ));
1611    }
1612
1613    #[test]
1614    fn generated_secret_value_supports_start_encodings() {
1615        let raw = generated_secret_value(&GeneratedSecretRequirement {
1616            policy: "random".into(),
1617            length: 20,
1618            encoding: "raw_text".into(),
1619            scope: GeneratedSecretScope {
1620                level: "tenant".into(),
1621                team: Some("_".into()),
1622            },
1623            regenerate_if_present: false,
1624        })
1625        .unwrap();
1626        assert_eq!(raw.len(), 20);
1627
1628        let b64 = generated_secret_value(&GeneratedSecretRequirement {
1629            policy: "random".into(),
1630            length: 20,
1631            encoding: "base64url".into(),
1632            scope: GeneratedSecretScope {
1633                level: "tenant".into(),
1634                team: Some("_".into()),
1635            },
1636            regenerate_if_present: false,
1637        })
1638        .unwrap();
1639        assert!(!b64.contains('+'));
1640        assert!(!b64.contains('/'));
1641        assert!(!b64.contains('='));
1642
1643        let hex = generated_secret_value(&GeneratedSecretRequirement {
1644            policy: "random".into(),
1645            length: 20,
1646            encoding: "hex".into(),
1647            scope: GeneratedSecretScope {
1648                level: "tenant".into(),
1649                team: Some("_".into()),
1650            },
1651            regenerate_if_present: false,
1652        })
1653        .unwrap();
1654        assert_eq!(hex.len(), 40);
1655        assert!(hex.chars().all(|ch| ch.is_ascii_hexdigit()));
1656    }
1657
1658    #[test]
1659    fn flat_secret_name_limits_length_with_digest() {
1660        let name = flat_cloud_secret_name(
1661            "greentic/dev/demo/default",
1662            "very-long-provider-name",
1663            "THIS_IS_A_VERY_LONG_SECRET_NAME",
1664            40,
1665        );
1666        assert!(name.len() <= 40);
1667        assert!(name.starts_with("greentic-dev-demo-default"));
1668    }
1669
1670    #[test]
1671    fn infers_bundle_root_from_pack_path_under_packs_dir() {
1672        let path = Path::new("/tmp/demo-bundle/packs/app.gtpack");
1673        assert_eq!(
1674            infer_bundle_root_from_pack_path(path).as_deref(),
1675            Some(Path::new("/tmp/demo-bundle"))
1676        );
1677    }
1678
1679    #[test]
1680    fn skips_non_zip_gtpack_when_scanning_secret_requirements() {
1681        let dir = tempfile::tempdir().unwrap();
1682        let pack = dir.path().join("aws.gtpack");
1683        std::fs::write(&pack, b"not a zip").unwrap();
1684        let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1685        assert!(reqs.is_empty());
1686    }
1687
1688    #[test]
1689    fn reads_secret_requirements_from_tar_gtpack() {
1690        let dir = tempfile::tempdir().unwrap();
1691        let pack = dir.path().join("provider.gtpack");
1692        let file = File::create(&pack).unwrap();
1693        let mut builder = tar::Builder::new(file);
1694        let contents = br#"[{"key":"API_TOKEN","required":true}]"#;
1695        let mut header = tar::Header::new_gnu();
1696        header.set_path("assets/secret-requirements.json").unwrap();
1697        header.set_size(contents.len() as u64);
1698        header.set_cksum();
1699        builder
1700            .append(&header, contents.as_slice())
1701            .expect("append tar entry");
1702        builder.finish().unwrap();
1703
1704        let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1705        assert_eq!(reqs.len(), 1);
1706        assert_eq!(reqs[0].key, "API_TOKEN");
1707    }
1708
1709    #[test]
1710    fn reads_secret_requirements_from_setup_yaml() {
1711        let dir = tempfile::tempdir().unwrap();
1712        let pack = dir.path().join("pack");
1713        std::fs::create_dir_all(pack.join("assets")).unwrap();
1714        std::fs::write(
1715            pack.join("assets/setup.yaml"),
1716            r#"
1717questions:
1718  - name: api_key
1719    secret: true
1720    required: true
1721  - name: display_name
1722    secret: false
1723"#,
1724        )
1725        .unwrap();
1726
1727        let reqs = load_secret_requirements_from_pack(&pack).unwrap();
1728        assert_eq!(reqs.len(), 1);
1729        assert_eq!(reqs[0].key, "api_key");
1730        assert!(reqs[0].required);
1731    }
1732
1733    #[test]
1734    fn discovers_provider_pack_paths() {
1735        let dir = tempfile::tempdir().unwrap();
1736        std::fs::create_dir_all(dir.path().join("providers/messaging")).unwrap();
1737        std::fs::write(
1738            dir.path()
1739                .join("providers/messaging/messaging-webchat-gui.gtpack"),
1740            b"",
1741        )
1742        .unwrap();
1743
1744        let paths = discover_bundle_pack_paths(dir.path()).unwrap();
1745        assert_eq!(paths.len(), 1);
1746        assert!(paths[0].ends_with("messaging-webchat-gui.gtpack"));
1747    }
1748
1749    #[tokio::test]
1750    async fn cloud_apply_resolution_filters_secrets_provider_packs_by_target() {
1751        let dir = tempfile::tempdir().unwrap();
1752        let bundle_root = dir.path();
1753        let app_pack = bundle_root.join("packs/greentic-main-website");
1754        std::fs::create_dir_all(&app_pack).unwrap();
1755        std::fs::write(app_pack.join("pack.yaml"), "id: greentic-main-website\n").unwrap();
1756
1757        let aws_provider = bundle_root.join("providers/secrets/aws-sm");
1758        std::fs::create_dir_all(aws_provider.join("assets")).unwrap();
1759        std::fs::write(
1760            aws_provider.join("pack.yaml"),
1761            "id: greentic.secrets.aws-sm\n",
1762        )
1763        .unwrap();
1764        std::fs::write(
1765            aws_provider.join("assets/secret-requirements.json"),
1766            r#"[{
1767                "key":"aws_runtime_probe",
1768                "required":true,
1769                "generated":{
1770                    "policy":"random",
1771                    "length":20,
1772                    "encoding":"raw_text",
1773                    "scope":{"level":"tenant","team":"_"}
1774                }
1775            }]"#,
1776        )
1777        .unwrap();
1778
1779        for (provider_dir, key) in [
1780            ("gcp-sm", "gcp_project_credentials"),
1781            ("azure-kv", "azure_key_vault_credentials"),
1782        ] {
1783            let inactive_provider = bundle_root.join("providers/secrets").join(provider_dir);
1784            std::fs::create_dir_all(inactive_provider.join("assets")).unwrap();
1785            std::fs::write(
1786                inactive_provider.join("pack.yaml"),
1787                format!("id: greentic.secrets.{provider_dir}\n"),
1788            )
1789            .unwrap();
1790            std::fs::write(
1791                inactive_provider.join("assets/secret-requirements.json"),
1792                format!(r#"[{{"key":"{key}","required":true}}]"#),
1793            )
1794            .unwrap();
1795        }
1796
1797        let config = DeployerConfig {
1798            capability: DeployerCapability::Apply,
1799            provider: Provider::Aws,
1800            strategy: "iac-only".into(),
1801            tenant: "demo".into(),
1802            environment: "dev".into(),
1803            pack_path: app_pack,
1804            bundle_root: Some(bundle_root.to_path_buf()),
1805            providers_dir: PathBuf::from("providers/deployer"),
1806            packs_dir: PathBuf::from("packs"),
1807            provider_pack: None,
1808            pack_ref: None,
1809            distributor_url: None,
1810            distributor_token: None,
1811            preview: false,
1812            dry_run: false,
1813            execute_local: true,
1814            output: crate::config::OutputFormat::Json,
1815            greentic: greentic_config::ConfigResolver::new()
1816                .load()
1817                .unwrap()
1818                .config,
1819            provenance: greentic_config::ProvenanceMap::new(),
1820            config_warnings: Vec::new(),
1821            deploy_pack_id_override: None,
1822            deploy_flow_id_override: None,
1823            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1824            bundle_digest: Some(
1825                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
1826            ),
1827            repo_registry_base: None,
1828            store_registry_base: None,
1829        };
1830
1831        let resolution = resolve_for_cloud_apply(&config)
1832            .await
1833            .expect("resolve AWS cloud runtime secrets")
1834            .expect("runtime secrets should be present");
1835        let resolved_uris = resolution
1836            .resolved
1837            .iter()
1838            .map(|secret| secret.requirement.uri.as_str())
1839            .collect::<Vec<_>>();
1840
1841        assert_eq!(
1842            resolved_uris,
1843            vec!["secrets://dev/demo/_/aws_sm/aws_runtime_probe"]
1844        );
1845        assert!(resolution.missing.is_empty());
1846    }
1847
1848    #[tokio::test]
1849    async fn resolves_secret_values_from_setup_answers() {
1850        let dir = tempfile::tempdir().unwrap();
1851        let answers_dir = dir.path().join("state/config/demo-pack");
1852        std::fs::create_dir_all(&answers_dir).unwrap();
1853        std::fs::write(
1854            answers_dir.join("setup-answers.json"),
1855            r#"{"api_key":"secret-value"}"#,
1856        )
1857        .unwrap();
1858        let ctx = RuntimeSecretContext {
1859            bundle_root: dir.path().to_path_buf(),
1860            pack_paths: Vec::new(),
1861            environment: "dev".into(),
1862            tenant: "demo".into(),
1863            team: None,
1864        };
1865        let requirement = RuntimeSecretRequirement {
1866            uri: canonical_secret_uri("dev", "demo", None, "demo_pack", "api_key"),
1867            provider_id: "demo_pack".into(),
1868            key: "api_key".into(),
1869            required: true,
1870            default_value: None,
1871            generated: None,
1872            source: dir.path().join("packs/demo-pack.gtpack"),
1873        };
1874
1875        let resolution = resolve_runtime_secrets(&ctx, &[requirement]).await;
1876        assert!(resolution.missing.is_empty());
1877        assert_eq!(resolution.resolved.len(), 1);
1878        assert_eq!(resolution.resolved[0].value.expose(), "secret-value");
1879        assert!(matches!(
1880            resolution.resolved[0].source,
1881            SecretValueSource::SetupAnswers { .. }
1882        ));
1883    }
1884
1885    #[test]
1886    fn runtime_secret_env_map_omits_explicit_secret_env_aliases_for_cloud_binding() {
1887        let dir = tempfile::tempdir().unwrap();
1888        let bundle_root = dir.path();
1889        let packs_dir = bundle_root.join("packs");
1890        let config_dir = bundle_root.join("state/config/demo-app");
1891        std::fs::create_dir_all(packs_dir.join("demo-app/assets")).unwrap();
1892        std::fs::create_dir_all(&config_dir).unwrap();
1893        std::fs::write(
1894            packs_dir.join("demo-app/assets/setup.yaml"),
1895            r#"
1896questions:
1897  - name: api_key
1898    secret: true
1899    required: false
1900  - name: oauth_client_secret
1901    secret: true
1902    required: false
1903  - name: jwt_signing_key
1904    secret: true
1905    required: true
1906"#,
1907        )
1908        .unwrap();
1909        std::fs::write(
1910            config_dir.join("setup-answers.json"),
1911            r#"{"api_key":"secret-value"}"#,
1912        )
1913        .unwrap();
1914
1915        let config = DeployerConfig {
1916            capability: DeployerCapability::Apply,
1917            provider: Provider::Aws,
1918            strategy: "iac-only".into(),
1919            tenant: "demo".into(),
1920            environment: "dev".into(),
1921            pack_path: packs_dir.join("demo-app"),
1922            bundle_root: Some(bundle_root.to_path_buf()),
1923            providers_dir: PathBuf::from("providers/deployer"),
1924            packs_dir: PathBuf::from("packs"),
1925            provider_pack: None,
1926            pack_ref: None,
1927            distributor_url: None,
1928            distributor_token: None,
1929            preview: false,
1930            dry_run: false,
1931            execute_local: true,
1932            output: crate::config::OutputFormat::Json,
1933            greentic: greentic_config::ConfigResolver::new()
1934                .load()
1935                .unwrap()
1936                .config,
1937            provenance: greentic_config::ProvenanceMap::new(),
1938            config_warnings: Vec::new(),
1939            deploy_pack_id_override: None,
1940            deploy_flow_id_override: None,
1941            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1942            bundle_digest: Some(
1943                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
1944            ),
1945            repo_registry_base: None,
1946            store_registry_base: None,
1947        };
1948
1949        let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
1950        assert!(env_map.is_empty());
1951    }
1952
1953    #[test]
1954    fn runtime_secret_env_map_omits_optional_setup_secret_default_for_cloud_binding() {
1955        let dir = tempfile::tempdir().unwrap();
1956        let bundle_root = dir.path();
1957        let packs_dir = bundle_root.join("packs");
1958        std::fs::create_dir_all(packs_dir.join("deep-research-demo/assets")).unwrap();
1959        std::fs::write(
1960            packs_dir.join("deep-research-demo/assets/setup.yaml"),
1961            r#"
1962questions:
1963  - name: api_key_secret
1964    secret_key: api_key_secret
1965    secret: true
1966    required: false
1967    default: ollama-placeholder
1968"#,
1969        )
1970        .unwrap();
1971
1972        let config = DeployerConfig {
1973            capability: DeployerCapability::Apply,
1974            provider: Provider::Aws,
1975            strategy: "iac-only".into(),
1976            tenant: "demo".into(),
1977            environment: "dev".into(),
1978            pack_path: packs_dir.join("deep-research-demo"),
1979            bundle_root: Some(bundle_root.to_path_buf()),
1980            providers_dir: PathBuf::from("providers/deployer"),
1981            packs_dir: PathBuf::from("packs"),
1982            provider_pack: None,
1983            pack_ref: None,
1984            distributor_url: None,
1985            distributor_token: None,
1986            preview: false,
1987            dry_run: false,
1988            execute_local: true,
1989            output: crate::config::OutputFormat::Json,
1990            greentic: greentic_config::ConfigResolver::new()
1991                .load()
1992                .unwrap()
1993                .config,
1994            provenance: greentic_config::ProvenanceMap::new(),
1995            config_warnings: Vec::new(),
1996            deploy_pack_id_override: None,
1997            deploy_flow_id_override: None,
1998            bundle_source: Some("file:///tmp/demo.gtbundle".into()),
1999            bundle_digest: Some(
2000                "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into(),
2001            ),
2002            repo_registry_base: None,
2003            store_registry_base: None,
2004        };
2005
2006        let env_map = runtime_secret_env_map_for_cloud(&config).unwrap();
2007        assert!(env_map.is_empty());
2008    }
2009
2010    #[test]
2011    fn secret_value_debug_is_redacted() {
2012        let value = SecretValue("super-secret".to_string());
2013        assert_eq!(format!("{value:?}"), "<redacted>");
2014    }
2015}