use std::io::Write as _;
use std::process::{Command, Stdio};
use std::time::Duration;
use tracing::{debug, error, warn};
use crate::error::SynqroError;
use crate::keychain::KeychainProvider;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinuxBackend {
SecretTool,
Keyctl,
}
const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(10);
pub struct LinuxKeychain {
backend: LinuxBackend,
}
impl LinuxKeychain {
pub fn new() -> Result<Self, SynqroError> {
if binary_available("secret-tool") {
debug!(backend = "secret-tool", "Linux keychain backend selected");
return Ok(Self {
backend: LinuxBackend::SecretTool,
});
}
if binary_available("keyctl") {
warn!(
backend = "keyctl",
"secret-tool not found; falling back to kernel keyring (keyctl)"
);
return Ok(Self {
backend: LinuxBackend::Keyctl,
});
}
error!("Neither secret-tool nor keyctl found; cannot initialise Linux keychain");
Err(SynqroError::Permission(
"neither secret-tool nor keyctl found".into(),
))
}
}
impl KeychainProvider for LinuxKeychain {
fn load_secret(&self, service: &str, account: &str) -> Result<Vec<u8>, SynqroError> {
match self.backend {
LinuxBackend::SecretTool => secret_tool_lookup(service, account),
LinuxBackend::Keyctl => keyctl_read(service, account),
}
}
fn store_secret(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), SynqroError> {
match self.backend {
LinuxBackend::SecretTool => secret_tool_store(service, account, secret),
LinuxBackend::Keyctl => keyctl_store(service, account, secret),
}
}
fn delete_secret(&self, service: &str, account: &str) -> Result<(), SynqroError> {
match self.backend {
LinuxBackend::SecretTool => secret_tool_clear(service, account),
LinuxBackend::Keyctl => keyctl_unlink(service, account),
}
}
}
fn secret_tool_lookup(service: &str, account: &str) -> Result<Vec<u8>, SynqroError> {
let output = run_with_timeout(
Command::new("secret-tool")
.args(["lookup", "service", service, "account", account])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear(), SUBPROCESS_TIMEOUT,
)?;
if !output.status.success() {
return Err(SynqroError::Keychain(
"secret-tool lookup: entry not found or keyring locked".to_owned(),
));
}
let mut bytes = output.stdout;
if bytes.last() == Some(&b'\n') {
bytes.pop();
}
Ok(bytes)
}
fn secret_tool_store(service: &str, account: &str, secret: &[u8]) -> Result<(), SynqroError> {
let mut child = Command::new("secret-tool")
.args([
"store",
"--label",
"Synqro Token",
"service",
service,
"account",
account,
])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.env_clear() .spawn()
.map_err(|e| {
error!(error = %e, "Failed to spawn secret-tool store");
SynqroError::Permission(e.to_string())
})?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| SynqroError::Internal("stdin not available".into()))?;
stdin.write_all(secret).map_err(|e| {
error!(error = %e, "Failed to write secret to secret-tool stdin");
SynqroError::Keychain("stdin write failed".to_owned())
})?;
}
let output = wait_with_timeout(child, SUBPROCESS_TIMEOUT)?;
if !output.status.success() {
return Err(SynqroError::Keychain(
"secret-tool store: operation failed".to_owned(),
));
}
Ok(())
}
fn secret_tool_clear(service: &str, account: &str) -> Result<(), SynqroError> {
let output = run_with_timeout(
Command::new("secret-tool")
.args(["clear", "service", service, "account", account])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.env_clear(), SUBPROCESS_TIMEOUT,
)?;
if !output.status.success() {
return Err(SynqroError::Keychain(
"secret-tool clear: operation failed".to_owned(),
));
}
Ok(())
}
fn keyctl_key_desc(service: &str, account: &str) -> String {
format!("synqro/{}/{}", service, account)
}
fn keyctl_read(service: &str, account: &str) -> Result<Vec<u8>, SynqroError> {
let desc = keyctl_key_desc(service, account);
let id_output = run_with_timeout(
Command::new("keyctl")
.args(["search", "@u", "user", &desc])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear(), SUBPROCESS_TIMEOUT,
)?;
if !id_output.status.success() {
return Err(SynqroError::Keychain(format!(
"keyctl search: key '{}' not found in @u keyring",
desc
)));
}
let key_id = String::from_utf8_lossy(&id_output.stdout).trim().to_owned();
let pipe_output = run_with_timeout(
Command::new("keyctl")
.args(["pipe", &key_id])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear(), SUBPROCESS_TIMEOUT,
)?;
if !pipe_output.status.success() {
return Err(SynqroError::Keychain(
"keyctl pipe: failed to read key payload".to_owned(),
));
}
Ok(pipe_output.stdout)
}
fn keyctl_store(service: &str, account: &str, secret: &[u8]) -> Result<(), SynqroError> {
let desc = keyctl_key_desc(service, account);
let mut child = Command::new("keyctl")
.args(["padd", "user", &desc, "@u"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.env_clear() .spawn()
.map_err(|e| {
error!(error = %e, "Failed to spawn keyctl padd");
SynqroError::Permission(e.to_string())
})?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| SynqroError::Internal("stdin not available".into()))?;
stdin.write_all(secret).map_err(|e| {
error!(error = %e, "Failed to write secret to keyctl stdin");
SynqroError::Keychain("stdin write failed".to_owned())
})?;
}
let output = wait_with_timeout(child, SUBPROCESS_TIMEOUT)?;
if !output.status.success() {
return Err(SynqroError::Keychain(
"keyctl padd: store operation failed".to_owned(),
));
}
Ok(())
}
fn keyctl_unlink(service: &str, account: &str) -> Result<(), SynqroError> {
let desc = keyctl_key_desc(service, account);
let id_output = run_with_timeout(
Command::new("keyctl")
.args(["search", "@u", "user", &desc])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear(), SUBPROCESS_TIMEOUT,
)?;
if !id_output.status.success() {
debug!(key_desc = %desc, "keyctl: key not found; delete is a no-op");
return Ok(());
}
let key_id = String::from_utf8_lossy(&id_output.stdout).trim().to_owned();
let unlink_output = run_with_timeout(
Command::new("keyctl")
.args(["unlink", &key_id, "@u"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.env_clear(), SUBPROCESS_TIMEOUT,
)?;
if !unlink_output.status.success() {
return Err(SynqroError::Keychain(
"keyctl unlink: delete operation failed".to_owned(),
));
}
Ok(())
}
fn binary_available(binary: &str) -> bool {
Command::new(binary)
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.env_clear()
.spawn()
.and_then(|mut c| c.wait())
.is_ok()
}
fn run_with_timeout(
cmd: &mut Command,
timeout: Duration,
) -> Result<std::process::Output, SynqroError> {
let child = cmd.spawn().map_err(|e| {
error!(error = %e, "Failed to spawn subprocess");
SynqroError::Permission(e.to_string())
})?;
wait_with_timeout(child, timeout)
}
fn wait_with_timeout(
child: std::process::Child,
timeout: Duration,
) -> Result<std::process::Output, SynqroError> {
let (tx, rx) = std::sync::mpsc::channel::<Result<std::process::Output, std::io::Error>>();
std::thread::spawn(move || {
let result = child.wait_with_output();
let _ = tx.send(result);
});
match rx.recv_timeout(timeout) {
Ok(Ok(output)) => Ok(output),
Ok(Err(io_err)) => {
error!(error = %io_err, "Subprocess I/O error");
Err(SynqroError::Io(io_err))
}
Err(_timeout) => {
error!("Subprocess exceeded 10-second timeout");
Err(SynqroError::Keychain(
"subprocess timed out after 10 seconds".to_owned(),
))
}
}
}