1use std::fmt;
25use std::process::{Command, Output, Stdio};
26
27const AUR_SSH_HOST: &str = "aur@aur.archlinux.org";
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum VoteAction {
42 Vote,
44 Unvote,
46}
47
48impl VoteAction {
49 const fn as_ssh_arg(self) -> &'static str {
54 match self {
55 Self::Vote => "vote",
56 Self::Unvote => "unvote",
57 }
58 }
59}
60
61impl fmt::Display for VoteAction {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::Vote => write!(f, "Vote"),
65 Self::Unvote => write!(f, "Unvote"),
66 }
67 }
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum AurPackageVoteState {
77 Voted,
79 NotVoted,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct AurVoteOutcome {
90 pub action: VoteAction,
92 pub pkgbase: String,
94 pub dry_run: bool,
96}
97
98impl AurVoteOutcome {
99 #[must_use]
104 pub fn message(&self) -> String {
105 if self.dry_run {
106 return match self.action {
107 VoteAction::Vote => {
108 format!("[dry-run] Would vote for '{}'", self.pkgbase)
109 }
110 VoteAction::Unvote => {
111 format!("[dry-run] Would remove vote for '{}'", self.pkgbase)
112 }
113 };
114 }
115 match self.action {
116 VoteAction::Vote => format!("Voted for '{}'", self.pkgbase),
117 VoteAction::Unvote => format!("Removed vote for '{}'", self.pkgbase),
118 }
119 }
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
128pub enum AurVoteError {
129 AlreadyVoted(String),
131 NotVoted(String),
133 NotFound(String),
135 AuthFailed(String),
137 Maintenance,
139 Banned,
141 Timeout(String),
143 NetworkError(String),
145 SshNotFound(String),
147 Unexpected(String),
149}
150
151impl fmt::Display for AurVoteError {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 Self::AlreadyVoted(pkg) => {
155 write!(f, "You have already voted for '{pkg}'")
156 }
157 Self::NotVoted(pkg) => {
158 write!(f, "You haven't voted for '{pkg}'")
159 }
160 Self::NotFound(pkg) => {
161 write!(f, "Package base '{pkg}' not found on AUR")
162 }
163 Self::AuthFailed(detail) => {
164 write!(
165 f,
166 "SSH auth failed. Ensure your SSH key is uploaded to your AUR account \
167 at https://aur.archlinux.org/account ({detail})"
168 )
169 }
170 Self::Maintenance => {
171 write!(f, "AUR is under maintenance. Try again later")
172 }
173 Self::Banned => {
174 write!(f, "SSH interface disabled for your IP. Contact AUR support")
175 }
176 Self::Timeout(detail) => {
177 write!(
178 f,
179 "Connection to aur.archlinux.org timed out. Check network ({detail})"
180 )
181 }
182 Self::NetworkError(detail) => {
183 write!(
184 f,
185 "Could not connect to aur.archlinux.org. Check connectivity ({detail})"
186 )
187 }
188 Self::SshNotFound(cmd) => {
189 write!(
190 f,
191 "SSH binary '{cmd}' not found. Install openssh or configure \
192 aur_vote_ssh_command in settings"
193 )
194 }
195 Self::Unexpected(detail) => {
196 write!(f, "AUR vote failed unexpectedly: {detail}")
197 }
198 }
199 }
200}
201
202impl std::error::Error for AurVoteError {}
203
204#[derive(Clone, Debug)]
211pub struct AurVoteContext {
212 pub dry_run: bool,
214 pub ssh_timeout_secs: u32,
216 pub ssh_command: String,
218}
219
220impl Default for AurVoteContext {
221 fn default() -> Self {
222 Self {
223 dry_run: false,
224 ssh_timeout_secs: 10,
225 ssh_command: "ssh".to_string(),
226 }
227 }
228}
229
230trait SshVoteTransport {
240 fn execute(
251 &self,
252 action: VoteAction,
253 pkgbase: &str,
254 ctx: &AurVoteContext,
255 ) -> std::io::Result<Output>;
256}
257
258struct RealSshTransport;
265
266impl SshVoteTransport for RealSshTransport {
267 fn execute(
268 &self,
269 action: VoteAction,
270 pkgbase: &str,
271 ctx: &AurVoteContext,
272 ) -> std::io::Result<Output> {
273 let timeout_arg = format!("ConnectTimeout={}", ctx.ssh_timeout_secs);
274 Command::new(&ctx.ssh_command)
275 .args([
276 "-o",
277 &timeout_arg,
278 "-o",
279 "BatchMode=yes",
280 AUR_SSH_HOST,
281 action.as_ssh_arg(),
282 pkgbase,
283 ])
284 .stdin(Stdio::null())
285 .stdout(Stdio::piped())
286 .stderr(Stdio::piped())
287 .output()
288 }
289}
290
291const SSH_ERROR_EXIT_CODE: i32 = 255;
297const LIST_VOTES_UNSUPPORTED_PATTERN: &str = "invalid command: list-votes";
299
300fn parse_ssh_result(
318 output: &Output,
319 action: VoteAction,
320 pkgbase: &str,
321) -> Result<AurVoteOutcome, AurVoteError> {
322 let exit_code = output.status.code().unwrap_or(-1);
323 let stderr = String::from_utf8_lossy(&output.stderr);
324 let stderr_trimmed = stderr.trim();
325
326 if exit_code == 0 {
327 return Ok(AurVoteOutcome {
328 action,
329 pkgbase: pkgbase.to_string(),
330 dry_run: false,
331 });
332 }
333
334 if exit_code == 1 {
335 if stderr_trimmed.contains("already voted for package base") {
336 return Err(AurVoteError::AlreadyVoted(pkgbase.to_string()));
337 }
338 if stderr_trimmed.contains("missing vote for package base") {
339 return Err(AurVoteError::NotVoted(pkgbase.to_string()));
340 }
341 if stderr_trimmed.contains("package base not found") {
342 return Err(AurVoteError::NotFound(pkgbase.to_string()));
343 }
344 if stderr_trimmed.contains("AUR is down due to maintenance") {
345 return Err(AurVoteError::Maintenance);
346 }
347 if stderr_trimmed.contains("SSH interface is disabled") {
348 return Err(AurVoteError::Banned);
349 }
350 }
351
352 if stderr_trimmed.contains("Connection timed out")
355 || stderr_trimmed.contains("Connection refused")
356 {
357 return Err(AurVoteError::Timeout(sanitize_stderr(stderr_trimmed)));
358 }
359
360 if stderr_trimmed.contains("Could not resolve hostname")
361 || stderr_trimmed.contains("Network is unreachable")
362 || stderr_trimmed.contains("No route to host")
363 {
364 return Err(AurVoteError::NetworkError(sanitize_stderr(stderr_trimmed)));
365 }
366
367 if exit_code == SSH_ERROR_EXIT_CODE {
368 return Err(AurVoteError::AuthFailed(sanitize_stderr(stderr_trimmed)));
369 }
370
371 Err(AurVoteError::Unexpected(sanitize_stderr(stderr_trimmed)))
372}
373
374fn parse_list_votes_result(
389 output: &Output,
390 pkgbase: &str,
391) -> Result<AurPackageVoteState, AurVoteError> {
392 let exit_code = output.status.code().unwrap_or(-1);
393 let stderr = String::from_utf8_lossy(&output.stderr);
394 let stderr_trimmed = stderr.trim();
395
396 if exit_code == 0 {
397 let stdout = String::from_utf8_lossy(&output.stdout);
398 let is_voted = stdout.split_whitespace().any(|name| name == pkgbase);
399 return Ok(if is_voted {
400 AurPackageVoteState::Voted
401 } else {
402 AurPackageVoteState::NotVoted
403 });
404 }
405
406 if stderr_trimmed.contains("AUR is down due to maintenance") {
407 return Err(AurVoteError::Maintenance);
408 }
409 if stderr_trimmed.contains(LIST_VOTES_UNSUPPORTED_PATTERN) {
410 return Err(AurVoteError::Unexpected(
411 "AUR SSH server does not support vote-state lookup.".to_string(),
412 ));
413 }
414 if stderr_trimmed.contains("SSH interface is disabled") {
415 return Err(AurVoteError::Banned);
416 }
417 if stderr_trimmed.contains("Connection timed out")
418 || stderr_trimmed.contains("Connection refused")
419 {
420 return Err(AurVoteError::Timeout(sanitize_stderr(stderr_trimmed)));
421 }
422 if stderr_trimmed.contains("Could not resolve hostname")
423 || stderr_trimmed.contains("Network is unreachable")
424 || stderr_trimmed.contains("No route to host")
425 {
426 return Err(AurVoteError::NetworkError(sanitize_stderr(stderr_trimmed)));
427 }
428 if exit_code == SSH_ERROR_EXIT_CODE {
429 return Err(AurVoteError::AuthFailed(sanitize_stderr(stderr_trimmed)));
430 }
431
432 Err(AurVoteError::Unexpected(sanitize_stderr(stderr_trimmed)))
433}
434
435fn sanitize_stderr(raw: &str) -> String {
447 const MAX_LEN: usize = 200;
448 let filtered: String = raw
449 .lines()
450 .filter(|line| !line.contains("/.ssh/") && !line.contains("identity file"))
451 .collect::<Vec<_>>()
452 .join("; ");
453
454 if filtered.len() > MAX_LEN {
455 format!("{}...", &filtered[..MAX_LEN])
456 } else {
457 filtered
458 }
459}
460
461pub fn aur_vote(
488 pkgbase: &str,
489 action: VoteAction,
490 ctx: &AurVoteContext,
491) -> Result<AurVoteOutcome, AurVoteError> {
492 aur_vote_with_transport(&RealSshTransport, pkgbase, action, ctx)
493}
494
495pub fn aur_vote_state(
515 pkgbase: &str,
516 ctx: &AurVoteContext,
517) -> Result<AurPackageVoteState, AurVoteError> {
518 let timeout_arg = format!("ConnectTimeout={}", ctx.ssh_timeout_secs);
519 let output = Command::new(&ctx.ssh_command)
520 .args([
521 "-o",
522 &timeout_arg,
523 "-o",
524 "BatchMode=yes",
525 AUR_SSH_HOST,
526 "list-votes",
527 ])
528 .stdin(Stdio::null())
529 .stdout(Stdio::piped())
530 .stderr(Stdio::piped())
531 .output()
532 .map_err(|e| match e.kind() {
533 std::io::ErrorKind::NotFound => AurVoteError::SshNotFound(ctx.ssh_command.clone()),
534 _ => AurVoteError::NetworkError(e.to_string()),
535 })?;
536
537 parse_list_votes_result(&output, pkgbase)
538}
539
540#[must_use]
552pub fn is_vote_state_unsupported_error(error: &AurVoteError) -> bool {
553 match error {
554 AurVoteError::Unexpected(detail) => detail.contains("does not support vote-state lookup"),
555 _ => false,
556 }
557}
558
559fn aur_vote_with_transport<T: SshVoteTransport>(
574 transport: &T,
575 pkgbase: &str,
576 action: VoteAction,
577 ctx: &AurVoteContext,
578) -> Result<AurVoteOutcome, AurVoteError> {
579 if ctx.dry_run {
580 return Ok(AurVoteOutcome {
581 action,
582 pkgbase: pkgbase.to_string(),
583 dry_run: true,
584 });
585 }
586
587 let output = transport
588 .execute(action, pkgbase, ctx)
589 .map_err(|e| match e.kind() {
590 std::io::ErrorKind::NotFound => AurVoteError::SshNotFound(ctx.ssh_command.clone()),
591 _ => AurVoteError::NetworkError(e.to_string()),
592 })?;
593
594 parse_ssh_result(&output, action, pkgbase)
595}
596
597#[cfg(test)]
602mod tests {
603 use super::*;
604 #[cfg(unix)]
605 use std::os::unix::process::ExitStatusExt;
606 #[cfg(windows)]
607 use std::os::windows::process::ExitStatusExt;
608 use std::process::ExitStatus;
609
610 fn exit_status_from_code(exit_code: i32) -> ExitStatus {
611 #[cfg(unix)]
612 {
613 ExitStatus::from_raw(exit_code << 8)
614 }
615
616 #[cfg(windows)]
617 {
618 ExitStatus::from_raw(exit_code.cast_unsigned())
619 }
620 }
621
622 struct MockSshTransport {
624 exit_code: i32,
625 stderr: String,
626 }
627
628 impl MockSshTransport {
629 fn new(exit_code: i32, stderr: &str) -> Self {
630 Self {
631 exit_code,
632 stderr: stderr.to_string(),
633 }
634 }
635 }
636
637 impl SshVoteTransport for MockSshTransport {
638 fn execute(
639 &self,
640 _action: VoteAction,
641 _pkgbase: &str,
642 _ctx: &AurVoteContext,
643 ) -> std::io::Result<Output> {
644 Ok(Output {
645 status: exit_status_from_code(self.exit_code),
646 stdout: Vec::new(),
647 stderr: self.stderr.as_bytes().to_vec(),
648 })
649 }
650 }
651
652 struct FailingTransport {
654 kind: std::io::ErrorKind,
655 }
656
657 impl SshVoteTransport for FailingTransport {
658 fn execute(
659 &self,
660 _action: VoteAction,
661 _pkgbase: &str,
662 _ctx: &AurVoteContext,
663 ) -> std::io::Result<Output> {
664 Err(std::io::Error::new(self.kind, "mock io error"))
665 }
666 }
667
668 fn default_ctx() -> AurVoteContext {
669 AurVoteContext::default()
670 }
671
672 fn dry_run_ctx() -> AurVoteContext {
673 AurVoteContext {
674 dry_run: true,
675 ..AurVoteContext::default()
676 }
677 }
678
679 #[test]
680 fn test_dry_run_vote() {
681 let transport = MockSshTransport::new(0, "");
682 let result =
683 aur_vote_with_transport(&transport, "pacsea-bin", VoteAction::Vote, &dry_run_ctx());
684 let outcome = result.expect("dry-run vote should succeed");
685 assert!(outcome.dry_run);
686 assert_eq!(outcome.action, VoteAction::Vote);
687 assert!(outcome.message().contains("[dry-run]"));
688 assert!(outcome.message().contains("pacsea-bin"));
689 }
690
691 #[test]
692 fn test_dry_run_unvote() {
693 let transport = MockSshTransport::new(0, "");
694 let result =
695 aur_vote_with_transport(&transport, "pacsea-bin", VoteAction::Unvote, &dry_run_ctx());
696 let outcome = result.expect("dry-run unvote should succeed");
697 assert!(outcome.dry_run);
698 assert_eq!(outcome.action, VoteAction::Unvote);
699 assert!(outcome.message().contains("[dry-run]"));
700 assert!(outcome.message().contains("remove vote"));
701 }
702
703 #[test]
704 fn test_success_vote() {
705 let transport = MockSshTransport::new(0, "");
706 let result =
707 aur_vote_with_transport(&transport, "yay-bin", VoteAction::Vote, &default_ctx());
708 let outcome = result.expect("success vote should return outcome");
709 assert!(!outcome.dry_run);
710 assert_eq!(outcome.action, VoteAction::Vote);
711 assert_eq!(outcome.pkgbase, "yay-bin");
712 assert!(outcome.message().contains("Voted for"));
713 }
714
715 #[test]
716 fn test_success_unvote() {
717 let transport = MockSshTransport::new(0, "");
718 let result =
719 aur_vote_with_transport(&transport, "yay-bin", VoteAction::Unvote, &default_ctx());
720 let outcome = result.expect("success unvote should return outcome");
721 assert_eq!(outcome.action, VoteAction::Unvote);
722 assert!(outcome.message().contains("Removed vote"));
723 }
724
725 #[test]
726 fn test_already_voted() {
727 let transport = MockSshTransport::new(1, "vote: already voted for package base: yay-bin\n");
728 let result =
729 aur_vote_with_transport(&transport, "yay-bin", VoteAction::Vote, &default_ctx());
730 match result {
731 Err(AurVoteError::AlreadyVoted(pkg)) => assert_eq!(pkg, "yay-bin"),
732 other => panic!("expected AlreadyVoted, got {other:?}"),
733 }
734 }
735
736 #[test]
737 fn test_not_voted() {
738 let transport =
739 MockSshTransport::new(1, "unvote: missing vote for package base: yay-bin\n");
740 let result =
741 aur_vote_with_transport(&transport, "yay-bin", VoteAction::Unvote, &default_ctx());
742 match result {
743 Err(AurVoteError::NotVoted(pkg)) => assert_eq!(pkg, "yay-bin"),
744 other => panic!("expected NotVoted, got {other:?}"),
745 }
746 }
747
748 #[test]
749 fn test_package_not_found() {
750 let transport = MockSshTransport::new(1, "vote: package base not found: nonexistent-pkg\n");
751 let result = aur_vote_with_transport(
752 &transport,
753 "nonexistent-pkg",
754 VoteAction::Vote,
755 &default_ctx(),
756 );
757 match result {
758 Err(AurVoteError::NotFound(pkg)) => assert_eq!(pkg, "nonexistent-pkg"),
759 other => panic!("expected NotFound, got {other:?}"),
760 }
761 }
762
763 #[test]
764 fn test_auth_failure() {
765 let transport = MockSshTransport::new(
766 255,
767 "Permission denied (publickey).\r\nfatal: Could not read from remote repository.",
768 );
769 let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
770 match result {
771 Err(AurVoteError::AuthFailed(detail)) => {
772 assert!(detail.contains("Permission denied"));
773 }
774 other => panic!("expected AuthFailed, got {other:?}"),
775 }
776 }
777
778 #[test]
779 fn test_maintenance() {
780 let transport = MockSshTransport::new(
781 1,
782 "The AUR is down due to maintenance. We will be back soon.\n",
783 );
784 let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
785 match result {
786 Err(AurVoteError::Maintenance) => {}
787 other => panic!("expected Maintenance, got {other:?}"),
788 }
789 }
790
791 #[test]
792 fn test_banned() {
793 let transport =
794 MockSshTransport::new(1, "The SSH interface is disabled for your IP address.\n");
795 let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
796 match result {
797 Err(AurVoteError::Banned) => {}
798 other => panic!("expected Banned, got {other:?}"),
799 }
800 }
801
802 #[test]
803 fn test_ssh_not_found() {
804 let transport = FailingTransport {
805 kind: std::io::ErrorKind::NotFound,
806 };
807 let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
808 match result {
809 Err(AurVoteError::SshNotFound(cmd)) => assert_eq!(cmd, "ssh"),
810 other => panic!("expected SshNotFound, got {other:?}"),
811 }
812 }
813
814 #[test]
815 fn test_timeout() {
816 let transport = MockSshTransport::new(
817 255,
818 "ssh: connect to host aur.archlinux.org port 22: Connection timed out\n",
819 );
820 let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
821 match result {
822 Err(AurVoteError::Timeout(_)) => {}
823 other => panic!("expected Timeout, got {other:?}"),
824 }
825 }
826
827 #[test]
828 fn test_network_error() {
829 let transport = MockSshTransport::new(
830 255,
831 "ssh: Could not resolve hostname aur.archlinux.org: Name or service not known\n",
832 );
833 let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
834 match result {
835 Err(AurVoteError::NetworkError(_)) => {}
836 other => panic!("expected NetworkError, got {other:?}"),
837 }
838 }
839
840 #[test]
841 fn test_unexpected_error() {
842 let transport = MockSshTransport::new(99, "something completely unexpected happened\n");
843 let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
844 match result {
845 Err(AurVoteError::Unexpected(msg)) => {
846 assert!(msg.contains("something completely unexpected"));
847 }
848 other => panic!("expected Unexpected, got {other:?}"),
849 }
850 }
851
852 #[test]
853 fn test_sanitize_stderr_redacts_ssh_paths() {
854 let raw = "debug1: Offering public key: /home/user/.ssh/id_ed25519\n\
855 Permission denied (publickey).";
856 let sanitized = sanitize_stderr(raw);
857 assert!(!sanitized.contains("/.ssh/"));
858 assert!(sanitized.contains("Permission denied"));
859 }
860
861 #[test]
862 fn test_sanitize_stderr_truncates_long_output() {
863 let raw = "x".repeat(500);
864 let sanitized = sanitize_stderr(&raw);
865 assert!(sanitized.len() <= 203); assert!(sanitized.ends_with("..."));
867 }
868
869 #[test]
870 fn test_vote_action_display() {
871 assert_eq!(format!("{}", VoteAction::Vote), "Vote");
872 assert_eq!(format!("{}", VoteAction::Unvote), "Unvote");
873 }
874
875 #[test]
876 fn test_vote_action_ssh_arg() {
877 assert_eq!(VoteAction::Vote.as_ssh_arg(), "vote");
878 assert_eq!(VoteAction::Unvote.as_ssh_arg(), "unvote");
879 }
880
881 #[test]
882 fn test_error_display_messages() {
883 let err = AurVoteError::AlreadyVoted("foo".into());
884 let msg = format!("{err}");
885 assert!(msg.contains("already voted"));
886 assert!(msg.contains("foo"));
887
888 let err = AurVoteError::SshNotFound("ssh".into());
889 let msg = format!("{err}");
890 assert!(msg.contains("not found"));
891 assert!(msg.contains("openssh"));
892 }
893
894 #[test]
895 fn test_context_default() {
896 let ctx = AurVoteContext::default();
897 assert!(!ctx.dry_run);
898 assert_eq!(ctx.ssh_timeout_secs, 10);
899 assert_eq!(ctx.ssh_command, "ssh");
900 }
901
902 #[test]
903 fn test_parse_list_votes_result_voted() {
904 let output = Output {
905 status: exit_status_from_code(0),
906 stdout: b"pacsea-bin\nyay-bin\n".to_vec(),
907 stderr: Vec::new(),
908 };
909 let state = parse_list_votes_result(&output, "pacsea-bin")
910 .expect("list-votes parsing should succeed");
911 assert_eq!(state, AurPackageVoteState::Voted);
912 }
913
914 #[test]
915 fn test_parse_list_votes_result_not_voted() {
916 let output = Output {
917 status: exit_status_from_code(0),
918 stdout: b"yay-bin\nparu-bin\n".to_vec(),
919 stderr: Vec::new(),
920 };
921 let state = parse_list_votes_result(&output, "pacsea-bin")
922 .expect("list-votes parsing should succeed");
923 assert_eq!(state, AurPackageVoteState::NotVoted);
924 }
925
926 #[test]
927 fn test_parse_list_votes_result_auth_failed() {
928 let output = Output {
929 status: exit_status_from_code(255),
930 stdout: Vec::new(),
931 stderr: b"Permission denied (publickey).".to_vec(),
932 };
933 let result = parse_list_votes_result(&output, "pacsea-bin");
934 match result {
935 Err(AurVoteError::AuthFailed(detail)) => {
936 assert!(detail.contains("Permission denied"));
937 }
938 other => panic!("expected AuthFailed, got {other:?}"),
939 }
940 }
941
942 #[test]
943 fn test_parse_list_votes_result_unsupported_command() {
944 let output = Output {
945 status: exit_status_from_code(1),
946 stdout: Vec::new(),
947 stderr: b"list-votes: invalid command: list-votes".to_vec(),
948 };
949 let result = parse_list_votes_result(&output, "pacsea-bin");
950 match result {
951 Err(AurVoteError::Unexpected(detail)) => {
952 assert!(detail.contains("does not support vote-state lookup"));
953 }
954 other => panic!("expected Unexpected unsupported-command error, got {other:?}"),
955 }
956 }
957
958 #[test]
959 fn test_aur_vote_state_missing_ssh_binary_error() {
960 let ctx = AurVoteContext {
961 dry_run: false,
962 ssh_timeout_secs: 10,
963 ssh_command: "__pacsea_missing_ssh__".to_string(),
964 };
965 let result = aur_vote_state("pacsea-bin", &ctx);
966 match result {
967 Err(AurVoteError::SshNotFound(cmd)) => {
968 assert_eq!(cmd, "__pacsea_missing_ssh__");
969 }
970 other => panic!("expected SshNotFound, got {other:?}"),
971 }
972 }
973}