use std::io::Write;
use std::process::{Command, Stdio};
const SECURITY: &str = "/usr/bin/security";
const HEX: &str = "aura-hex:";
fn encode(value: &str) -> String {
if value.is_ascii() {
value.into()
} else {
format!("{HEX}{}", value.bytes().map(|b| format!("{b:02x}")).collect::<String>())
}
}
fn decode(stored: &str) -> String {
let Some(hex) = stored.strip_prefix(HEX) else { return stored.into() };
let bytes: Option<Vec<u8>> =
(0..hex.len() / 2).map(|i| u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()).collect();
bytes.and_then(|bytes| String::from_utf8(bytes).ok()).unwrap_or_else(|| stored.into())
}
pub fn secret_set(service: &str, account: &str, value: &str) -> Result<(), String> {
let command = add_command(service, account, &encode(value))?;
let mut child = Command::new(SECURITY)
.arg("-i")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("no se pudo ejecutar security: {error}"))?;
let written = child.stdin.take().ok_or("security no abrió su entrada estándar")?.write_all(command.as_bytes());
let output = child.wait_with_output().map_err(|error| format!("security no terminó: {error}"))?;
written.map_err(|error| format!("no se pudo enviar la clave a security: {error}"))?;
if output.status.success() && output.stderr.is_empty() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_owned())
}
}
fn add_command(service: &str, account: &str, value: &str) -> Result<String, String> {
let quote = |text: &str| -> Result<String, String> {
if text.chars().any(char::is_control) {
return Err("la clave y el nombre del proveedor no pueden contener caracteres de control".into());
}
Ok(format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")))
};
Ok(format!("add-generic-password -U -s {} -a {} -w {}\n", quote(service)?, quote(account)?, quote(value)?))
}
pub fn secret_get(service: &str, account: &str) -> Option<String> {
let output = Command::new(SECURITY)
.args(["find-generic-password", "-s", service, "-a", account, "-w"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
output.status.success().then(|| decode(String::from_utf8_lossy(&output.stdout).trim()))
}
pub fn secret_remove(service: &str, account: &str) -> Result<(), String> {
let status = Command::new(SECURITY)
.args(["delete-generic-password", "-s", service, "-a", account])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|error| format!("no se pudo ejecutar security: {error}"))?;
if status.success() || status.code() == Some(44) { Ok(()) } else { Err(format!("security terminó con {status}")) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn los_valores_no_ascii_viajan_en_hex_y_vuelven_iguales() {
let value = "tokén€\"x";
let stored = encode(value);
assert!(stored.is_ascii());
assert_eq!(decode(&stored), value);
assert_eq!(encode("abc"), "abc");
assert_eq!(decode("abc"), "abc");
assert_eq!(decode("aura-hex:zz"), "aura-hex:zz");
}
#[test]
fn la_linea_de_security_escapa_comillas_y_barras() {
let line = add_command("aura-ai", "anthropic", r#"p"a\ss w0rd$`x"#).unwrap();
assert_eq!(line, "add-generic-password -U -s \"aura-ai\" -a \"anthropic\" -w \"p\\\"a\\\\ss w0rd$`x\"\n");
assert_eq!(line.matches('\n').count(), 1);
}
#[test]
fn un_salto_de_linea_no_puede_encadenar_otro_comando() {
assert!(add_command("aura-ai", "openai", "abc\ndelete-generic-password -s x").is_err());
assert!(add_command("aura-ai", "openai", "abc\rx").is_err());
assert!(add_command("aura-ai", "openai", "abc\0x").is_err());
assert!(add_command("aura-ai", "open\nai", "abc").is_err());
}
}