use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
const AUR_HOST: &str = "aur.archlinux.org";
const AUR_KEY_NAME: &str = "aur_key";
pub const AUR_ACCOUNT_URL: &str = "https://aur.archlinux.org/login";
#[must_use]
pub fn ssh_public_key_line_from_status_lines(status_lines: &[String]) -> Option<String> {
status_lines.iter().find_map(|line| {
let trimmed = line.trim();
trimmed.starts_with("ssh-").then(|| trimmed.to_string())
})
}
#[must_use]
pub fn try_copy_aur_ssh_public_key_from_status_lines(
status_lines: &[String],
) -> Option<Result<(), String>> {
let key_line = ssh_public_key_line_from_status_lines(status_lines)?;
Some(crate::util::clipboard::copy_plain_text_to_clipboard(
&key_line,
))
}
#[must_use]
pub fn is_openssh_installed() -> bool {
#[cfg(test)]
if let Ok(v) = std::env::var("PACSEA_TEST_OPENSSH_INSTALLED") {
return v == "1";
}
crate::index::is_installed("openssh")
}
pub enum AurSshSetupResult {
Completed(AurSshSetupReport),
NeedsOverwrite {
existing_block: String,
lines: Vec<String>,
},
}
pub struct AurSshSetupReport {
pub success: bool,
pub lines: Vec<String>,
}
#[must_use]
pub fn is_aur_ssh_setup_configured() -> bool {
let Some(home) = home_dir() else {
return false;
};
let ssh_dir = home.join(".ssh");
let key_path = ssh_dir.join(AUR_KEY_NAME);
if !key_path.exists() {
return false;
}
let config_path = ssh_dir.join("config");
let Ok(content) = fs::read_to_string(config_path) else {
return false;
};
find_host_block(&content, AUR_HOST)
.is_some_and(|(_, _, block)| block_has_required_directives(&block))
}
#[must_use]
pub fn run_aur_ssh_setup(overwrite_existing_host: bool) -> AurSshSetupResult {
let mut lines = Vec::new();
let Some(home) = home_dir() else {
return AurSshSetupResult::Completed(AurSshSetupReport {
success: false,
lines: vec![setup_failure_line(
"home",
"could not resolve your home directory (HOME may be unset in this session)",
)],
});
};
let ssh_dir = home.join(".ssh");
if let Err(err) = fs::create_dir_all(&ssh_dir) {
return AurSshSetupResult::Completed(AurSshSetupReport {
success: false,
lines: vec![setup_failure_line(
"directory creation",
format!("could not create '{}': {err}", ssh_dir.display()),
)],
});
}
lines.push(format!("SSH directory ready: '{}'", ssh_dir.display()));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(err) = fs::set_permissions(&ssh_dir, fs::Permissions::from_mode(0o700)) {
lines.push(format!(
"Warning: could not set '{}' permissions to 700: {err}",
ssh_dir.display()
));
}
}
let known_hosts_path = match ensure_known_hosts_file_exists(&ssh_dir, &mut lines) {
Ok(path) => path,
Err(err) => {
return AurSshSetupResult::Completed(AurSshSetupReport {
success: false,
lines: vec![setup_failure_line("known_hosts", err)],
});
}
};
maybe_seed_known_hosts_with_aur_entry(&known_hosts_path, &mut lines);
let key_path = ssh_dir.join(AUR_KEY_NAME);
if key_path.exists() {
lines.push(format!("Key exists: '{}'", key_path.display()));
} else {
let output = Command::new("ssh-keygen")
.args(["-t", "ed25519", "-f"])
.arg(&key_path)
.args(["-N", ""])
.output();
match output {
Ok(out) if out.status.success() => {
lines.push(format!("Created key pair: '{}'", key_path.display()));
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
lines.push(format!(
"Failed [keygen]: ssh-keygen exited with code {}: {}",
out.status.code().unwrap_or(-1),
if stderr.is_empty() {
"no stderr output".to_string()
} else {
stderr
}
));
return AurSshSetupResult::Completed(AurSshSetupReport {
success: false,
lines,
});
}
Err(err) => {
lines.push(setup_failure_line(
"keygen",
format!("could not run ssh-keygen: {err}"),
));
return AurSshSetupResult::Completed(AurSshSetupReport {
success: false,
lines,
});
}
}
}
let config_path = ssh_dir.join("config");
match write_or_update_aur_host_config(&config_path, overwrite_existing_host, &mut lines) {
Ok(Some(existing_block)) => {
return AurSshSetupResult::NeedsOverwrite {
existing_block,
lines,
};
}
Ok(None) => {}
Err(err) => {
lines.push(setup_failure_line(
"config update",
format!("could not update '{}': {err}", config_path.display()),
));
return AurSshSetupResult::Completed(AurSshSetupReport {
success: false,
lines,
});
}
}
let pub_key_path = key_path.with_extension("pub");
match fs::read_to_string(&pub_key_path) {
Ok(pub_key) => {
let trimmed = pub_key.trim();
if trimmed.is_empty() {
lines.push(
"Warning: public key file is empty. Re-run setup or regenerate key."
.to_string(),
);
} else {
lines.push(format!(
"Public key file: '{}' (copy this into your AUR account).",
pub_key_path.display()
));
lines.push(trimmed.to_string());
}
}
Err(err) => {
lines.push(format!(
"Warning: could not read public key '{}': {err}",
pub_key_path.display()
));
}
}
lines.push(format!(
"Next step: open {AUR_ACCOUNT_URL} and paste the public key."
));
AurSshSetupResult::Completed(AurSshSetupReport {
success: true,
lines,
})
}
fn ensure_known_hosts_file_exists(
ssh_dir: &Path,
lines: &mut Vec<String>,
) -> Result<PathBuf, String> {
let known_hosts_path = ssh_dir.join("known_hosts");
if known_hosts_path.exists() {
lines.push(format!(
"known_hosts file ready: '{}'",
known_hosts_path.display()
));
} else {
OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&known_hosts_path)
.map_err(|err| format!("could not create '{}': {err}", known_hosts_path.display()))?;
lines.push(format!(
"Created known_hosts file: '{}'",
known_hosts_path.display()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(err) = fs::set_permissions(&known_hosts_path, fs::Permissions::from_mode(0o600))
{
lines.push(format!(
"Warning: could not set '{}' permissions to 600: {err}",
known_hosts_path.display()
));
}
}
Ok(known_hosts_path)
}
fn maybe_seed_known_hosts_with_aur_entry(known_hosts_path: &Path, lines: &mut Vec<String>) {
let content = fs::read_to_string(known_hosts_path).unwrap_or_default();
if content.contains(AUR_HOST) {
lines.push("known_hosts already contains aur.archlinux.org entry.".to_string());
return;
}
let output = Command::new("ssh-keyscan").args(["-H", AUR_HOST]).output();
match output {
Ok(out) if out.status.success() && !out.stdout.is_empty() => {
match OpenOptions::new().append(true).open(known_hosts_path) {
Ok(mut file) => {
if let Err(err) = file.write_all(&out.stdout) {
lines.push(format!(
"Warning: failed to append AUR host key to '{}': {err}",
known_hosts_path.display()
));
} else {
lines.push("Added aur.archlinux.org host key to known_hosts.".to_string());
}
}
Err(err) => {
lines.push(format!(
"Warning: failed to open '{}' for host key append: {err}",
known_hosts_path.display()
));
}
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
lines.push(format!(
"Warning: could not fetch AUR host key via ssh-keyscan (exit {}): {}",
out.status.code().unwrap_or(-1),
if stderr.is_empty() {
"no stderr output".to_string()
} else {
stderr
}
));
}
Err(err) => {
lines.push(format!(
"Warning: ssh-keyscan unavailable for known_hosts seeding: {err}"
));
}
}
}
#[must_use]
pub fn validate_aur_ssh_setup_connection(ssh_command: &str) -> AurSshSetupReport {
let Some(home) = home_dir() else {
return AurSshSetupReport {
success: false,
lines: vec![setup_failure_line(
"home",
"could not resolve your home directory (HOME may be unset in this session)",
)],
};
};
let key_path = home.join(".ssh").join(AUR_KEY_NAME);
let validation = Command::new(ssh_command)
.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"])
.arg("aur@aur.archlinux.org")
.arg("help")
.output();
match validation {
Ok(out) if out.status.success() => AurSshSetupReport {
success: true,
lines: vec!["Validation OK: 'ssh aur@aur.archlinux.org help' succeeded.".to_string()],
},
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
let mut lines = Vec::new();
lines.push(format!(
"Failed [connection check]: ssh validation exited with code {}: {}",
out.status.code().unwrap_or(-1),
if detail.is_empty() {
"no output".to_string()
} else {
detail
}
));
lines.push(format!(
"Next step: upload public key '{}' to {}",
key_path.with_extension("pub").display(),
AUR_ACCOUNT_URL
));
AurSshSetupReport {
success: false,
lines,
}
}
Err(err) => AurSshSetupReport {
success: false,
lines: vec![setup_failure_line(
"connection check",
format!("could not run ssh validation command '{ssh_command}': {err}"),
)],
},
}
}
fn setup_failure_line(stage: &str, detail: impl AsRef<str>) -> String {
format!("Failed [{stage}]: {}", detail.as_ref())
}
#[must_use]
pub fn spawn_aur_ssh_help_check(ssh_command: String) -> Arc<Mutex<Option<bool>>> {
let result = Arc::new(Mutex::new(None));
let result_clone = Arc::clone(&result);
std::thread::spawn(move || {
let ok = Command::new(&ssh_command)
.args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"])
.arg("aur@aur.archlinux.org")
.arg("help")
.output()
.is_ok_and(|out| out.status.success());
if let Ok(mut slot) = result_clone.lock() {
*slot = Some(ok);
}
});
result
}
fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from)
.or_else(resolve_home_dir_unix_passwd)
}
#[cfg(unix)]
fn resolve_home_dir_unix_passwd() -> Option<PathBuf> {
use nix::unistd::{Uid, User};
let uid = Uid::current();
User::from_uid(uid)
.ok()
.flatten()
.and_then(|user| (!user.dir.as_os_str().is_empty()).then_some(user.dir))
}
#[cfg(not(unix))]
fn resolve_home_dir_unix_passwd() -> Option<PathBuf> {
None
}
fn desired_aur_host_block() -> String {
"Host aur.archlinux.org\n User aur\n IdentityFile ~/.ssh/aur_key\n IdentitiesOnly yes\n"
.to_string()
}
fn find_host_block(content: &str, host: &str) -> Option<(usize, usize, String)> {
let mut entries: Vec<(usize, &str)> = Vec::new();
let mut start = 0usize;
for line in content.lines() {
entries.push((start, line));
start = start.saturating_add(line.len()).saturating_add(1);
}
let mut block_start: Option<usize> = None;
let mut end = content.len();
for (line_start, line) in entries {
let trimmed = line.trim();
if !trimmed.starts_with("Host ") {
continue;
}
if block_start.is_none() {
let hosts = trimmed.trim_start_matches("Host ").split_whitespace();
if hosts.into_iter().any(|entry| entry == host) {
block_start = Some(line_start);
}
continue;
}
end = line_start;
break;
}
let start = block_start?;
Some((start, end, content[start..end].trim_end().to_string()))
}
fn block_has_required_directives(block: &str) -> bool {
let mut user_ok = false;
let mut id_ok = false;
let mut only_ok = false;
for line in block.lines() {
let trimmed = line.trim();
if trimmed.eq_ignore_ascii_case("User aur") {
user_ok = true;
} else if trimmed.eq_ignore_ascii_case("IdentityFile ~/.ssh/aur_key") {
id_ok = true;
} else if trimmed.eq_ignore_ascii_case("IdentitiesOnly yes") {
only_ok = true;
}
}
user_ok && id_ok && only_ok
}
fn write_or_update_aur_host_config(
config_path: &Path,
overwrite_existing_host: bool,
lines: &mut Vec<String>,
) -> Result<Option<String>, String> {
let desired = desired_aur_host_block();
let mut content = fs::read_to_string(config_path).unwrap_or_default();
if let Some((start, end, existing)) = find_host_block(&content, AUR_HOST) {
if block_has_required_directives(&existing) {
lines.push(format!(
"SSH config already contains required '{AUR_HOST}'."
));
return Ok(None);
}
if !overwrite_existing_host {
lines.push(format!(
"Existing '{AUR_HOST}' block detected. Confirmation required to overwrite."
));
return Ok(Some(existing));
}
content.replace_range(start..end, &desired);
lines.push(format!("Overwrote existing '{AUR_HOST}' host block."));
} else {
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
if !content.is_empty() {
content.push('\n');
}
content.push_str(&desired);
lines.push(format!("Added new '{AUR_HOST}' host block."));
}
fs::write(config_path, content).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(err) = fs::set_permissions(config_path, fs::Permissions::from_mode(0o600)) {
lines.push(format!(
"Warning: could not set '{}' permissions to 600: {err}",
config_path.display()
));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_config_path(name: &str) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
std::env::temp_dir().join(format!(
"pacsea_{name}_{}_{}.conf",
std::process::id(),
stamp
))
}
#[test]
fn block_directives_detected() {
let block = "Host aur.archlinux.org\n User aur\n IdentityFile ~/.ssh/aur_key\n IdentitiesOnly yes\n";
assert!(block_has_required_directives(block));
}
#[test]
fn block_directives_missing_detected() {
let block = "Host aur.archlinux.org\n User aur\n IdentityFile ~/.ssh/id_ed25519\n";
assert!(!block_has_required_directives(block));
}
#[test]
fn find_host_block_returns_range() {
let content = "Host github.com\n User git\n\nHost aur.archlinux.org\n User aur\n";
let found = find_host_block(content, "aur.archlinux.org")
.expect("expected aur host block to be found");
assert!(found.2.contains("Host aur.archlinux.org"));
}
#[test]
fn write_or_update_requests_overwrite_for_conflicting_block() {
let path = temp_config_path("ssh_setup_conflict");
let original = "Host aur.archlinux.org\n User aur\n IdentityFile ~/.ssh/id_ed25519\n";
fs::write(&path, original).expect("should write temp config");
let mut lines = Vec::new();
let result =
write_or_update_aur_host_config(&path, false, &mut lines).expect("should not error");
assert!(
result.is_some(),
"conflicting block should request overwrite"
);
let _ = fs::remove_file(path);
}
#[test]
fn write_or_update_writes_expected_block_when_missing() {
let path = temp_config_path("ssh_setup_missing");
let _ = fs::remove_file(&path);
let mut lines = Vec::new();
let result =
write_or_update_aur_host_config(&path, false, &mut lines).expect("should not error");
assert!(result.is_none(), "missing block should be written directly");
let body = fs::read_to_string(&path).expect("config should be created");
assert!(body.contains("Host aur.archlinux.org"));
assert!(body.contains("IdentityFile ~/.ssh/aur_key"));
let _ = fs::remove_file(path);
}
#[test]
fn openssh_check_honors_test_override() {
unsafe {
std::env::set_var("PACSEA_TEST_OPENSSH_INSTALLED", "1");
}
assert!(is_openssh_installed());
unsafe {
std::env::set_var("PACSEA_TEST_OPENSSH_INSTALLED", "0");
}
assert!(!is_openssh_installed());
unsafe {
std::env::remove_var("PACSEA_TEST_OPENSSH_INSTALLED");
}
}
#[test]
fn setup_failure_line_includes_stage_and_detail() {
let line = setup_failure_line("connection check", "network timeout");
assert_eq!(
line,
"Failed [connection check]: network timeout".to_string()
);
}
#[test]
fn ssh_public_key_line_from_status_finds_first_key_line() {
let lines = vec![
"Key exists: '/home/u/.ssh/aur_key'".to_string(),
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIABCD user@host".to_string(),
"Next step: open https://example.test/login".to_string(),
];
assert_eq!(
ssh_public_key_line_from_status_lines(&lines).as_deref(),
Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIABCD user@host")
);
}
#[test]
fn ensure_known_hosts_file_exists_creates_missing_file() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let ssh_dir = std::env::temp_dir().join(format!(
"pacsea_ssh_known_hosts_{}_{}",
std::process::id(),
stamp
));
fs::create_dir_all(&ssh_dir).expect("should create temp ssh dir");
let mut lines = Vec::new();
let path = ensure_known_hosts_file_exists(&ssh_dir, &mut lines)
.expect("should create known_hosts");
assert!(path.exists(), "known_hosts file should exist");
assert!(
lines
.iter()
.any(|line| line.contains("Created known_hosts file")),
"status lines should mention known_hosts creation"
);
let _ = fs::remove_file(path);
let _ = fs::remove_dir(ssh_dir);
}
}