use std::fmt;
use std::process::{Command, Output, Stdio};
const AUR_SSH_HOST: &str = "aur@aur.archlinux.org";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VoteAction {
Vote,
Unvote,
}
impl VoteAction {
const fn as_ssh_arg(self) -> &'static str {
match self {
Self::Vote => "vote",
Self::Unvote => "unvote",
}
}
}
impl fmt::Display for VoteAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Vote => write!(f, "Vote"),
Self::Unvote => write!(f, "Unvote"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AurPackageVoteState {
Voted,
NotVoted,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AurVoteOutcome {
pub action: VoteAction,
pub pkgbase: String,
pub dry_run: bool,
}
impl AurVoteOutcome {
#[must_use]
pub fn message(&self) -> String {
if self.dry_run {
return match self.action {
VoteAction::Vote => {
format!("[dry-run] Would vote for '{}'", self.pkgbase)
}
VoteAction::Unvote => {
format!("[dry-run] Would remove vote for '{}'", self.pkgbase)
}
};
}
match self.action {
VoteAction::Vote => format!("Voted for '{}'", self.pkgbase),
VoteAction::Unvote => format!("Removed vote for '{}'", self.pkgbase),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AurVoteError {
AlreadyVoted(String),
NotVoted(String),
NotFound(String),
AuthFailed(String),
Maintenance,
Banned,
Timeout(String),
NetworkError(String),
SshNotFound(String),
Unexpected(String),
}
impl fmt::Display for AurVoteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AlreadyVoted(pkg) => {
write!(f, "You have already voted for '{pkg}'")
}
Self::NotVoted(pkg) => {
write!(f, "You haven't voted for '{pkg}'")
}
Self::NotFound(pkg) => {
write!(f, "Package base '{pkg}' not found on AUR")
}
Self::AuthFailed(detail) => {
write!(
f,
"SSH auth failed. Ensure your SSH key is uploaded to your AUR account \
at https://aur.archlinux.org/account ({detail})"
)
}
Self::Maintenance => {
write!(f, "AUR is under maintenance. Try again later")
}
Self::Banned => {
write!(f, "SSH interface disabled for your IP. Contact AUR support")
}
Self::Timeout(detail) => {
write!(
f,
"Connection to aur.archlinux.org timed out. Check network ({detail})"
)
}
Self::NetworkError(detail) => {
write!(
f,
"Could not connect to aur.archlinux.org. Check connectivity ({detail})"
)
}
Self::SshNotFound(cmd) => {
write!(
f,
"SSH binary '{cmd}' not found. Install openssh or configure \
aur_vote_ssh_command in settings"
)
}
Self::Unexpected(detail) => {
write!(f, "AUR vote failed unexpectedly: {detail}")
}
}
}
}
impl std::error::Error for AurVoteError {}
#[derive(Clone, Debug)]
pub struct AurVoteContext {
pub dry_run: bool,
pub ssh_timeout_secs: u32,
pub ssh_command: String,
}
impl Default for AurVoteContext {
fn default() -> Self {
Self {
dry_run: false,
ssh_timeout_secs: 10,
ssh_command: "ssh".to_string(),
}
}
}
trait SshVoteTransport {
fn execute(
&self,
action: VoteAction,
pkgbase: &str,
ctx: &AurVoteContext,
) -> std::io::Result<Output>;
}
struct RealSshTransport;
impl SshVoteTransport for RealSshTransport {
fn execute(
&self,
action: VoteAction,
pkgbase: &str,
ctx: &AurVoteContext,
) -> std::io::Result<Output> {
let timeout_arg = format!("ConnectTimeout={}", ctx.ssh_timeout_secs);
Command::new(&ctx.ssh_command)
.args([
"-o",
&timeout_arg,
"-o",
"BatchMode=yes",
AUR_SSH_HOST,
action.as_ssh_arg(),
pkgbase,
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
}
}
const SSH_ERROR_EXIT_CODE: i32 = 255;
const LIST_VOTES_UNSUPPORTED_PATTERN: &str = "invalid command: list-votes";
fn parse_ssh_result(
output: &Output,
action: VoteAction,
pkgbase: &str,
) -> Result<AurVoteOutcome, AurVoteError> {
let exit_code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_trimmed = stderr.trim();
if exit_code == 0 {
return Ok(AurVoteOutcome {
action,
pkgbase: pkgbase.to_string(),
dry_run: false,
});
}
if exit_code == 1 {
if stderr_trimmed.contains("already voted for package base") {
return Err(AurVoteError::AlreadyVoted(pkgbase.to_string()));
}
if stderr_trimmed.contains("missing vote for package base") {
return Err(AurVoteError::NotVoted(pkgbase.to_string()));
}
if stderr_trimmed.contains("package base not found") {
return Err(AurVoteError::NotFound(pkgbase.to_string()));
}
if stderr_trimmed.contains("AUR is down due to maintenance") {
return Err(AurVoteError::Maintenance);
}
if stderr_trimmed.contains("SSH interface is disabled") {
return Err(AurVoteError::Banned);
}
}
if stderr_trimmed.contains("Connection timed out")
|| stderr_trimmed.contains("Connection refused")
{
return Err(AurVoteError::Timeout(sanitize_stderr(stderr_trimmed)));
}
if stderr_trimmed.contains("Could not resolve hostname")
|| stderr_trimmed.contains("Network is unreachable")
|| stderr_trimmed.contains("No route to host")
{
return Err(AurVoteError::NetworkError(sanitize_stderr(stderr_trimmed)));
}
if exit_code == SSH_ERROR_EXIT_CODE {
return Err(AurVoteError::AuthFailed(sanitize_stderr(stderr_trimmed)));
}
Err(AurVoteError::Unexpected(sanitize_stderr(stderr_trimmed)))
}
fn parse_list_votes_result(
output: &Output,
pkgbase: &str,
) -> Result<AurPackageVoteState, AurVoteError> {
let exit_code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_trimmed = stderr.trim();
if exit_code == 0 {
let stdout = String::from_utf8_lossy(&output.stdout);
let is_voted = stdout.split_whitespace().any(|name| name == pkgbase);
return Ok(if is_voted {
AurPackageVoteState::Voted
} else {
AurPackageVoteState::NotVoted
});
}
if stderr_trimmed.contains("AUR is down due to maintenance") {
return Err(AurVoteError::Maintenance);
}
if stderr_trimmed.contains(LIST_VOTES_UNSUPPORTED_PATTERN) {
return Err(AurVoteError::Unexpected(
"AUR SSH server does not support vote-state lookup.".to_string(),
));
}
if stderr_trimmed.contains("SSH interface is disabled") {
return Err(AurVoteError::Banned);
}
if stderr_trimmed.contains("Connection timed out")
|| stderr_trimmed.contains("Connection refused")
{
return Err(AurVoteError::Timeout(sanitize_stderr(stderr_trimmed)));
}
if stderr_trimmed.contains("Could not resolve hostname")
|| stderr_trimmed.contains("Network is unreachable")
|| stderr_trimmed.contains("No route to host")
{
return Err(AurVoteError::NetworkError(sanitize_stderr(stderr_trimmed)));
}
if exit_code == SSH_ERROR_EXIT_CODE {
return Err(AurVoteError::AuthFailed(sanitize_stderr(stderr_trimmed)));
}
Err(AurVoteError::Unexpected(sanitize_stderr(stderr_trimmed)))
}
fn sanitize_stderr(raw: &str) -> String {
const MAX_LEN: usize = 200;
let filtered: String = raw
.lines()
.filter(|line| !line.contains("/.ssh/") && !line.contains("identity file"))
.collect::<Vec<_>>()
.join("; ");
if filtered.len() > MAX_LEN {
format!("{}...", &filtered[..MAX_LEN])
} else {
filtered
}
}
pub fn aur_vote(
pkgbase: &str,
action: VoteAction,
ctx: &AurVoteContext,
) -> Result<AurVoteOutcome, AurVoteError> {
aur_vote_with_transport(&RealSshTransport, pkgbase, action, ctx)
}
pub fn aur_vote_state(
pkgbase: &str,
ctx: &AurVoteContext,
) -> Result<AurPackageVoteState, AurVoteError> {
let timeout_arg = format!("ConnectTimeout={}", ctx.ssh_timeout_secs);
let output = Command::new(&ctx.ssh_command)
.args([
"-o",
&timeout_arg,
"-o",
"BatchMode=yes",
AUR_SSH_HOST,
"list-votes",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => AurVoteError::SshNotFound(ctx.ssh_command.clone()),
_ => AurVoteError::NetworkError(e.to_string()),
})?;
parse_list_votes_result(&output, pkgbase)
}
#[must_use]
pub fn is_vote_state_unsupported_error(error: &AurVoteError) -> bool {
match error {
AurVoteError::Unexpected(detail) => detail.contains("does not support vote-state lookup"),
_ => false,
}
}
fn aur_vote_with_transport<T: SshVoteTransport>(
transport: &T,
pkgbase: &str,
action: VoteAction,
ctx: &AurVoteContext,
) -> Result<AurVoteOutcome, AurVoteError> {
if ctx.dry_run {
return Ok(AurVoteOutcome {
action,
pkgbase: pkgbase.to_string(),
dry_run: true,
});
}
let output = transport
.execute(action, pkgbase, ctx)
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => AurVoteError::SshNotFound(ctx.ssh_command.clone()),
_ => AurVoteError::NetworkError(e.to_string()),
})?;
parse_ssh_result(&output, action, pkgbase)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::process::ExitStatusExt;
use std::process::ExitStatus;
struct MockSshTransport {
exit_code: i32,
stderr: String,
}
impl MockSshTransport {
fn new(exit_code: i32, stderr: &str) -> Self {
Self {
exit_code,
stderr: stderr.to_string(),
}
}
}
impl SshVoteTransport for MockSshTransport {
fn execute(
&self,
_action: VoteAction,
_pkgbase: &str,
_ctx: &AurVoteContext,
) -> std::io::Result<Output> {
Ok(Output {
status: ExitStatus::from_raw(self.exit_code << 8),
stdout: Vec::new(),
stderr: self.stderr.as_bytes().to_vec(),
})
}
}
struct FailingTransport {
kind: std::io::ErrorKind,
}
impl SshVoteTransport for FailingTransport {
fn execute(
&self,
_action: VoteAction,
_pkgbase: &str,
_ctx: &AurVoteContext,
) -> std::io::Result<Output> {
Err(std::io::Error::new(self.kind, "mock io error"))
}
}
fn default_ctx() -> AurVoteContext {
AurVoteContext::default()
}
fn dry_run_ctx() -> AurVoteContext {
AurVoteContext {
dry_run: true,
..AurVoteContext::default()
}
}
#[test]
fn test_dry_run_vote() {
let transport = MockSshTransport::new(0, "");
let result =
aur_vote_with_transport(&transport, "pacsea-bin", VoteAction::Vote, &dry_run_ctx());
let outcome = result.expect("dry-run vote should succeed");
assert!(outcome.dry_run);
assert_eq!(outcome.action, VoteAction::Vote);
assert!(outcome.message().contains("[dry-run]"));
assert!(outcome.message().contains("pacsea-bin"));
}
#[test]
fn test_dry_run_unvote() {
let transport = MockSshTransport::new(0, "");
let result =
aur_vote_with_transport(&transport, "pacsea-bin", VoteAction::Unvote, &dry_run_ctx());
let outcome = result.expect("dry-run unvote should succeed");
assert!(outcome.dry_run);
assert_eq!(outcome.action, VoteAction::Unvote);
assert!(outcome.message().contains("[dry-run]"));
assert!(outcome.message().contains("remove vote"));
}
#[test]
fn test_success_vote() {
let transport = MockSshTransport::new(0, "");
let result =
aur_vote_with_transport(&transport, "yay-bin", VoteAction::Vote, &default_ctx());
let outcome = result.expect("success vote should return outcome");
assert!(!outcome.dry_run);
assert_eq!(outcome.action, VoteAction::Vote);
assert_eq!(outcome.pkgbase, "yay-bin");
assert!(outcome.message().contains("Voted for"));
}
#[test]
fn test_success_unvote() {
let transport = MockSshTransport::new(0, "");
let result =
aur_vote_with_transport(&transport, "yay-bin", VoteAction::Unvote, &default_ctx());
let outcome = result.expect("success unvote should return outcome");
assert_eq!(outcome.action, VoteAction::Unvote);
assert!(outcome.message().contains("Removed vote"));
}
#[test]
fn test_already_voted() {
let transport = MockSshTransport::new(1, "vote: already voted for package base: yay-bin\n");
let result =
aur_vote_with_transport(&transport, "yay-bin", VoteAction::Vote, &default_ctx());
match result {
Err(AurVoteError::AlreadyVoted(pkg)) => assert_eq!(pkg, "yay-bin"),
other => panic!("expected AlreadyVoted, got {other:?}"),
}
}
#[test]
fn test_not_voted() {
let transport =
MockSshTransport::new(1, "unvote: missing vote for package base: yay-bin\n");
let result =
aur_vote_with_transport(&transport, "yay-bin", VoteAction::Unvote, &default_ctx());
match result {
Err(AurVoteError::NotVoted(pkg)) => assert_eq!(pkg, "yay-bin"),
other => panic!("expected NotVoted, got {other:?}"),
}
}
#[test]
fn test_package_not_found() {
let transport = MockSshTransport::new(1, "vote: package base not found: nonexistent-pkg\n");
let result = aur_vote_with_transport(
&transport,
"nonexistent-pkg",
VoteAction::Vote,
&default_ctx(),
);
match result {
Err(AurVoteError::NotFound(pkg)) => assert_eq!(pkg, "nonexistent-pkg"),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn test_auth_failure() {
let transport = MockSshTransport::new(
255,
"Permission denied (publickey).\r\nfatal: Could not read from remote repository.",
);
let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
match result {
Err(AurVoteError::AuthFailed(detail)) => {
assert!(detail.contains("Permission denied"));
}
other => panic!("expected AuthFailed, got {other:?}"),
}
}
#[test]
fn test_maintenance() {
let transport = MockSshTransport::new(
1,
"The AUR is down due to maintenance. We will be back soon.\n",
);
let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
match result {
Err(AurVoteError::Maintenance) => {}
other => panic!("expected Maintenance, got {other:?}"),
}
}
#[test]
fn test_banned() {
let transport =
MockSshTransport::new(1, "The SSH interface is disabled for your IP address.\n");
let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
match result {
Err(AurVoteError::Banned) => {}
other => panic!("expected Banned, got {other:?}"),
}
}
#[test]
fn test_ssh_not_found() {
let transport = FailingTransport {
kind: std::io::ErrorKind::NotFound,
};
let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
match result {
Err(AurVoteError::SshNotFound(cmd)) => assert_eq!(cmd, "ssh"),
other => panic!("expected SshNotFound, got {other:?}"),
}
}
#[test]
fn test_timeout() {
let transport = MockSshTransport::new(
255,
"ssh: connect to host aur.archlinux.org port 22: Connection timed out\n",
);
let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
match result {
Err(AurVoteError::Timeout(_)) => {}
other => panic!("expected Timeout, got {other:?}"),
}
}
#[test]
fn test_network_error() {
let transport = MockSshTransport::new(
255,
"ssh: Could not resolve hostname aur.archlinux.org: Name or service not known\n",
);
let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
match result {
Err(AurVoteError::NetworkError(_)) => {}
other => panic!("expected NetworkError, got {other:?}"),
}
}
#[test]
fn test_unexpected_error() {
let transport = MockSshTransport::new(99, "something completely unexpected happened\n");
let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
match result {
Err(AurVoteError::Unexpected(msg)) => {
assert!(msg.contains("something completely unexpected"));
}
other => panic!("expected Unexpected, got {other:?}"),
}
}
#[test]
fn test_sanitize_stderr_redacts_ssh_paths() {
let raw = "debug1: Offering public key: /home/user/.ssh/id_ed25519\n\
Permission denied (publickey).";
let sanitized = sanitize_stderr(raw);
assert!(!sanitized.contains("/.ssh/"));
assert!(sanitized.contains("Permission denied"));
}
#[test]
fn test_sanitize_stderr_truncates_long_output() {
let raw = "x".repeat(500);
let sanitized = sanitize_stderr(&raw);
assert!(sanitized.len() <= 203); assert!(sanitized.ends_with("..."));
}
#[test]
fn test_vote_action_display() {
assert_eq!(format!("{}", VoteAction::Vote), "Vote");
assert_eq!(format!("{}", VoteAction::Unvote), "Unvote");
}
#[test]
fn test_vote_action_ssh_arg() {
assert_eq!(VoteAction::Vote.as_ssh_arg(), "vote");
assert_eq!(VoteAction::Unvote.as_ssh_arg(), "unvote");
}
#[test]
fn test_error_display_messages() {
let err = AurVoteError::AlreadyVoted("foo".into());
let msg = format!("{err}");
assert!(msg.contains("already voted"));
assert!(msg.contains("foo"));
let err = AurVoteError::SshNotFound("ssh".into());
let msg = format!("{err}");
assert!(msg.contains("not found"));
assert!(msg.contains("openssh"));
}
#[test]
fn test_context_default() {
let ctx = AurVoteContext::default();
assert!(!ctx.dry_run);
assert_eq!(ctx.ssh_timeout_secs, 10);
assert_eq!(ctx.ssh_command, "ssh");
}
#[test]
fn test_parse_list_votes_result_voted() {
let output = Output {
status: ExitStatus::from_raw(0),
stdout: b"pacsea-bin\nyay-bin\n".to_vec(),
stderr: Vec::new(),
};
let state = parse_list_votes_result(&output, "pacsea-bin")
.expect("list-votes parsing should succeed");
assert_eq!(state, AurPackageVoteState::Voted);
}
#[test]
fn test_parse_list_votes_result_not_voted() {
let output = Output {
status: ExitStatus::from_raw(0),
stdout: b"yay-bin\nparu-bin\n".to_vec(),
stderr: Vec::new(),
};
let state = parse_list_votes_result(&output, "pacsea-bin")
.expect("list-votes parsing should succeed");
assert_eq!(state, AurPackageVoteState::NotVoted);
}
#[test]
fn test_parse_list_votes_result_auth_failed() {
let output = Output {
status: ExitStatus::from_raw(255 << 8),
stdout: Vec::new(),
stderr: b"Permission denied (publickey).".to_vec(),
};
let result = parse_list_votes_result(&output, "pacsea-bin");
match result {
Err(AurVoteError::AuthFailed(detail)) => {
assert!(detail.contains("Permission denied"));
}
other => panic!("expected AuthFailed, got {other:?}"),
}
}
#[test]
fn test_parse_list_votes_result_unsupported_command() {
let output = Output {
status: ExitStatus::from_raw(1 << 8),
stdout: Vec::new(),
stderr: b"list-votes: invalid command: list-votes".to_vec(),
};
let result = parse_list_votes_result(&output, "pacsea-bin");
match result {
Err(AurVoteError::Unexpected(detail)) => {
assert!(detail.contains("does not support vote-state lookup"));
}
other => panic!("expected Unexpected unsupported-command error, got {other:?}"),
}
}
#[test]
fn test_aur_vote_state_missing_ssh_binary_error() {
let ctx = AurVoteContext {
dry_run: false,
ssh_timeout_secs: 10,
ssh_command: "__pacsea_missing_ssh__".to_string(),
};
let result = aur_vote_state("pacsea-bin", &ctx);
match result {
Err(AurVoteError::SshNotFound(cmd)) => {
assert_eq!(cmd, "__pacsea_missing_ssh__");
}
other => panic!("expected SshNotFound, got {other:?}"),
}
}
}