yah_qed/secrets_bridge.rs
1//! Loads `~/.yah/qed/secrets.toml` — a per-user name-bridge from GHA secret
2//! names to a yah-vault slot (or, as a CI/headless fallback, an env var).
3//! R487 follow-up.
4//!
5//! The file is a flat mapping under `[secrets]`. Values are source URIs:
6//!
7//! ```toml
8//! [secrets]
9//! # GHA name # local source
10//! GITHUB_TOKEN = "vault:github-pat"
11//! CF_R2_ACCESS_KEY_ID = "vault:r2-access-key"
12//! CF_R2_SECRET_ACCESS_KEY = "vault:r2-secret-key"
13//! DEEPSEEK_API_KEY = "vault:deepseek-api-key"
14//!
15//! # Env fallback — for CI/headless hosts that don't carry a vault.
16//! NPM_TOKEN = "env:NPM_TOKEN"
17//!
18//! # Or-fallback: try vault first, then env on miss.
19//! GH_PAT = "vault:github-pat|env:GH_PAT_LOCAL"
20//! ```
21//!
22//! Sources today:
23//! - `vault:<slot>` — read from the yah credentials vault
24//! ([`keys::KeysStore::get`]). Vault-open failures (no machine key, CI
25//! runner, fresh install) are tolerated — they just count as "not found"
26//! so the env fallback can still resolve.
27//! - `env:<VAR>` — read from `std::env::var(VAR)`.
28//! - `<vault:…>|<env:…>` — pipe-joined fallback chain; first source that
29//! yields a value wins. Mixed schemes are fine.
30//! - bare literal — return verbatim. Escape hatch for fixtures /
31//! non-secret values; DO NOT use for real secrets.
32//!
33//! Missing file → empty mapping (every `${{ secrets.X }}` evaluates to `""`,
34//! matching GHA's behavior for unset secrets — workflows that depend on a
35//! secret will fail at their own check or at the consuming step).
36//!
37//! @yah:relay(R500, "Vault UI: GHA secrets bridge editor + raw slot list")
38//! @yah:at(2026-06-10T01:38:49Z)
39//! @yah:status(open)
40//! @yah:next("R027 covers the curated Settings→API Keys panel for known model/cloud providers; this relay adds the two un-curated surfaces: (a) the raw vault slot dictionary, (b) the GHA-name↔vault-slot bridge that `secrets_bridge.rs` consumes. Same KeysStore underneath; no RBAC, no leases.")
41//! @yah:next("Three children: F1 = daemon RPC for slot list/set/delete + secrets.toml read/write, F2 = Settings→Vault pane (raw slot CRUD), F3 = QED→Secrets tab (GHA-name bridge editor + live resolution status, autocompletes slot names from F1).")
42//! @yah:next("F2 and F3 both consume F1; F3 also depends on F2's UI patterns. F2 stands alone (useful for any vault user, not just qed-gha).")
43//! @yah:gotcha("KeysStore::get returns the plaintext secret — the daemon RPC must NEVER ship values to the renderer, only slot NAMES + presence. The F2 'set' RPC takes a value but the response only echoes the name. R027-T7's single-blob storage already exists; this relay just exposes list/set/delete + a small TOML read/write for secrets.toml.")
44//! @arch:see(.yah/docs/architecture/A019-settings-api-keys.md)
45
46use std::collections::HashMap;
47use std::path::PathBuf;
48
49use serde::Deserialize;
50
51/// Parsed `~/.yah/qed/secrets.toml`. Missing file is not an error.
52#[derive(Debug, Clone, Default, Deserialize)]
53pub struct SecretsConfig {
54 #[serde(default)]
55 pub secrets: HashMap<String, String>,
56}
57
58impl SecretsConfig {
59 /// Path: `<home>/.yah/qed/secrets.toml`. Returns `Default::default()`
60 /// when the file or HOME is unavailable.
61 pub fn load_default() -> Self {
62 let Some(path) = default_path() else {
63 return Self::default();
64 };
65 Self::load_from(&path)
66 }
67
68 pub fn load_from(path: &std::path::Path) -> Self {
69 match std::fs::read_to_string(path) {
70 Ok(text) => match toml::from_str::<SecretsConfig>(&text) {
71 Ok(cfg) => cfg,
72 Err(e) => {
73 tracing::warn!(
74 path = %path.display(),
75 error = %e,
76 "qed-gha secrets: parse failed; ignoring file",
77 );
78 Self::default()
79 }
80 },
81 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
82 Err(e) => {
83 tracing::warn!(
84 path = %path.display(),
85 error = %e,
86 "qed-gha secrets: read failed; ignoring file",
87 );
88 Self::default()
89 }
90 }
91 }
92
93 /// Build the `secrets.*` `Value::Object` the qed-gha executor expects.
94 /// Each declared GHA secret name is resolved through its source URI
95 /// using the canonical yah vault; unresolved sources surface as `""`
96 /// (matches GHA's unset behavior).
97 pub fn resolve_all(&self) -> yah_qed_gha::Value {
98 // Open the vault once per resolve — `KeysStore::get` is a small
99 // file read + AES-GCM decrypt of a usually-tiny credentials.enc,
100 // so the open cost amortizes across N lookups.
101 let vault = fob::KeysStore::open().ok();
102 let mut out: indexmap::IndexMap<String, yah_qed_gha::Value> = indexmap::IndexMap::new();
103 for (gha_name, source) in &self.secrets {
104 let value = resolve_source(source, vault.as_ref()).unwrap_or_default();
105 out.insert(gha_name.clone(), yah_qed_gha::Value::String(value));
106 }
107 yah_qed_gha::Value::Object(out)
108 }
109
110 /// Resolve a single declared secret by its bridged name, running the same
111 /// `vault:` / `env:` / pipe-fallback chain as [`resolve_all`]. Returns
112 /// `None` when the name isn't declared in the bridge, or when its source
113 /// chain yields nothing. Opens the vault once per call — fine for the
114 /// handful of credential slots a release-provider adapter reads at publish
115 /// time (R509). For resolving the whole bridge at once, prefer
116 /// [`resolve_all`], which amortizes the vault open across all entries.
117 pub fn resolve_one(&self, name: &str) -> Option<String> {
118 let source = self.secrets.get(name)?;
119 let vault = fob::KeysStore::open().ok();
120 resolve_source(source, vault.as_ref())
121 }
122
123 /// Names of declared GHA secrets (sorted). Used by the desktop
124 /// vault-explorer to show which workflow names are bridged + which
125 /// vault slots they map to.
126 pub fn names(&self) -> Vec<String> {
127 let mut v: Vec<String> = self.secrets.keys().cloned().collect();
128 v.sort();
129 v
130 }
131
132 /// Per-entry presence report — runs the same fallback chain as
133 /// [`resolve_all`] but reports only whether each entry resolves to a
134 /// non-empty value, never the value itself. Sorted by GHA name so
135 /// the editor UI gets a stable row order. (R500-F1)
136 pub fn resolve_status(&self) -> Vec<EntryStatus> {
137 let vault = fob::KeysStore::open().ok();
138 let mut out: Vec<EntryStatus> = self
139 .secrets
140 .iter()
141 .map(|(name, source)| {
142 let resolved = resolve_source(source, vault.as_ref())
143 .map(|v| !v.is_empty())
144 .unwrap_or(false);
145 EntryStatus {
146 name: name.clone(),
147 source: source.clone(),
148 resolved,
149 }
150 })
151 .collect();
152 out.sort_by(|a, b| a.name.cmp(&b.name));
153 out
154 }
155}
156
157/// Per-entry resolution status from [`SecretsConfig::resolve_status`].
158/// `resolved` carries presence only — the actual value never leaves the
159/// daemon process.
160#[derive(Debug, Clone)]
161pub struct EntryStatus {
162 pub name: String,
163 pub source: String,
164 pub resolved: bool,
165}
166
167/// Atomically write `entries` as the `[secrets]` table of `path`. Tempfile
168/// + rename in the same directory so partially-written files are never
169/// visible to readers. Creates the parent directory if missing. Empty
170/// `entries` writes an empty `[secrets]` table (well-formed; loads back
171/// as an empty mapping). (R500-F1)
172pub fn save_to(
173 path: &std::path::Path,
174 entries: &std::collections::BTreeMap<String, String>,
175) -> std::io::Result<()> {
176 if let Some(parent) = path.parent() {
177 std::fs::create_dir_all(parent)?;
178 }
179 let mut buf = String::with_capacity(64 + entries.len() * 64);
180 buf.push_str("[secrets]\n");
181 for (name, source) in entries {
182 // Authored TOML keys can already include `[a-zA-Z0-9_-]+`
183 // bare; anything else (a name with a dot, say) needs quoting.
184 // GHA secret names in practice are uppercase + underscores;
185 // err on the side of always-quoting to keep the writer total.
186 buf.push_str(&format!(
187 "{} = {}\n",
188 quote_toml_key(name),
189 quote_toml_str(source)
190 ));
191 }
192 let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
193 let tmp_name = format!(
194 ".{}.tmp",
195 path.file_name()
196 .and_then(|s| s.to_str())
197 .unwrap_or("secrets.toml")
198 );
199 let tmp = dir.join(tmp_name);
200 std::fs::write(&tmp, buf.as_bytes())?;
201 std::fs::rename(&tmp, path)
202}
203
204fn quote_toml_key(k: &str) -> String {
205 let bare_ok = !k.is_empty()
206 && k.chars()
207 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
208 if bare_ok {
209 k.to_string()
210 } else {
211 quote_toml_str(k)
212 }
213}
214
215fn quote_toml_str(s: &str) -> String {
216 let mut out = String::with_capacity(s.len() + 2);
217 out.push('"');
218 for c in s.chars() {
219 match c {
220 '\\' => out.push_str("\\\\"),
221 '"' => out.push_str("\\\""),
222 '\n' => out.push_str("\\n"),
223 '\r' => out.push_str("\\r"),
224 '\t' => out.push_str("\\t"),
225 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
226 c => out.push(c),
227 }
228 }
229 out.push('"');
230 out
231}
232
233/// Resolve a single source URI to its current string value, using `vault`
234/// for `vault:<slot>` lookups. Returns `None` when nothing resolved (the
235/// caller folds that to `""` so undefined secrets read empty, matching GHA).
236fn resolve_source(source: &str, vault: Option<&fob::KeysStore>) -> Option<String> {
237 // Pipe-joined fallback chain: try each alternative in order until one
238 // yields a Some. Same precedence as shell `${VAR:-${OTHER:-…}}` — the
239 // first non-empty wins.
240 for alt in source.split('|') {
241 let alt = alt.trim();
242 if alt.is_empty() {
243 continue;
244 }
245 if let Some(slot) = alt.strip_prefix("vault:") {
246 if let Some(v) = vault {
247 match v.get(slot) {
248 Ok(Some(value)) if !value.is_empty() => return Some(value),
249 Ok(_) => continue,
250 Err(e) => {
251 // Lenient: a corrupt or partially-set vault on
252 // this host shouldn't blanket-fail all secrets
253 // (env fallbacks should still work). Log + drop.
254 tracing::warn!(
255 slot = %slot,
256 error = %e,
257 "qed-gha secrets: vault read failed; trying next fallback",
258 );
259 continue;
260 }
261 }
262 }
263 // No vault on this host (no machine.key — common on CI /
264 // fresh installs). Fall through to next alt.
265 continue;
266 }
267 if let Some(var) = alt.strip_prefix("env:") {
268 if let Ok(value) = std::env::var(var) {
269 if !value.is_empty() {
270 return Some(value);
271 }
272 }
273 continue;
274 }
275 if alt.starts_with("keystore://") {
276 // Reserved for future OS keystore (the URI scheme `cloud::config`
277 // uses for `credentials = "keystore://cloudflare/yah"`). No
278 // resolver yet — warn + treat as unset.
279 tracing::warn!(
280 source = %alt,
281 "qed-gha secrets: keystore:// scheme is reserved; no resolver yet — trying next fallback",
282 );
283 continue;
284 }
285 // Bare literal escape hatch (fixtures / known-non-secret values).
286 return Some(alt.to_string());
287 }
288 None
289}
290
291/// Canonical path for the bridge file: `~/.yah/qed/secrets.toml`. `None`
292/// when HOME is unset (CI containers without a $HOME). Exposed so callers
293/// that need to read **and** write the file share the same path.
294pub fn default_path() -> Option<PathBuf> {
295 let home = std::env::var_os("HOME")?;
296 Some(
297 PathBuf::from(home)
298 .join(".yah")
299 .join("qed")
300 .join("secrets.toml"),
301 )
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn missing_file_yields_empty_mapping() {
310 let cfg = SecretsConfig::load_from(std::path::Path::new("/nonexistent/secrets.toml"));
311 assert!(cfg.secrets.is_empty());
312 }
313
314 #[test]
315 fn env_scheme_resolves_via_env_var() {
316 let key = "QED_SECRETS_TEST_VAR_4F8A";
317 std::env::set_var(key, "hunter2");
318 let mut cfg = SecretsConfig::default();
319 cfg.secrets
320 .insert("GITHUB_TOKEN".into(), format!("env:{key}"));
321 let v = cfg.resolve_all();
322 assert_eq!(string_at(&v, "GITHUB_TOKEN").as_deref(), Some("hunter2"));
323 std::env::remove_var(key);
324 }
325
326 #[test]
327 fn unresolved_env_var_yields_empty_string() {
328 let mut cfg = SecretsConfig::default();
329 cfg.secrets.insert(
330 "GITHUB_TOKEN".into(),
331 "env:DEFINITELY_NOT_SET_QED_X92".into(),
332 );
333 let v = cfg.resolve_all();
334 // Unresolved → empty string (matches GHA's unset-secret behavior).
335 assert_eq!(string_at(&v, "GITHUB_TOKEN").as_deref(), Some(""));
336 }
337
338 #[test]
339 fn pipe_chain_falls_back_to_env_when_vault_misses() {
340 let key = "QED_SECRETS_FALLBACK_VAR_AAAA";
341 std::env::set_var(key, "from-env");
342 let mut cfg = SecretsConfig::default();
343 // Vault may or may not exist on this host; either way the slot
344 // `nonexistent-slot-1f2e` won't resolve, so the pipe chain falls
345 // through to the env source.
346 cfg.secrets.insert(
347 "GH_PAT".into(),
348 format!("vault:nonexistent-slot-1f2e|env:{key}"),
349 );
350 let v = cfg.resolve_all();
351 assert_eq!(string_at(&v, "GH_PAT").as_deref(), Some("from-env"));
352 std::env::remove_var(key);
353 }
354
355 #[test]
356 fn parses_a_secrets_toml() {
357 let dir = tempfile::tempdir().unwrap();
358 let path = dir.path().join("secrets.toml");
359 std::fs::write(
360 &path,
361 r#"
362[secrets]
363GITHUB_TOKEN = "vault:github-pat"
364CF_R2_ACCESS_KEY = "vault:r2-access-key|env:CF_R2_ACCESS_KEY"
365"#,
366 )
367 .unwrap();
368 let cfg = SecretsConfig::load_from(&path);
369 assert_eq!(cfg.secrets.len(), 2);
370 assert_eq!(
371 cfg.secrets.get("GITHUB_TOKEN").map(|s| s.as_str()),
372 Some("vault:github-pat"),
373 );
374 assert_eq!(cfg.names(), vec!["CF_R2_ACCESS_KEY", "GITHUB_TOKEN"]);
375 }
376
377 #[test]
378 fn save_to_roundtrips_through_load_from() {
379 let dir = tempfile::tempdir().unwrap();
380 let path = dir.path().join("secrets.toml");
381 let mut entries = std::collections::BTreeMap::new();
382 entries.insert("GITHUB_TOKEN".into(), "vault:github-pat".into());
383 entries.insert("GH_PAT".into(), "vault:github-pat|env:GH_PAT_LOCAL".into());
384 save_to(&path, &entries).unwrap();
385 let cfg = SecretsConfig::load_from(&path);
386 assert_eq!(cfg.secrets.len(), 2);
387 assert_eq!(
388 cfg.secrets.get("GITHUB_TOKEN").map(|s| s.as_str()),
389 Some("vault:github-pat"),
390 );
391 assert_eq!(
392 cfg.secrets.get("GH_PAT").map(|s| s.as_str()),
393 Some("vault:github-pat|env:GH_PAT_LOCAL"),
394 );
395 }
396
397 #[test]
398 fn save_to_quotes_special_chars_in_source() {
399 let dir = tempfile::tempdir().unwrap();
400 let path = dir.path().join("secrets.toml");
401 let mut entries = std::collections::BTreeMap::new();
402 entries.insert("TRICKY".into(), "bare \"quoted\" \\and\\ slashed".into());
403 save_to(&path, &entries).unwrap();
404 let cfg = SecretsConfig::load_from(&path);
405 assert_eq!(
406 cfg.secrets.get("TRICKY").map(|s| s.as_str()),
407 Some("bare \"quoted\" \\and\\ slashed"),
408 );
409 }
410
411 #[test]
412 fn resolve_status_reports_presence_not_values() {
413 let key = "QED_SECRETS_STATUS_VAR_BBBB";
414 std::env::set_var(key, "present");
415 let mut cfg = SecretsConfig::default();
416 cfg.secrets.insert("PRESENT".into(), format!("env:{key}"));
417 cfg.secrets
418 .insert("ABSENT".into(), "env:DEFINITELY_NOT_SET_QED_X93".into());
419 let report = cfg.resolve_status();
420 assert_eq!(report.len(), 2);
421 // Sorted by name: ABSENT, PRESENT.
422 assert_eq!(report[0].name, "ABSENT");
423 assert!(!report[0].resolved);
424 assert_eq!(report[1].name, "PRESENT");
425 assert!(report[1].resolved);
426 // The status report never carries the actual value — only
427 // (name, source, bool). Compile-time guaranteed by the struct
428 // shape; this assertion just documents the invariant.
429 assert_eq!(report[1].source, format!("env:{key}"));
430 std::env::remove_var(key);
431 }
432
433 fn string_at(v: &yah_qed_gha::Value, key: &str) -> Option<String> {
434 match v {
435 yah_qed_gha::Value::Object(m) => m.get(key).and_then(|x| match x {
436 yah_qed_gha::Value::String(s) => Some(s.clone()),
437 _ => None,
438 }),
439 _ => None,
440 }
441 }
442}