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 std::env::var(OVERRIDE_ENV).ok().map(PathBuf::from)
29}
30
31pub fn find_existing(bundle_root: &Path) -> Option<PathBuf> {
33 find_existing_with_override(bundle_root, override_path().as_deref())
34}
35
36pub fn find_existing_with_override(
38 bundle_root: &Path,
39 override_path: Option<&Path>,
40) -> Option<PathBuf> {
41 if let Some(path) = override_path
42 && path.exists()
43 {
44 return Some(path.to_path_buf());
45 }
46 candidate_paths(bundle_root)
47 .into_iter()
48 .find(|candidate| candidate.exists())
49}
50
51pub fn ensure_path(bundle_root: &Path) -> Result<PathBuf> {
53 if let Some(path) = override_path() {
54 ensure_parent(&path)?;
55 return Ok(path);
56 }
57 let path = bundle_root.join(STORE_RELATIVE);
58 ensure_parent(&path)?;
59 Ok(path)
60}
61
62pub fn default_path(bundle_root: &Path) -> PathBuf {
64 bundle_root.join(STORE_RELATIVE)
65}
66
67fn candidate_paths(bundle_root: &Path) -> [PathBuf; 2] {
68 [
69 bundle_root.join(STORE_RELATIVE),
70 bundle_root.join(STORE_STATE_RELATIVE),
71 ]
72}
73
74fn ensure_parent(path: &Path) -> Result<()> {
75 if let Some(parent) = path.parent() {
76 std::fs::create_dir_all(parent)?;
77 }
78 Ok(())
79}
80
81pub struct SecretsSetup {
88 store: DevStore,
89 store_path: PathBuf,
90 env: String,
91 tenant: String,
92 team: Option<String>,
93 seeds: HashMap<String, SeedEntry>,
94}
95
96impl SecretsSetup {
97 pub fn new(bundle_root: &Path, env: &str, tenant: &str, team: Option<&str>) -> Result<Self> {
98 let store_path = ensure_path(bundle_root)?;
99 info!(path = %store_path.display(), "secrets: using dev store backend");
100 let store = DevStore::with_path(&store_path).map_err(|err| {
101 anyhow!(
102 "failed to open dev secrets store {}: {err}",
103 store_path.display()
104 )
105 })?;
106 let seeds = load_seed_entries(bundle_root)?;
107 Ok(Self {
108 store,
109 store_path,
110 env: env.to_string(),
111 tenant: tenant.to_string(),
112 team: team.map(|v| v.to_string()),
113 seeds,
114 })
115 }
116
117 pub fn store_path(&self) -> &Path {
119 &self.store_path
120 }
121
122 pub fn store(&self) -> &DevStore {
124 &self.store
125 }
126
127 pub async fn ensure_pack_secrets(&self, pack_path: &Path, provider_id: &str) -> Result<()> {
132 let keys = load_secret_keys_from_pack(pack_path)?;
133 if keys.is_empty() {
134 return Ok(());
135 }
136
137 let mut missing = Vec::new();
138 for key in keys {
139 let uri = canonical_secret_uri(
140 &self.env,
141 &self.tenant,
142 self.team.as_deref(),
143 provider_id,
144 &key,
145 );
146 debug!(uri = %uri, provider = %provider_id, key = %key, "canonicalized secret requirement");
147 match self.store.get(&uri).await {
148 Ok(_) => continue,
149 Err(SecretError::NotFound { .. }) => {
150 let source = if self.seeds.contains_key(&uri) {
151 "seeds.yaml"
152 } else {
153 "placeholder"
154 };
155 debug!(uri = %uri, source, "seeding missing secret");
156 missing.push(
157 self.seeds
158 .get(&uri)
159 .cloned()
160 .unwrap_or_else(|| placeholder_entry(uri)),
161 );
162 }
163 Err(err) => {
164 return Err(anyhow!("failed to read secret {uri}: {err}"));
165 }
166 }
167 }
168
169 if missing.is_empty() {
170 return Ok(());
171 }
172 let report = apply_seed(
173 &self.store,
174 &SeedDoc { entries: missing },
175 ApplyOptions::default(),
176 )
177 .await;
178 if !report.failed.is_empty() {
179 return Err(anyhow!("failed to seed secrets: {:?}", report.failed));
180 }
181 Ok(())
182 }
183}
184
185fn load_seed_entries(bundle_root: &Path) -> Result<HashMap<String, SeedEntry>> {
188 for candidate in seed_paths(bundle_root) {
189 if candidate.exists() {
190 let contents = std::fs::read_to_string(&candidate)?;
191 let doc: SeedDoc = serde_yaml_bw::from_str(&contents)?;
192 return Ok(doc
193 .entries
194 .into_iter()
195 .map(|entry| (entry.uri.clone(), entry))
196 .collect());
197 }
198 }
199 Ok(HashMap::new())
200}
201
202fn seed_paths(bundle_root: &Path) -> [PathBuf; 2] {
203 [
204 bundle_root.join("seeds.yaml"),
205 bundle_root.join("state").join("seeds.yaml"),
206 ]
207}
208
209fn placeholder_entry(uri: String) -> SeedEntry {
210 SeedEntry {
211 uri: uri.clone(),
212 format: SecretFormat::Text,
213 value: SeedValue::Text {
214 text: format!("placeholder for {uri}"),
215 },
216 description: Some("auto-applied placeholder".to_string()),
217 }
218}
219
220pub fn load_secret_keys_from_pack(pack_path: &Path) -> Result<Vec<String>> {
225 Ok(load_secret_requirements_from_pack(pack_path)?
226 .into_iter()
227 .map(|req| req.key)
228 .collect())
229}
230
231pub fn load_secret_requirements_from_pack(pack_path: &Path) -> Result<Vec<PackSecretRequirement>> {
233 let file = std::fs::File::open(pack_path)?;
234 let mut archive = zip::ZipArchive::new(file)?;
235
236 for entry_name in &[
237 "assets/secret-requirements.json",
238 "assets/secret_requirements.json",
239 "secret-requirements.json",
240 "secret_requirements.json",
241 ] {
242 match archive.by_name(entry_name) {
243 Ok(reader) => {
244 let reqs: Vec<PackSecretRequirement> = serde_json::from_reader(reader)?;
245 return Ok(dedup_requirements(reqs));
246 }
247 Err(zip::result::ZipError::FileNotFound) => continue,
248 Err(err) => return Err(err.into()),
249 }
250 }
251
252 let mut reqs = Vec::new();
253 for index in 0..archive.len() {
254 let name = {
255 let entry = archive.by_index(index)?;
256 entry.name().to_string()
257 };
258 if name != "manifest.cbor" && !name.ends_with(".manifest.cbor") {
259 continue;
260 }
261 let mut entry = archive.by_name(&name)?;
262 let mut bytes = Vec::new();
263 std::io::Read::read_to_end(&mut entry, &mut bytes)?;
264 let value: CborValue = serde_cbor::from_slice(&bytes)?;
265 collect_secret_requirements_from_cbor(&value, &mut reqs);
266 }
267
268 Ok(dedup_requirements(reqs))
269}
270
271#[derive(Clone, Debug, serde::Deserialize)]
272pub struct PackSecretRequirement {
273 pub key: String,
274 #[serde(default = "default_required")]
275 pub required: bool,
276 #[serde(default)]
277 pub description: Option<String>,
278}
279
280fn default_required() -> bool {
281 true
282}
283
284fn dedup_requirements(reqs: Vec<PackSecretRequirement>) -> Vec<PackSecretRequirement> {
285 let mut by_key = BTreeMap::new();
286 for req in reqs {
287 by_key.entry(req.key.clone()).or_insert(req);
288 }
289 by_key.into_values().collect()
290}
291
292fn collect_secret_requirements_from_cbor(value: &CborValue, out: &mut Vec<PackSecretRequirement>) {
293 match value {
294 CborValue::Array(values) => {
295 for value in values {
296 collect_secret_requirements_from_cbor(value, out);
297 }
298 }
299 CborValue::Map(map) => {
300 if let Some(req) = parse_secret_requirement_map(map) {
301 out.push(req);
302 }
303 for value in map.values() {
304 collect_secret_requirements_from_cbor(value, out);
305 }
306 }
307 _ => {}
308 }
309}
310
311fn parse_secret_requirement_map(
312 map: &BTreeMap<CborValue, CborValue>,
313) -> Option<PackSecretRequirement> {
314 let key = map_get_text(map, "key")?;
315 let has_secret_shape = map.contains_key(&CborValue::Text("required".to_string()))
316 || map.contains_key(&CborValue::Text("scope".to_string()))
317 || map.contains_key(&CborValue::Text("format".to_string()))
318 || map.contains_key(&CborValue::Text("description".to_string()));
319 if !has_secret_shape {
320 return None;
321 }
322 Some(PackSecretRequirement {
323 key,
324 required: map_get_bool(map, "required").unwrap_or(true),
325 description: map_get_text(map, "description"),
326 })
327}
328
329fn map_get_text(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<String> {
330 map.get(&CborValue::Text(key.to_string()))
331 .and_then(|value| match value {
332 CborValue::Text(text) => Some(text.clone()),
333 _ => None,
334 })
335}
336
337fn map_get_bool(map: &BTreeMap<CborValue, CborValue>, key: &str) -> Option<bool> {
338 map.get(&CborValue::Text(key.to_string()))
339 .and_then(|value| match value {
340 CborValue::Bool(flag) => Some(*flag),
341 _ => None,
342 })
343}
344
345pub fn open_dev_store(bundle_root: &Path) -> Result<DevStore> {
347 let store_path = ensure_path(bundle_root)?;
348 DevStore::with_path(&store_path).map_err(|err| {
349 anyhow!(
350 "failed to open dev secrets store {}: {err}",
351 store_path.display()
352 )
353 })
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use std::io::Write;
360 use zip::write::SimpleFileOptions;
361
362 fn write_pack_with_secret_requirements(path: &Path, req_json: &str) -> anyhow::Result<()> {
363 let file = std::fs::File::create(path)?;
364 let mut zip = zip::ZipWriter::new(file);
365 zip.start_file(
366 "assets/secret-requirements.json",
367 SimpleFileOptions::default(),
368 )?;
369 zip.write_all(req_json.as_bytes())?;
370 zip.finish()?;
371 Ok(())
372 }
373
374 #[test]
375 fn ensure_path_creates_parent_directories() {
376 let temp = tempfile::tempdir().expect("tempdir");
377 let bundle = temp.path().join("bundle");
378 std::fs::create_dir_all(&bundle).expect("bundle dir");
379 let path = ensure_path(&bundle).expect("ensure path");
380 assert!(path.ends_with(".greentic/dev/.dev.secrets.env"));
381 assert!(path.parent().expect("parent").exists());
382 }
383
384 #[test]
385 fn find_existing_with_override_prefers_override() {
386 let temp = tempfile::tempdir().expect("tempdir");
387 let bundle = temp.path().join("bundle");
388 std::fs::create_dir_all(&bundle).expect("bundle dir");
389 let override_file = temp.path().join("custom.env");
390 std::fs::write(&override_file, "KEY=value\n").expect("write override");
391
392 let found = find_existing_with_override(&bundle, Some(&override_file));
393 assert_eq!(found.as_deref(), Some(override_file.as_path()));
394 }
395
396 #[test]
397 fn find_existing_finds_default_locations() {
398 let temp = tempfile::tempdir().expect("tempdir");
399 let bundle = temp.path().join("bundle");
400 let store_path = bundle.join(STORE_RELATIVE);
401 std::fs::create_dir_all(store_path.parent().expect("parent")).expect("create dirs");
402 std::fs::write(&store_path, "K=V\n").expect("write store");
403
404 let found = find_existing_with_override(&bundle, None).expect("found");
405 assert_eq!(found, store_path);
406 }
407
408 #[test]
409 fn load_secret_keys_from_pack_reads_requirements() {
410 let temp = tempfile::tempdir().expect("tempdir");
411 let pack = temp.path().join("provider.gtpack");
412 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"},{"key":"API_SECRET"}]"#)
413 .expect("write pack");
414
415 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
416 assert_eq!(
417 keys,
418 vec!["API_SECRET".to_string(), "BOT_TOKEN".to_string()]
419 );
420 }
421
422 #[test]
423 fn load_secret_keys_from_pack_reads_cbor_manifest_requirements() {
424 let temp = tempfile::tempdir().expect("tempdir");
425 let pack = temp.path().join("provider.gtpack");
426 let file = std::fs::File::create(&pack).expect("create pack");
427 let mut zip = zip::ZipWriter::new(file);
428 zip.start_file("manifest.cbor", SimpleFileOptions::default())
429 .expect("start entry");
430 let manifest = serde_json::json!({
431 "components": [
432 {
433 "host": {
434 "secrets": {
435 "required": [
436 {
437 "key": "auth.param.get_weather.key",
438 "required": true,
439 "description": "Weather key",
440 "scope": {"env": "runtime", "tenant": "runtime"},
441 "format": "text"
442 }
443 ]
444 }
445 }
446 }
447 ]
448 });
449 let bytes = serde_cbor::to_vec(&manifest).expect("serialize cbor");
450 zip.write_all(&bytes).expect("write manifest");
451 zip.finish().expect("finish zip");
452
453 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
454 assert_eq!(keys, vec!["auth.param.get_weather.key".to_string()]);
455
456 let reqs = load_secret_requirements_from_pack(&pack).expect("load reqs");
457 assert_eq!(reqs.len(), 1);
458 assert_eq!(reqs[0].description.as_deref(), Some("Weather key"));
459 }
460
461 #[test]
462 fn load_secret_keys_from_pack_returns_empty_without_requirements() {
463 let temp = tempfile::tempdir().expect("tempdir");
464 let pack = temp.path().join("provider.gtpack");
465 let file = std::fs::File::create(&pack).expect("create pack");
466 let mut zip = zip::ZipWriter::new(file);
467 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())
468 .expect("start entry");
469 zip.write_all(b"questions: []\n").expect("write setup");
470 zip.finish().expect("finish zip");
471
472 let keys = load_secret_keys_from_pack(&pack).expect("load keys");
473 assert!(keys.is_empty());
474 }
475
476 #[tokio::test]
477 async fn ensure_pack_secrets_seeds_placeholders_for_missing_keys() {
478 let temp = tempfile::tempdir().expect("tempdir");
479 let bundle = temp.path().join("bundle");
480 std::fs::create_dir_all(&bundle).expect("bundle dir");
481 let pack = temp.path().join("provider.gtpack");
482 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
483
484 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
485 setup
486 .ensure_pack_secrets(&pack, "messaging-telegram")
487 .await
488 .expect("ensure secrets");
489
490 let uri = canonical_secret_uri(
491 "dev",
492 "tenant-a",
493 Some("core"),
494 "messaging-telegram",
495 "BOT_TOKEN",
496 );
497 let value = setup.store().get(&uri).await.expect("seeded value");
498 let value = String::from_utf8(value).expect("utf8");
499 assert!(
500 value.contains("placeholder for secrets://"),
501 "unexpected placeholder value: {value}"
502 );
503 }
504
505 #[tokio::test]
506 async fn ensure_pack_secrets_uses_seed_values_when_available() {
507 let temp = tempfile::tempdir().expect("tempdir");
508 let bundle = temp.path().join("bundle");
509 std::fs::create_dir_all(&bundle).expect("bundle dir");
510 let seed_uri = canonical_secret_uri(
511 "dev",
512 "tenant-a",
513 Some("core"),
514 "messaging-telegram",
515 "BOT_TOKEN",
516 );
517 let seeds_yaml = serde_yaml_bw::to_string(&SeedDoc {
518 entries: vec![SeedEntry {
519 uri: seed_uri.clone(),
520 format: SecretFormat::Text,
521 value: SeedValue::Text {
522 text: "seeded-secret".to_string(),
523 },
524 description: Some("test seed".to_string()),
525 }],
526 })
527 .expect("serialize seeds");
528 std::fs::write(bundle.join("seeds.yaml"), seeds_yaml).expect("write seeds");
529
530 let pack = temp.path().join("provider.gtpack");
531 write_pack_with_secret_requirements(&pack, r#"[{"key":"BOT_TOKEN"}]"#).expect("pack");
532
533 let setup = SecretsSetup::new(&bundle, "dev", "tenant-a", Some("core")).expect("setup");
534 setup
535 .ensure_pack_secrets(&pack, "messaging-telegram")
536 .await
537 .expect("ensure secrets");
538
539 let value = setup.store().get(&seed_uri).await.expect("seeded value");
540 let value = String::from_utf8(value).expect("utf8");
541 assert_eq!(value, "seeded-secret");
542 }
543}