1use std::collections::BTreeMap;
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Result, anyhow};
11use greentic_secrets_lib::core::Error as SecretError;
12use greentic_secrets_lib::{
13 ApplyOptions, DevStore, SecretFormat, SecretsStore, SeedDoc, SeedEntry, SeedValue, apply_seed,
14};
15use serde_cbor::Value as CborValue;
16use tracing::{debug, info};
17
18use crate::canonical_secret_uri;
19
20const STORE_RELATIVE: &str = ".greentic/dev/.dev.secrets.env";
23const STORE_STATE_RELATIVE: &str = ".greentic/state/dev/.dev.secrets.env";
24const OVERRIDE_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
25
26pub fn override_path() -> Option<PathBuf> {
28 assert_store_access_is_guarded();
29 std::env::var(OVERRIDE_ENV).ok().map(PathBuf::from)
30}
31
32#[cfg(test)]
42fn assert_store_access_is_guarded() {
43 assert!(
44 test_support::store_access_is_guarded(),
45 "dev secrets store resolved without a `secrets::test_support` guard — \
46 start this test with `let _store = crate::secrets::test_support::isolated_store();` \
47 (or `lock_env()` when it only reads path resolution)"
48 );
49}
50
51#[cfg(not(test))]
52#[inline]
53fn assert_store_access_is_guarded() {}
54
55pub fn env_store_dev_secrets_path(env: &str) -> Option<PathBuf> {
65 greentic_deployer::environment::LocalFsStore::default_root()
66 .map(|root| root.join(env).join(STORE_RELATIVE))
67}
68
69fn selected_env() -> Option<String> {
78 std::env::var("GREENTIC_ENV")
79 .ok()
80 .filter(|value| !value.trim().is_empty())
81 .map(|raw| crate::resolve_env(Some(&raw)))
82}
83
84fn write_path_for_env(bundle_root: &Path, env: &str) -> PathBuf {
87 env_store_dev_secrets_path(env).unwrap_or_else(|| bundle_root.join(STORE_RELATIVE))
88}
89
90fn bare_write_path(bundle_root: &Path) -> PathBuf {
93 selected_env()
94 .and_then(|env| env_store_dev_secrets_path(&env))
95 .unwrap_or_else(|| bundle_root.join(STORE_RELATIVE))
96}
97
98fn read_candidate_paths(bundle_root: &Path) -> Vec<PathBuf> {
101 let mut out = Vec::new();
102 if let Some(env_store) = selected_env().and_then(|env| env_store_dev_secrets_path(&env)) {
103 out.push(env_store);
104 }
105 out.push(bundle_root.join(STORE_RELATIVE));
106 out.push(bundle_root.join(STORE_STATE_RELATIVE));
107 out
108}
109
110pub fn find_existing(bundle_root: &Path) -> Option<PathBuf> {
112 find_existing_with_override(bundle_root, override_path().as_deref())
113}
114
115pub fn find_existing_with_override(
118 bundle_root: &Path,
119 override_path: Option<&Path>,
120) -> Option<PathBuf> {
121 if let Some(path) = override_path
122 && path.exists()
123 {
124 return Some(path.to_path_buf());
125 }
126 read_candidate_paths(bundle_root)
127 .into_iter()
128 .find(|candidate| candidate.exists())
129}
130
131pub fn ensure_path(bundle_root: &Path) -> Result<PathBuf> {
134 if let Some(path) = override_path() {
135 ensure_parent(&path)?;
136 return Ok(path);
137 }
138 let path = bare_write_path(bundle_root);
139 ensure_parent(&path)?;
140 Ok(path)
141}
142
143pub fn ensure_path_for_env(bundle_root: &Path, env: &str) -> Result<PathBuf> {
147 if let Some(path) = override_path() {
148 ensure_parent(&path)?;
149 return Ok(path);
150 }
151 let path = write_path_for_env(bundle_root, env);
152 ensure_parent(&path)?;
153 Ok(path)
154}
155
156pub fn default_path(bundle_root: &Path) -> PathBuf {
158 override_path().unwrap_or_else(|| bare_write_path(bundle_root))
159}
160
161fn ensure_parent(path: &Path) -> Result<()> {
162 if let Some(parent) = path.parent() {
163 std::fs::create_dir_all(parent)?;
164 }
165 Ok(())
166}
167
168pub struct SecretsSetup {
175 store: DevStore,
176 store_path: PathBuf,
177 env: String,
178 tenant: String,
179 team: Option<String>,
180 seeds: HashMap<String, SeedEntry>,
181}
182
183impl SecretsSetup {
184 pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
185 let store_path = ensure_path_for_env(bundle_root, env)?;
191 info!(path = %store_path.display(), "secrets: using dev store backend");
192 let store = DevStore::with_path(&store_path).map_err(|err| {
193 anyhow!(
194 "failed to open dev secrets store {}: {err}",
195 store_path.display()
196 )
197 })?;
198 let seeds = load_seed_entries(bundle_root)?;
199 Ok(Self {
200 store,
201 store_path,
202 env: env.to_string(),
203 tenant: tenant.to_string(),
204 team: team.map(|v| v.to_string()),
205 seeds,
206 })
207 }
208
209 pub fn store_path(&self) -> &Path {
211 &self.store_path
212 }
213
214 pub fn store(&self) -> &DevStore {
216 &self.store
217 }
218
219 pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
224 let keys = load_secret_keys_from_pack(pack_path)?;
225 if keys.is_empty() {
226 return Ok(());
227 }
228
229 let mut missing = Vec::new();
230 for key in keys {
231 let uri = canonical_secret_uri(
232 &self.env,
233 &self.tenant,
234 self.team.as_deref(),
235 provider_id,
236 &key,
237 );
238 debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
239 match self.store.get(&uri).await {
240 Ok(_) => continue,
241 Err(SecretError::NotFound { .. }) => {
242 let source = if self.seeds.contains_key(&uri) {
243 "seeds.yaml"
244 } else {
245 "placeholder"
246 };
247 debug!(uri = %uri, source, "seeding missing secret");
248 missing.push(
249 self.seeds
250 .get(&uri)
251 .cloned()
252 .unwrap_or_else(|| placeholder_entry(uri)),
253 );
254 }
255 Err(err) => {
256 return Err(anyhow!("failed to read secret {uri}: {err}"));
257 }
258 }
259 }
260
261 if missing.is_empty() {
262 return Ok(());
263 }
264 let report = apply_seed(
265 &self.store,
266 &SeedDoc { entries: missing },
267 ApplyOptions::default(),
268 )
269 .await;
270 if !report.failed.is_empty() {
271 return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
272 }
273 Ok(())
274 }
275}
276
277fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
280 for candidate in seed_paths(bundle_root) {
281 if candidate.exists() {
282 let contents = std::fs::read_to_string(&candidate)?;
283 let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
284 return Ok(doc
285 .entries
286 .into_iter()
287 .map(|entry| (entry.uri.clone(), entry))
288 .collect());
289 }
290 }
291 Ok(HashMap::new())
292}
293
294fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
295 [
296 bundle_root.join("seeds.yaml"),
297 bundle_root.join("state").join("seeds.yaml"),
298 ]
299}
300
301fn placeholder_entry(uri: String) -> SeedEntry {
302 SeedEntry {
303 uri: uri.clone(),
304 format: SecretFormat::Text,
305 value: SeedValue::Text {
306 text: format!("placeholder for {uri}"),
307 },
308 description: Some("auto-applied placeholder".to_string()),
309 }
310}
311
312pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
317 Ok(load_secret_requirements_from_pack(pack_path)?
318 .into_iter()
319 .map(|req| req.key)
320 .collect())
321}
322
323pub fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
325 let file = std::fs::File::open(pack_path)?;
326 let mut archive = zip::ZipArchive::new(file)?;
327
328 for entry_name in &[
329 "assets/secret-requirements.json",
330 "assets/secret_requirements.json",
331 "secret-requirements.json",
332 "secret_requirements.json",
333 ] {
334 match archive.by_name(entry_name) {
335 Ok(reader) => {
336 let reqs: Vec<PackSecretRequirement> = serde_json::from_reader(reader)?;
337 return Ok(dedup_requirements(reqs));
338 }
339 Err(zip::result::ZipError::FileNotFound) => continue,
340 Err(err) => return Err(err.into()),
341 }
342 }
343
344 let mut reqs = Vec::new();
345 for index in 0..archive.len() {
346 let name = {
347 let entry = archive.by_index(index)?;
348 entry.name().to_string()
349 };
350 if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
351 continue;
352 }
353 let mut entry = archive.by_name(&name)?;
354 let mut bytes = Vec::new();
355 std::io::Read::read_to_end(&mut entry, &mut bytes)?;
356 let value: CborValue = serde_cbor::from_slice(&bytes)?;
357 collect_secret_requirements_from_cbor(&value, &mut reqs);
358 }
359
360 Ok(dedup_requirements(reqs))
361}
362
363#[derive(Clone, Debug, serde::Deserialize)]
364pub struct PackSecretRequirement {
365 pub key: String,
366 #[serde(default = "default_required")]
367 pub required: bool,
368 #[serde(default)]
369 pub description: Option<String>,
370}
371
372fn default_required() -> bool {
373 true
374}
375
376fn dedup_requirements(reqs: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
377 let mut by_key = BTreeMap::new();
378 for req in reqs {
379 by_key.entry(req.key.clone()).or_insert(req);
380 }
381 by_key.into_values().collect()
382}
383
384fn collect_secret_requirements_from_cbor(value: &CborValue, out: &mut Vec<PackSecretRequirement>) {
385 match value {
386 CborValue::Array(values) => {
387 for value in values {
388 collect_secret_requirements_from_cbor(value, out);
389 }
390 }
391 CborValue::Map(map) => {
392 if let Some(req) = parse_secret_requirement_map(map) {
393 out.push(req);
394 }
395 for value in map.values() {
396 collect_secret_requirements_from_cbor(value, out);
397 }
398 }
399 _ => {}
400 }
401}
402
403fn parse_secret_requirement_map(
404 map: &BTreeMap<CborValue, CborValue>,
405) -> Option<PackSecretRequirement> {
406 let key = map_get_text(map, "key")?;
407 let has_secret_shape = map.contains_key(&CborValue::Text("required".to_string()))
408 || map.contains_key(&CborValue::Text("scope".to_string()))
409 || map.contains_key(&CborValue::Text("format".to_string()))
410 || map.contains_key(&CborValue::Text("description".to_string()));
411 if !has_secret_shape {
412 return None;
413 }
414 Some(PackSecretRequirement {
415 key,
416 required: map_get_bool(map, "required").unwrap_or(true),
417 description: map_get_text(map, "description"),
418 })
419}
420
421fn map_get_text(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<String> {
422 map.get(&CborValue::Text(key.to_string()))
423 .and_then(|value| match value {
424 CborValue::Text(text) => Some(text.clone()),
425 _ => None,
426 })
427}
428
429fn map_get_bool(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<bool> {
430 map.get(&CborValue::Text(key.to_string()))
431 .and_then(|value| match value {
432 CborValue::Bool(flag) => Some(*flag),
433 _ => None,
434 })
435}
436
437pub fn open_dev_store_for_env(bundle_root: &Path, env: &str) -> Result<DevStore> {
442 let store_path = ensure_path_for_env(bundle_root, env)?;
443 DevStore::with_path(&store_path).map_err(|err| {
444 anyhow!(
445 "failed to open dev secrets store {}: {err}",
446 store_path.display()
447 )
448 })
449}
450
451pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
455 let store_path = ensure_path(bundle_root)?;
456 DevStore::with_path(&store_path).map_err(|err| {
457 anyhow!(
458 "failed to open dev secrets store {}: {err}",
459 store_path.display()
460 )
461 })
462}
463
464#[cfg(test)]
477pub(crate) mod test_support {
478 use std::path::Path;
479 use std::sync::atomic::{AtomicBool, Ordering};
480 use std::sync::{Mutex, MutexGuard, OnceLock};
481
482 static GUARDED: AtomicBool = AtomicBool::new(false);
491
492 pub(crate) fn store_access_is_guarded() -> bool {
493 GUARDED.load(Ordering::SeqCst)
494 }
495
496 struct GuardFlag;
498
499 impl GuardFlag {
500 fn set() -> Self {
501 GUARDED.store(true, Ordering::SeqCst);
502 Self
503 }
504 }
505
506 impl Drop for GuardFlag {
507 fn drop(&mut self) {
508 GUARDED.store(false, Ordering::SeqCst);
509 }
510 }
511
512 pub(crate) fn env_lock() -> MutexGuard<'static, ()> {
520 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
521 LOCK.get_or_init(|| Mutex::new(()))
522 .lock()
523 .unwrap_or_else(|poisoned| poisoned.into_inner())
524 }
525
526 pub(crate) struct StoreOverride {
529 _flag: GuardFlag,
532 _guard: MutexGuard<'static, ()>,
533 previous: Option<String>,
534 }
535
536 impl StoreOverride {
537 pub(crate) fn at(path: &Path) -> Self {
538 let guard = env_lock();
539 let previous = std::env::var(super::OVERRIDE_ENV).ok();
540 unsafe { std::env::set_var(super::OVERRIDE_ENV, path) };
544 Self {
545 _flag: GuardFlag::set(),
546 _guard: guard,
547 previous,
548 }
549 }
550
551 pub(crate) fn in_dir(dir: &Path) -> Self {
553 Self::at(&dir.join(".dev.secrets.env"))
554 }
555 }
556
557 pub(crate) struct IsolatedStore {
563 _override: StoreOverride,
567 _dir: tempfile::TempDir,
568 }
569
570 pub(crate) fn isolated_store() -> IsolatedStore {
572 let dir = tempfile::tempdir().expect("dev store isolation dir");
573 let guard = StoreOverride::in_dir(dir.path());
574 IsolatedStore {
575 _override: guard,
576 _dir: dir,
577 }
578 }
579
580 pub(crate) struct EnvLock(
588 #[allow(dead_code)] GuardFlag,
589 #[allow(dead_code)] MutexGuard<'static, ()>,
590 );
591
592 pub(crate) fn lock_env() -> EnvLock {
593 let guard = env_lock();
594 EnvLock(GuardFlag::set(), guard)
595 }
596
597 impl Drop for StoreOverride {
598 fn drop(&mut self) {
599 match self.previous.take() {
601 Some(value) => unsafe { std::env::set_var(super::OVERRIDE_ENV, value) },
602 None => unsafe { std::env::remove_var(super::OVERRIDE_ENV) },
603 }
604 }
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use std::io::Write;
612 use zip::write::SimpleFileOptions;
613
614 fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
615 let file = std::fs::File::create(path)?;
616 let mut zip = zip::ZipWriter::new(file);
617 zip.start_file(
618 "assets/secret-requirements.json",
619 SimpleFileOptions::default(),
620 )?;
621 zip.write_all(req_json.as_bytes())?;
622 zip.finish()?;
623 Ok(())
624 }
625
626 #[test]
633 fn env_store_path_has_expected_shape() {
634 if let Some(path) = env_store_dev_secrets_path("some-env") {
638 assert!(
639 path.ends_with("environments/some-env/.greentic/dev/.dev.secrets.env"),
640 "unexpected env-store path: {}",
641 path.display()
642 );
643 }
644 }
645
646 fn write_bundle_local_store(bundle: &Path) -> PathBuf {
648 let path = bundle.join(STORE_RELATIVE);
649 std::fs::create_dir_all(path.parent().expect("store parent")).expect("store dir");
650 std::fs::write(&path, "KEY=value\n").expect("write store");
651 path
652 }
653
654 #[test]
655 fn write_path_for_env_and_default_path_target_the_dev_store() {
656 let _env_lock = crate::secrets::test_support::lock_env();
657 let temp = tempfile::tempdir().expect("tempdir");
662 let bundle = temp.path().join("bundle");
663
664 let write = write_path_for_env(&bundle, "some-env");
665 assert!(
666 write.ends_with(STORE_RELATIVE),
667 "unexpected write path: {}",
668 write.display()
669 );
670
671 let default = default_path(&bundle);
672 assert!(
673 default.ends_with(STORE_RELATIVE),
674 "unexpected default path: {}",
675 default.display()
676 );
677 }
678
679 #[test]
685 fn write_path_for_env_targets_the_env_store_not_bundle_local() {
686 let temp = tempfile::tempdir().expect("tempdir");
687 let bundle = temp.path().join("bundle");
688 let Some(env_store) = env_store_dev_secrets_path("guard-env") else {
689 return; };
691 assert_eq!(
692 write_path_for_env(&bundle, "guard-env"),
693 env_store,
694 "env-explicit write must target the env store",
695 );
696 assert!(
697 !write_path_for_env(&bundle, "guard-env").starts_with(&bundle),
698 "env-explicit write must never land in the bundle-local store",
699 );
700 }
701
702 #[test]
703 fn find_existing_locates_a_bundle_local_dev_store() {
704 let _env_lock = test_support::lock_env();
707 let temp = tempfile::tempdir().expect("tempdir");
712 let bundle = temp.path().join("bundle");
713 let store = bundle.join(STORE_RELATIVE);
714 std::fs::create_dir_all(store.parent().expect("store parent")).expect("dev dir");
715 std::fs::write(&store, "KEY=value\n").expect("write store");
716
717 let found = find_existing(&bundle).expect("find dev store");
718 assert!(
719 found.exists(),
720 "found path should exist: {}",
721 found.display()
722 );
723 assert!(
724 found.ends_with(STORE_RELATIVE),
725 "unexpected found path: {}",
726 found.display()
727 );
728 }
729
730 #[test]
731 fn find_existing_with_override_prefers_override() {
732 let _env_lock = test_support::lock_env();
735 let temp = tempfile::tempdir().expect("tempdir");
736 let bundle = temp.path().join("bundle");
737 std::fs::create_dir_all(&bundle).expect("bundle dir");
738 let override_file = temp.path().join("custom.env");
739 std::fs::write(&override_file, "KEY=value\n").expect("write override");
740
741 let found = find_existing_with_override(&bundle, Some(&override_file));
742 assert_eq!(found.as_deref(), Some(override_file.as_path()));
743 }
744
745 #[test]
746 fn find_existing_with_override_falls_back_when_override_is_absent() {
747 let _env_lock = test_support::lock_env();
750 let temp = tempfile::tempdir().expect("tempdir");
751 let bundle = temp.path().join("bundle");
752 std::fs::create_dir_all(&bundle).expect("bundle dir");
753 write_bundle_local_store(&bundle);
754 let missing_override = temp.path().join("does-not-exist.env");
755
756 for override_arg in [None, Some(missing_override.as_path())] {
761 let found = find_existing_with_override(&bundle, override_arg)
762 .expect("existing store must be discovered");
763 assert!(found.exists(), "resolved store does not exist: {found:?}");
764 assert!(
765 found.ends_with(".dev.secrets.env"),
766 "unexpected store file: {found:?}"
767 );
768 }
769 }
770
771 #[test]
772 fn find_existing_discovers_the_bundle_local_store() {
773 let _env_lock = test_support::lock_env();
776 let temp = tempfile::tempdir().expect("tempdir");
777 let bundle = temp.path().join("bundle");
778 std::fs::create_dir_all(&bundle).expect("bundle dir");
779 write_bundle_local_store(&bundle);
780
781 let found = find_existing(&bundle).expect("existing store must be discovered");
782 assert!(found.exists(), "resolved store does not exist: {found:?}");
783 }
784
785 #[test]
786 fn read_candidate_paths_ends_with_bundle_local_candidates() {
787 let _env_lock = test_support::lock_env();
790 let temp = tempfile::tempdir().expect("tempdir");
791 let bundle = temp.path().join("bundle");
792
793 let candidates = read_candidate_paths(&bundle);
794 assert!(
798 candidates.len() == 2 || candidates.len() == 3,
799 "unexpected candidates: {candidates:?}"
800 );
801 assert_eq!(
802 &candidates[candidates.len() - 2..],
803 &[
804 bundle.join(STORE_RELATIVE),
805 bundle.join(STORE_STATE_RELATIVE)
806 ]
807 );
808 }
809
810 #[test]
811 fn write_path_for_env_always_targets_a_dev_store_file() {
812 let _env_lock = test_support::lock_env();
815 let temp = tempfile::tempdir().expect("tempdir");
816 let bundle = temp.path().join("bundle");
817
818 let path = write_path_for_env(&bundle, "some-env");
821 assert!(
822 path.ends_with(STORE_RELATIVE),
823 "unexpected write path: {}",
824 path.display()
825 );
826 }
827
828 #[test]
829 fn default_path_creates_nothing() {
830 let _env_lock = test_support::lock_env();
833 let temp = tempfile::tempdir().expect("tempdir");
834 let bundle = temp.path().join("bundle");
835
836 let path = default_path(&bundle);
837 assert!(
838 path.ends_with(".dev.secrets.env"),
839 "unexpected default path: {}",
840 path.display()
841 );
842 assert!(
843 !bundle.exists(),
844 "default_path must not touch the filesystem"
845 );
846 }
847
848 #[test]
849 fn load_secret_keys_from_pack_reads_requirements() {
850 let temp = tempfile::tempdir().expect("tempdir");
851 let pack = temp.path().join("provider.gtpack");
852 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
853 .expect("write pack");
854
855 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
856 assert_eq!(
857 keys,
858 vec!["API_SECRET".to_string(), "BOT_TOKEN".to_string()]
859 );
860 }
861
862 #[test]
863 fn load_secret_keys_from_pack_reads_cbor_manifest_requirements() {
864 let temp = tempfile::tempdir().expect("tempdir");
865 let pack = temp.path().join("provider.gtpack");
866 let file = std::fs::File::create(&pack).expect("create pack");
867 let mut zip = zip::ZipWriter::new(file);
868 zip.start_file("manifest.cbor", SimpleFileOptions::default())
869 .expect("start entry");
870 let manifest = serde_json::json!({
871 "components": [
872 {
873 "host": {
874 "secrets": {
875 "required": [
876 {
877 "key": "auth.param.get_weather.key",
878 "required": true,
879 "description": "Weather key",
880 "scope": {"env": "runtime", "tenant": "runtime"},
881 "format": "text"
882 }
883 ]
884 }
885 }
886 }
887 ]
888 });
889 let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
890 zip.write_all(&bytes).expect("write manifest");
891 zip.finish().expect("finish zip");
892
893 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
894 assert_eq!(keys, vec!["auth.param.get_weather.key".to_string()]);
895
896 let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
897 assert_eq!(reqs.len(), 1);
898 assert_eq!(reqs[0].description.as_deref(), Some("Weather key"));
899 }
900
901 #[test]
902 fn load_secret_keys_from_pack_returns_empty_without_requirements() {
903 let temp = tempfile::tempdir().expect("tempdir");
904 let pack = temp.path().join("provider.gtpack");
905 let file = std::fs::File::create(&pack).expect("create pack");
906 let mut zip = zip::ZipWriter::new(file);
907 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
908 .expect("start entry");
909 zip.write_all(b"questions: []\n").expect("write setup");
910 zip.finish().expect("finish zip");
911
912 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
913 assert!(keys.is_empty());
914 }
915
916 #[test]
917 fn cbor_manifest_scan_skips_maps_that_are_not_secret_requirements() {
918 let temp = tempfile::tempdir().expect("tempdir");
919 let pack = temp.path().join("provider.gtpack");
920 let file = std::fs::File::create(&pack).expect("create pack");
921 let mut zip = zip::ZipWriter::new(file);
922 zip.start_file("manifest.cbor", SimpleFileOptions::default())
923 .expect("start entry");
924 let manifest = serde_json::json!({
925 "routes": [{"key": "not-a-secret", "target": "node-a"}],
928 "indexed": [{"key": 7, "required": true}],
930 "secrets": [{"key": "loose.secret", "required": "yes"}],
932 });
933 let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
934 zip.write_all(&bytes).expect("write manifest");
935 zip.finish().expect("finish zip");
936
937 let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
938 assert_eq!(reqs.len(), 1, "unexpected requirements: {reqs:?}");
939 assert_eq!(reqs[0].key, "loose.secret");
940 assert!(reqs[0].required, "non-bool `required` must default to true");
941 assert!(reqs[0].description.is_none());
942 }
943
944 #[tokio::test]
945 async fn ensure_pack_secrets_is_a_no_op_without_requirements() {
946 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
947 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
948 let temp = tempfile::tempdir().expect("tempdir");
949 let bundle = temp.path().join("bundle");
950 std::fs::create_dir_all(&bundle).expect("bundle dir");
951 let pack = temp.path().join("provider.gtpack");
952 write_pack_with_secret_requirements(&pack, "[]").expect("pack");
953
954 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
955 setup
956 .ensure_pack_secrets(&pack, "messaging-telegram")
957 .await
958 .expect("ensure secrets");
959
960 assert!(
961 setup
962 .store_path()
963 .parent()
964 .is_some_and(|parent| parent.is_dir()),
965 "store parent must be created: {}",
966 setup.store_path().display()
967 );
968 assert!(
969 setup.store_path().ends_with(".dev.secrets.env"),
970 "unexpected store path: {}",
971 setup.store_path().display()
972 );
973 }
974
975 #[tokio::test]
976 async fn ensure_pack_secrets_is_a_noop_without_requirements() {
977 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
978 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
979 let temp = tempfile::tempdir().expect("tempdir");
980 let bundle = temp.path().join("bundle");
981 std::fs::create_dir_all(&bundle).expect("bundle dir");
982
983 let pack = temp.path().join("provider.gtpack");
985 let file = std::fs::File::create(&pack).expect("create pack");
986 let mut zip = zip::ZipWriter::new(file);
987 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
988 .expect("start entry");
989 zip.write_all(b"questions: []\n").expect("write setup");
990 zip.finish().expect("finish zip");
991
992 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", None).expect("setup");
993 assert!(setup.store_path().ends_with(".dev.secrets.env"));
995
996 setup
997 .ensure_pack_secrets(&pack, "messaging-telegram")
998 .await
999 .expect("noop for empty requirements");
1000 }
1001
1002 #[tokio::test]
1003 async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
1004 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
1005 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
1006 let temp = tempfile::tempdir().expect("tempdir");
1007 let bundle = temp.path().join("bundle");
1008 std::fs::create_dir_all(&bundle).expect("bundle dir");
1009 let pack = temp.path().join("provider.gtpack");
1010 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
1011
1012 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
1013 setup
1014 .ensure_pack_secrets(&pack, "messaging-telegram")
1015 .await
1016 .expect("ensure secrets");
1017
1018 let uri = canonical_secret_uri(
1019 "dev",
1020 "tenant-a",
1021 Some("core"),
1022 "messaging-telegram",
1023 "BOT_TOKEN",
1024 );
1025 let value = setup.store().get(&uri).await.expect("seeded value");
1026 let value = String::from_utf8(value).expect("utf8");
1027 assert!(
1028 value.contains("placeholder for secrets://"),
1029 "unexpected placeholder value: {value}"
1030 );
1031 }
1032
1033 #[tokio::test]
1034 async fn ensure_pack_secrets_uses_seed_values_when_available() {
1035 let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
1036 let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
1037 let temp = tempfile::tempdir().expect("tempdir");
1038 let bundle = temp.path().join("bundle");
1039 std::fs::create_dir_all(&bundle).expect("bundle dir");
1040 let seed_uri = canonical_secret_uri(
1041 "dev",
1042 "tenant-a",
1043 Some("core"),
1044 "messaging-telegram",
1045 "BOT_TOKEN",
1046 );
1047 let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
1048 entries: vec![SeedEntry {
1049 uri: seed_uri.clone(),
1050 format: SecretFormat::Text,
1051 value: SeedValue::Text {
1052 text: "seeded-secret".to_string(),
1053 },
1054 description: Some("test seed".to_string()),
1055 }],
1056 })
1057 .expect("serialize seeds");
1058 std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
1059
1060 let pack = temp.path().join("provider.gtpack");
1061 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
1062
1063 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
1064 setup
1065 .ensure_pack_secrets(&pack, "messaging-telegram")
1066 .await
1067 .expect("ensure secrets");
1068
1069 let value = setup.store().get(&seed_uri).await.expect("seeded value");
1070 let value = String::from_utf8(value).expect("utf8");
1071 assert_eq!(value, "seeded-secret");
1072 }
1073}