use tokio::process::Command;
pub struct KeychainBridge {
service_prefix: String,
}
impl KeychainBridge {
pub fn new(org_name: &str) -> Self {
Self {
service_prefix: format!("hyperforge:{}", org_name),
}
}
fn service_name(&self, key: &str) -> String {
format!("{}:{}", self.service_prefix, key)
}
pub async fn get(&self, key: &str) -> Result<Option<String>, String> {
let service = self.service_name(key);
let account = std::env::var("USER").unwrap_or_else(|_| "hyperforge".to_string());
let output = Command::new("security")
.args(["find-generic-password", "-a", &account, "-s", &service, "-w"])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(Some(value))
} else {
Ok(None)
}
}
pub async fn set(&self, key: &str, value: &str) -> Result<(), String> {
let service = self.service_name(key);
let account = std::env::var("USER").unwrap_or_else(|_| "hyperforge".to_string());
let _ = Command::new("security")
.args(["delete-generic-password", "-s", &service])
.output()
.await;
let output = Command::new("security")
.args([
"add-generic-password",
"-s",
&service,
"-a",
&account,
"-w",
value,
"-U", ])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
pub async fn exists(&self, key: &str) -> Result<bool, String> {
Ok(self.get(key).await?.is_some())
}
pub async fn delete(&self, key: &str) -> Result<(), String> {
let service = self.service_name(key);
let output = Command::new("security")
.args(["delete-generic-password", "-s", &service])
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("could not be found") {
Ok(())
} else {
Err(stderr.to_string())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_service_name_format() {
let bridge = KeychainBridge::new("myorg");
let service = bridge.service_name("github");
assert_eq!(service, "hyperforge:myorg:github");
}
#[tokio::test]
#[ignore] async fn test_set_and_get() {
let bridge = KeychainBridge::new("test-org");
let key = "test-token";
let value = "test-value-12345";
let _ = bridge.delete(key).await;
bridge.set(key, value).await.unwrap();
let retrieved = bridge.get(key).await.unwrap();
assert_eq!(retrieved, Some(value.to_string()));
bridge.delete(key).await.unwrap();
}
#[tokio::test]
#[ignore] async fn test_get_nonexistent() {
let bridge = KeychainBridge::new("test-org");
let result = bridge.get("nonexistent-key").await.unwrap();
assert_eq!(result, None);
}
#[tokio::test]
#[ignore] async fn test_delete_nonexistent() {
let bridge = KeychainBridge::new("test-org");
bridge.delete("nonexistent-key").await.unwrap();
}
}