Skip to main content

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}