use std::collections::HashMap;
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SecretsConfig {
#[serde(default)]
pub secrets: HashMap<String, String>,
}
impl SecretsConfig {
pub fn load_default() -> Self {
let Some(path) = default_path() else {
return Self::default();
};
Self::load_from(&path)
}
pub fn load_from(path: &std::path::Path) -> Self {
match std::fs::read_to_string(path) {
Ok(text) => match toml::from_str::<SecretsConfig>(&text) {
Ok(cfg) => cfg,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"qed-gha secrets: parse failed; ignoring file",
);
Self::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"qed-gha secrets: read failed; ignoring file",
);
Self::default()
}
}
}
pub fn resolve_all(&self) -> yah_qed_gha::Value {
let vault = fob::KeysStore::open().ok();
let mut out: indexmap::IndexMap<String, yah_qed_gha::Value> = indexmap::IndexMap::new();
for (gha_name, source) in &self.secrets {
let value = resolve_source(source, vault.as_ref()).unwrap_or_default();
out.insert(gha_name.clone(), yah_qed_gha::Value::String(value));
}
yah_qed_gha::Value::Object(out)
}
pub fn resolve_one(&self, name: &str) -> Option<String> {
let source = self.secrets.get(name)?;
let vault = fob::KeysStore::open().ok();
resolve_source(source, vault.as_ref())
}
pub fn names(&self) -> Vec<String> {
let mut v: Vec<String> = self.secrets.keys().cloned().collect();
v.sort();
v
}
pub fn resolve_status(&self) -> Vec<EntryStatus> {
let vault = fob::KeysStore::open().ok();
let mut out: Vec<EntryStatus> = self
.secrets
.iter()
.map(|(name, source)| {
let resolved = resolve_source(source, vault.as_ref())
.map(|v| !v.is_empty())
.unwrap_or(false);
EntryStatus {
name: name.clone(),
source: source.clone(),
resolved,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
}
#[derive(Debug, Clone)]
pub struct EntryStatus {
pub name: String,
pub source: String,
pub resolved: bool,
}
pub fn save_to(
path: &std::path::Path,
entries: &std::collections::BTreeMap<String, String>,
) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut buf = String::with_capacity(64 + entries.len() * 64);
buf.push_str("[secrets]\n");
for (name, source) in entries {
buf.push_str(&format!(
"{} = {}\n",
quote_toml_key(name),
quote_toml_str(source)
));
}
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let tmp_name = format!(
".{}.tmp",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("secrets.toml")
);
let tmp = dir.join(tmp_name);
std::fs::write(&tmp, buf.as_bytes())?;
std::fs::rename(&tmp, path)
}
fn quote_toml_key(k: &str) -> String {
let bare_ok = !k.is_empty()
&& k.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if bare_ok {
k.to_string()
} else {
quote_toml_str(k)
}
}
fn quote_toml_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
pub fn resolve_source(source: &str, vault: Option<&fob::KeysStore>) -> Option<String> {
for alt in source.split('|') {
let alt = alt.trim();
if alt.is_empty() {
continue;
}
if let Some(slot) = alt.strip_prefix("vault:") {
if let Some(v) = vault {
match v.get(slot) {
Ok(Some(value)) if !value.is_empty() => return Some(value),
Ok(_) => continue,
Err(e) => {
tracing::warn!(
slot = %slot,
error = %e,
"qed-gha secrets: vault read failed; trying next fallback",
);
continue;
}
}
}
continue;
}
if let Some(var) = alt.strip_prefix("env:") {
if let Ok(value) = std::env::var(var) {
if !value.is_empty() {
return Some(value);
}
}
continue;
}
if alt.starts_with("keystore://") {
tracing::warn!(
source = %alt,
"qed-gha secrets: keystore:// scheme is reserved; no resolver yet — trying next fallback",
);
continue;
}
return Some(alt.to_string());
}
None
}
pub fn default_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(
PathBuf::from(home)
.join(".yah")
.join("qed")
.join("secrets.toml"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_yields_empty_mapping() {
let cfg = SecretsConfig::load_from(std::path::Path::new("/nonexistent/secrets.toml"));
assert!(cfg.secrets.is_empty());
}
#[test]
fn env_scheme_resolves_via_env_var() {
let key = "QED_SECRETS_TEST_VAR_4F8A";
std::env::set_var(key, "hunter2");
let mut cfg = SecretsConfig::default();
cfg.secrets
.insert("GITHUB_TOKEN".into(), format!("env:{key}"));
let v = cfg.resolve_all();
assert_eq!(string_at(&v, "GITHUB_TOKEN").as_deref(), Some("hunter2"));
std::env::remove_var(key);
}
#[test]
fn unresolved_env_var_yields_empty_string() {
let mut cfg = SecretsConfig::default();
cfg.secrets.insert(
"GITHUB_TOKEN".into(),
"env:DEFINITELY_NOT_SET_QED_X92".into(),
);
let v = cfg.resolve_all();
assert_eq!(string_at(&v, "GITHUB_TOKEN").as_deref(), Some(""));
}
#[test]
fn pipe_chain_falls_back_to_env_when_vault_misses() {
let key = "QED_SECRETS_FALLBACK_VAR_AAAA";
std::env::set_var(key, "from-env");
let mut cfg = SecretsConfig::default();
cfg.secrets.insert(
"GH_PAT".into(),
format!("vault:nonexistent-slot-1f2e|env:{key}"),
);
let v = cfg.resolve_all();
assert_eq!(string_at(&v, "GH_PAT").as_deref(), Some("from-env"));
std::env::remove_var(key);
}
#[test]
fn parses_a_secrets_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.toml");
std::fs::write(
&path,
r#"
[secrets]
GITHUB_TOKEN = "vault:github-pat"
CF_R2_ACCESS_KEY = "vault:r2-access-key|env:CF_R2_ACCESS_KEY"
"#,
)
.unwrap();
let cfg = SecretsConfig::load_from(&path);
assert_eq!(cfg.secrets.len(), 2);
assert_eq!(
cfg.secrets.get("GITHUB_TOKEN").map(|s| s.as_str()),
Some("vault:github-pat"),
);
assert_eq!(cfg.names(), vec!["CF_R2_ACCESS_KEY", "GITHUB_TOKEN"]);
}
#[test]
fn save_to_roundtrips_through_load_from() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.toml");
let mut entries = std::collections::BTreeMap::new();
entries.insert("GITHUB_TOKEN".into(), "vault:github-pat".into());
entries.insert("GH_PAT".into(), "vault:github-pat|env:GH_PAT_LOCAL".into());
save_to(&path, &entries).unwrap();
let cfg = SecretsConfig::load_from(&path);
assert_eq!(cfg.secrets.len(), 2);
assert_eq!(
cfg.secrets.get("GITHUB_TOKEN").map(|s| s.as_str()),
Some("vault:github-pat"),
);
assert_eq!(
cfg.secrets.get("GH_PAT").map(|s| s.as_str()),
Some("vault:github-pat|env:GH_PAT_LOCAL"),
);
}
#[test]
fn save_to_quotes_special_chars_in_source() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.toml");
let mut entries = std::collections::BTreeMap::new();
entries.insert("TRICKY".into(), "bare \"quoted\" \\and\\ slashed".into());
save_to(&path, &entries).unwrap();
let cfg = SecretsConfig::load_from(&path);
assert_eq!(
cfg.secrets.get("TRICKY").map(|s| s.as_str()),
Some("bare \"quoted\" \\and\\ slashed"),
);
}
#[test]
fn resolve_status_reports_presence_not_values() {
let key = "QED_SECRETS_STATUS_VAR_BBBB";
std::env::set_var(key, "present");
let mut cfg = SecretsConfig::default();
cfg.secrets.insert("PRESENT".into(), format!("env:{key}"));
cfg.secrets
.insert("ABSENT".into(), "env:DEFINITELY_NOT_SET_QED_X93".into());
let report = cfg.resolve_status();
assert_eq!(report.len(), 2);
assert_eq!(report[0].name, "ABSENT");
assert!(!report[0].resolved);
assert_eq!(report[1].name, "PRESENT");
assert!(report[1].resolved);
assert_eq!(report[1].source, format!("env:{key}"));
std::env::remove_var(key);
}
fn string_at(v: &yah_qed_gha::Value, key: &str) -> Option<String> {
match v {
yah_qed_gha::Value::Object(m) => m.get(key).and_then(|x| match x {
yah_qed_gha::Value::String(s) => Some(s.clone()),
_ => None,
}),
_ => None,
}
}
}