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
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Written {
Vault { slot: String },
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum WriteError {
#[error("secret `{0}` is not declared in the qed secrets bridge")]
NotDeclared(String),
#[error("source `{0}` has no writable sink (only `vault:` alternatives are writable)")]
NoWritableSink(String),
#[error("vault slot `{slot}`: {error}")]
Vault { slot: String, error: String },
#[error("wrote vault slot `{slot}`, but source `{chain}` reads back a different value (an earlier alternative shadows it)")]
Shadowed { slot: String, chain: String },
}
pub fn writable_sink(source: &str) -> Option<&str> {
source
.split('|')
.map(str::trim)
.find_map(|alt| alt.strip_prefix("vault:"))
.filter(|slot| !slot.is_empty())
}
pub fn write_source(source: &str, value: &str, vault: &fob::KeysStore) -> Result<Written, WriteError> {
let slot = writable_sink(source).ok_or_else(|| WriteError::NoWritableSink(source.to_string()))?;
vault
.set(slot, value)
.map_err(|e| WriteError::Vault { slot: slot.to_string(), error: format!("{e:#}") })?;
if resolve_source(source, Some(vault)).as_deref() != Some(value) {
return Err(WriteError::Shadowed { slot: slot.to_string(), chain: source.to_string() });
}
Ok(Written::Vault { slot: slot.to_string() })
}
impl SecretsConfig {
pub fn write_one(&self, name: &str, value: &str) -> Result<Written, WriteError> {
let source = self.secrets.get(name).ok_or_else(|| WriteError::NotDeclared(name.to_string()))?;
let slot = writable_sink(source).ok_or_else(|| WriteError::NoWritableSink(source.clone()))?;
let vault = fob::KeysStore::open()
.map_err(|e| WriteError::Vault { slot: slot.to_string(), error: format!("{e:#}") })?;
write_source(source, value, &vault)
}
}
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 vault() -> (tempfile::TempDir, fob::KeysStore) {
let dir = tempfile::tempdir().unwrap();
let store = fob::KeysStore::at(dir.path()).unwrap();
(dir, store)
}
#[test]
fn a_written_secret_reads_back_through_the_same_source() {
let (_dir, store) = vault();
let source = "vault:qed-write-rt|env:QED_WRITE_RT_UNSET_X1";
store.set("qed-write-rt", "token-1").unwrap();
assert_eq!(resolve_source(source, Some(&store)).as_deref(), Some("token-1"));
let written = write_source(source, "token-2", &store).unwrap();
assert_eq!(written, Written::Vault { slot: "qed-write-rt".into() });
assert_eq!(resolve_source(source, Some(&store)).as_deref(), Some("token-2"));
let kept: Vec<String> = store.previous("qed-write-rt").unwrap().into_iter().map(|p| p.value).collect();
assert!(kept.contains(&"token-1".to_string()), "the displaced value is recoverable: {kept:?}");
}
#[test]
fn the_sink_is_the_first_vault_alternative() {
assert_eq!(writable_sink("env:A|vault:s1|vault:s2"), Some("s1"));
assert_eq!(writable_sink("env:A"), None);
assert_eq!(writable_sink("literal"), None);
assert_eq!(writable_sink("vault:"), None);
}
#[test]
fn a_source_with_no_store_is_refused_and_nothing_is_written() {
let (_dir, store) = vault();
for source in ["env:QED_WRITE_ENV_ONLY", "a-literal"] {
assert_eq!(
write_source(source, "v", &store),
Err(WriteError::NoWritableSink(source.to_string()))
);
}
assert!(store.list().unwrap().is_empty());
}
#[test]
fn a_write_an_earlier_env_alternative_shadows_fails_loudly() {
let (_dir, store) = vault();
let var = "QED_WRITE_SHADOW_VAR_CCCC";
std::env::set_var(var, "from-env");
let source = format!("env:{var}|vault:qed-write-shadow");
let err = write_source(&source, "rotated", &store).unwrap_err();
assert!(matches!(&err, WriteError::Shadowed { slot, .. } if slot == "qed-write-shadow"), "{err:?}");
assert!(!err.to_string().contains("rotated"), "an error never carries the value: {err}");
std::env::remove_var(var);
}
#[test]
fn a_vault_that_cannot_store_is_a_typed_failure() {
use std::os::unix::fs::PermissionsExt;
let (dir, store) = vault();
store.set("qed-write-fail", "old").unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap();
let err = write_source("vault:qed-write-fail", "new", &store).unwrap_err();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(matches!(&err, WriteError::Vault { slot, .. } if slot == "qed-write-fail"), "{err:?}");
assert!(!err.to_string().contains("new"), "an error never carries the value: {err}");
assert_eq!(store.get("qed-write-fail").unwrap().as_deref(), Some("old"), "nothing half-written");
}
#[test]
fn write_one_refuses_an_undeclared_name() {
let cfg = SecretsConfig::default();
assert_eq!(cfg.write_one("NOPE", "v"), Err(WriteError::NotDeclared("NOPE".into())));
}
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,
}
}
}