use std::path::{Path, PathBuf};
use secrecy::{ExposeSecret, SecretString};
use crate::auth::file::{decrypt_credential, encrypt_credential};
use crate::error::{OlError, OL_4201_KEYRING_UNAVAILABLE, OL_4204_TOKEN_FILE_UNREADABLE};
const SERVICE_NAME: &str = "openlatch-provider-binding";
const SKIP_KEYRING_ENV: &str = "OPENLATCH_PROVIDER_SKIP_KEYRING";
pub trait BindingSecretStore: Send + Sync {
fn store(&self, binding_id: &str, secret: SecretString) -> Result<(), OlError>;
fn retrieve(&self, binding_id: &str) -> Result<SecretString, OlError>;
fn list_known(&self) -> Result<Vec<String>, OlError>;
fn delete(&self, binding_id: &str) -> Result<(), OlError>;
}
pub struct KeyringBindingSecretStore;
impl Default for KeyringBindingSecretStore {
fn default() -> Self {
Self::new()
}
}
impl KeyringBindingSecretStore {
pub fn new() -> Self {
Self
}
}
fn keyring_disabled_by_env() -> bool {
match std::env::var(SKIP_KEYRING_ENV) {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
!matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
}
Err(_) => false,
}
}
impl BindingSecretStore for KeyringBindingSecretStore {
fn store(&self, binding_id: &str, secret: SecretString) -> Result<(), OlError> {
if keyring_disabled_by_env() {
return Err(OlError::new(
OL_4201_KEYRING_UNAVAILABLE,
"keyring disabled via OPENLATCH_PROVIDER_SKIP_KEYRING",
));
}
let entry = keyring::Entry::new(SERVICE_NAME, binding_id)
.map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring open: {e}")))?;
entry
.set_password(secret.expose_secret())
.map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring set: {e}")))
}
fn retrieve(&self, binding_id: &str) -> Result<SecretString, OlError> {
if keyring_disabled_by_env() {
return Err(OlError::new(
OL_4201_KEYRING_UNAVAILABLE,
"keyring disabled via OPENLATCH_PROVIDER_SKIP_KEYRING",
));
}
let entry = keyring::Entry::new(SERVICE_NAME, binding_id)
.map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring open: {e}")))?;
let pwd = entry
.get_password()
.map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring get: {e}")))?;
Ok(SecretString::from(pwd))
}
fn list_known(&self) -> Result<Vec<String>, OlError> {
Ok(Vec::new())
}
fn delete(&self, binding_id: &str) -> Result<(), OlError> {
let entry = keyring::Entry::new(SERVICE_NAME, binding_id)
.map_err(|e| OlError::new(OL_4201_KEYRING_UNAVAILABLE, format!("keyring open: {e}")))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(OlError::new(
OL_4201_KEYRING_UNAVAILABLE,
format!("keyring delete: {e}"),
)),
}
}
}
pub struct FileBindingSecretStore {
dir: PathBuf,
machine_id: String,
}
impl FileBindingSecretStore {
pub fn new(dir: impl Into<PathBuf>, machine_id: impl Into<String>) -> Self {
Self {
dir: dir.into(),
machine_id: machine_id.into(),
}
}
fn entry_path(&self, binding_id: &str) -> PathBuf {
self.dir.join(format!("{binding_id}.enc"))
}
fn ensure_dir(&self) -> Result<(), OlError> {
std::fs::create_dir_all(&self.dir).map_err(|e| {
OlError::new(
OL_4204_TOKEN_FILE_UNREADABLE,
format!("cannot create {}: {e}", self.dir.display()),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o700));
}
Ok(())
}
}
impl BindingSecretStore for FileBindingSecretStore {
fn store(&self, binding_id: &str, secret: SecretString) -> Result<(), OlError> {
self.ensure_dir()?;
let plaintext = secret.expose_secret().as_bytes().to_vec();
let blob = encrypt_credential(&plaintext, &self.machine_id);
let path = self.entry_path(binding_id);
let tmp = path.with_extension("enc.tmp");
std::fs::write(&tmp, &blob).map_err(|e| {
OlError::new(
OL_4204_TOKEN_FILE_UNREADABLE,
format!("write {}: {e}", tmp.display()),
)
})?;
std::fs::rename(&tmp, &path).map_err(|e| {
OlError::new(
OL_4204_TOKEN_FILE_UNREADABLE,
format!("rename {}: {e}", path.display()),
)
})?;
Ok(())
}
fn retrieve(&self, binding_id: &str) -> Result<SecretString, OlError> {
let path = self.entry_path(binding_id);
let blob = std::fs::read(&path).map_err(|e| {
OlError::new(
OL_4204_TOKEN_FILE_UNREADABLE,
format!("read {}: {e}", path.display()),
)
})?;
let plaintext = decrypt_credential(&blob, &self.machine_id)?;
let s = String::from_utf8(plaintext).map_err(|e| {
OlError::new(
OL_4204_TOKEN_FILE_UNREADABLE,
format!("non-utf8 secret: {e}"),
)
})?;
Ok(SecretString::from(s))
}
fn list_known(&self) -> Result<Vec<String>, OlError> {
if !self.dir.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&self.dir).map_err(|e| {
OlError::new(
OL_4204_TOKEN_FILE_UNREADABLE,
format!("read {}: {e}", self.dir.display()),
)
})? {
let entry = entry.map_err(|e| {
OlError::new(OL_4204_TOKEN_FILE_UNREADABLE, format!("read entry: {e}"))
})?;
let p = entry.path();
if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("enc") {
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
out.push(stem.to_string());
}
}
}
out.sort();
Ok(out)
}
fn delete(&self, binding_id: &str) -> Result<(), OlError> {
let path = self.entry_path(binding_id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(OlError::new(
OL_4204_TOKEN_FILE_UNREADABLE,
format!("delete {}: {e}", path.display()),
)),
}
}
}
pub fn default_file_dir(provider_dir: &Path) -> PathBuf {
provider_dir.join("binding-secrets")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn file_store_round_trips_secret() {
let tmp = TempDir::new().unwrap();
let store = FileBindingSecretStore::new(tmp.path(), "mach_test");
let secret = SecretString::from("whsec_live_abc123".to_string());
store.store("bnd_42", secret).unwrap();
let got = store.retrieve("bnd_42").unwrap();
assert_eq!(got.expose_secret(), "whsec_live_abc123");
}
#[test]
fn file_store_list_known_returns_stored_ids() {
let tmp = TempDir::new().unwrap();
let store = FileBindingSecretStore::new(tmp.path(), "mach_test");
store
.store("bnd_a", SecretString::from("s1".to_string()))
.unwrap();
store
.store("bnd_b", SecretString::from("s2".to_string()))
.unwrap();
let ids = store.list_known().unwrap();
assert_eq!(ids, vec!["bnd_a".to_string(), "bnd_b".to_string()]);
}
#[test]
fn file_store_delete_is_idempotent() {
let tmp = TempDir::new().unwrap();
let store = FileBindingSecretStore::new(tmp.path(), "mach_test");
store.delete("bnd_missing").unwrap();
store
.store("bnd_x", SecretString::from("x".to_string()))
.unwrap();
store.delete("bnd_x").unwrap();
store.delete("bnd_x").unwrap(); }
#[test]
fn file_store_decrypt_fails_with_wrong_machine_id() {
let tmp = TempDir::new().unwrap();
let s1 = FileBindingSecretStore::new(tmp.path(), "mach_a");
s1.store("bnd_a", SecretString::from("secret".to_string()))
.unwrap();
let s2 = FileBindingSecretStore::new(tmp.path(), "mach_DIFFERENT");
let err = s2.retrieve("bnd_a").unwrap_err();
assert_eq!(err.code.code, "OL-4204");
}
}