1use std::fs;
4use std::fs::OpenOptions;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::sync::{Arc, Mutex};
9
10const AUR_HOST: &str = "aur.archlinux.org";
12const AUR_KEY_NAME: &str = "aur_key";
14pub const AUR_ACCOUNT_URL: &str = "https://aur.archlinux.org/login";
16
17#[must_use]
28pub fn ssh_public_key_line_from_status_lines(status_lines: &[String]) -> Option<String> {
29 status_lines.iter().find_map(|line| {
30 let trimmed = line.trim();
31 trimmed.starts_with("ssh-").then(|| trimmed.to_string())
32 })
33}
34
35#[must_use]
48pub fn try_copy_aur_ssh_public_key_from_status_lines(
49 status_lines: &[String],
50) -> Option<Result<(), String>> {
51 let key_line = ssh_public_key_line_from_status_lines(status_lines)?;
52 Some(crate::util::clipboard::copy_plain_text_to_clipboard(
53 &key_line,
54 ))
55}
56
57#[must_use]
67pub fn is_openssh_installed() -> bool {
68 #[cfg(test)]
69 if let Ok(v) = std::env::var("PACSEA_TEST_OPENSSH_INSTALLED") {
70 return v == "1";
71 }
72 crate::index::is_installed("openssh")
73}
74
75pub enum AurSshSetupResult {
86 Completed(AurSshSetupReport),
88 NeedsOverwrite {
90 existing_block: String,
92 lines: Vec<String>,
94 },
95}
96
97pub struct AurSshSetupReport {
105 pub success: bool,
107 pub lines: Vec<String>,
109}
110
111#[must_use]
121pub fn is_aur_ssh_setup_configured() -> bool {
122 let Some(home) = home_dir() else {
123 return false;
124 };
125 let ssh_dir = home.join(".ssh");
126 let key_path = ssh_dir.join(AUR_KEY_NAME);
127 if !key_path.exists() {
128 return false;
129 }
130 let config_path = ssh_dir.join("config");
131 let Ok(content) = fs::read_to_string(config_path) else {
132 return false;
133 };
134 find_host_block(&content, AUR_HOST)
135 .is_some_and(|(_, _, block)| block_has_required_directives(&block))
136}
137
138#[must_use]
151pub fn run_aur_ssh_setup(overwrite_existing_host: bool) -> AurSshSetupResult {
152 let mut lines = Vec::new();
153 let Some(home) = home_dir() else {
154 return AurSshSetupResult::Completed(AurSshSetupReport {
155 success: false,
156 lines: vec![setup_failure_line(
157 "home",
158 "could not resolve your home directory (HOME may be unset in this session)",
159 )],
160 });
161 };
162 let ssh_dir = home.join(".ssh");
163 if let Err(err) = fs::create_dir_all(&ssh_dir) {
164 return AurSshSetupResult::Completed(AurSshSetupReport {
165 success: false,
166 lines: vec![setup_failure_line(
167 "directory creation",
168 format!("could not create '{}': {err}", ssh_dir.display()),
169 )],
170 });
171 }
172 lines.push(format!("SSH directory ready: '{}'", ssh_dir.display()));
173 #[cfg(unix)]
174 {
175 use std::os::unix::fs::PermissionsExt;
176 if let Err(err) = fs::set_permissions(&ssh_dir, fs::Permissions::from_mode(0o700)) {
177 lines.push(format!(
178 "Warning: could not set '{}' permissions to 700: {err}",
179 ssh_dir.display()
180 ));
181 }
182 }
183 let known_hosts_path = match ensure_known_hosts_file_exists(&ssh_dir, &mut lines) {
184 Ok(path) => path,
185 Err(err) => {
186 return AurSshSetupResult::Completed(AurSshSetupReport {
187 success: false,
188 lines: vec![setup_failure_line("known_hosts", err)],
189 });
190 }
191 };
192 maybe_seed_known_hosts_with_aur_entry(&known_hosts_path, &mut lines);
193
194 let key_path = ssh_dir.join(AUR_KEY_NAME);
195 if key_path.exists() {
196 lines.push(format!("Key exists: '{}'", key_path.display()));
197 } else {
198 let output = Command::new("ssh-keygen")
199 .args(["-t", "ed25519", "-f"])
200 .arg(&key_path)
201 .args(["-N", ""])
202 .output();
203 match output {
204 Ok(out) if out.status.success() => {
205 lines.push(format!("Created key pair: '{}'", key_path.display()));
206 }
207 Ok(out) => {
208 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
209 lines.push(format!(
210 "Failed [keygen]: ssh-keygen exited with code {}: {}",
211 out.status.code().unwrap_or(-1),
212 if stderr.is_empty() {
213 "no stderr output".to_string()
214 } else {
215 stderr
216 }
217 ));
218 return AurSshSetupResult::Completed(AurSshSetupReport {
219 success: false,
220 lines,
221 });
222 }
223 Err(err) => {
224 lines.push(setup_failure_line(
225 "keygen",
226 format!("could not run ssh-keygen: {err}"),
227 ));
228 return AurSshSetupResult::Completed(AurSshSetupReport {
229 success: false,
230 lines,
231 });
232 }
233 }
234 }
235
236 let config_path = ssh_dir.join("config");
237 match write_or_update_aur_host_config(&config_path, overwrite_existing_host, &mut lines) {
238 Ok(Some(existing_block)) => {
239 return AurSshSetupResult::NeedsOverwrite {
240 existing_block,
241 lines,
242 };
243 }
244 Ok(None) => {}
245 Err(err) => {
246 lines.push(setup_failure_line(
247 "config update",
248 format!("could not update '{}': {err}", config_path.display()),
249 ));
250 return AurSshSetupResult::Completed(AurSshSetupReport {
251 success: false,
252 lines,
253 });
254 }
255 }
256
257 let pub_key_path = key_path.with_extension("pub");
258 match fs::read_to_string(&pub_key_path) {
259 Ok(pub_key) => {
260 let trimmed = pub_key.trim();
261 if trimmed.is_empty() {
262 lines.push(
263 "Warning: public key file is empty. Re-run setup or regenerate key."
264 .to_string(),
265 );
266 } else {
267 lines.push(format!(
268 "Public key file: '{}' (copy this into your AUR account).",
269 pub_key_path.display()
270 ));
271 lines.push(trimmed.to_string());
272 }
273 }
274 Err(err) => {
275 lines.push(format!(
276 "Warning: could not read public key '{}': {err}",
277 pub_key_path.display()
278 ));
279 }
280 }
281 lines.push(format!(
282 "Next step: open {AUR_ACCOUNT_URL} and paste the public key."
283 ));
284 AurSshSetupResult::Completed(AurSshSetupReport {
285 success: true,
286 lines,
287 })
288}
289
290fn ensure_known_hosts_file_exists(
300 ssh_dir: &Path,
301 lines: &mut Vec<String>,
302) -> Result<PathBuf, String> {
303 let known_hosts_path = ssh_dir.join("known_hosts");
304 if known_hosts_path.exists() {
305 lines.push(format!(
306 "known_hosts file ready: '{}'",
307 known_hosts_path.display()
308 ));
309 } else {
310 OpenOptions::new()
311 .create(true)
312 .write(true)
313 .truncate(false)
314 .open(&known_hosts_path)
315 .map_err(|err| format!("could not create '{}': {err}", known_hosts_path.display()))?;
316 lines.push(format!(
317 "Created known_hosts file: '{}'",
318 known_hosts_path.display()
319 ));
320 }
321 #[cfg(unix)]
322 {
323 use std::os::unix::fs::PermissionsExt;
324 if let Err(err) = fs::set_permissions(&known_hosts_path, fs::Permissions::from_mode(0o600))
325 {
326 lines.push(format!(
327 "Warning: could not set '{}' permissions to 600: {err}",
328 known_hosts_path.display()
329 ));
330 }
331 }
332 Ok(known_hosts_path)
333}
334
335fn maybe_seed_known_hosts_with_aur_entry(known_hosts_path: &Path, lines: &mut Vec<String>) {
344 let content = fs::read_to_string(known_hosts_path).unwrap_or_default();
345 if content.contains(AUR_HOST) {
346 lines.push("known_hosts already contains aur.archlinux.org entry.".to_string());
347 return;
348 }
349 let output = Command::new("ssh-keyscan").args(["-H", AUR_HOST]).output();
350 match output {
351 Ok(out) if out.status.success() && !out.stdout.is_empty() => {
352 match OpenOptions::new().append(true).open(known_hosts_path) {
353 Ok(mut file) => {
354 if let Err(err) = file.write_all(&out.stdout) {
355 lines.push(format!(
356 "Warning: failed to append AUR host key to '{}': {err}",
357 known_hosts_path.display()
358 ));
359 } else {
360 lines.push("Added aur.archlinux.org host key to known_hosts.".to_string());
361 }
362 }
363 Err(err) => {
364 lines.push(format!(
365 "Warning: failed to open '{}' for host key append: {err}",
366 known_hosts_path.display()
367 ));
368 }
369 }
370 }
371 Ok(out) => {
372 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
373 lines.push(format!(
374 "Warning: could not fetch AUR host key via ssh-keyscan (exit {}): {}",
375 out.status.code().unwrap_or(-1),
376 if stderr.is_empty() {
377 "no stderr output".to_string()
378 } else {
379 stderr
380 }
381 ));
382 }
383 Err(err) => {
384 lines.push(format!(
385 "Warning: ssh-keyscan unavailable for known_hosts seeding: {err}"
386 ));
387 }
388 }
389}
390
391#[must_use]
399pub fn validate_aur_ssh_setup_connection(ssh_command: &str) -> AurSshSetupReport {
400 let Some(home) = home_dir() else {
401 return AurSshSetupReport {
402 success: false,
403 lines: vec![setup_failure_line(
404 "home",
405 "could not resolve your home directory (HOME may be unset in this session)",
406 )],
407 };
408 };
409 let key_path = home.join(".ssh").join(AUR_KEY_NAME);
410 let validation = Command::new(ssh_command)
411 .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"])
412 .arg("aur@aur.archlinux.org")
413 .arg("help")
414 .output();
415 match validation {
416 Ok(out) if out.status.success() => AurSshSetupReport {
417 success: true,
418 lines: vec!["Validation OK: 'ssh aur@aur.archlinux.org help' succeeded.".to_string()],
419 },
420 Ok(out) => {
421 let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
422 let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
423 let detail = if stderr.is_empty() { stdout } else { stderr };
424 let mut lines = Vec::new();
425 lines.push(format!(
426 "Failed [connection check]: ssh validation exited with code {}: {}",
427 out.status.code().unwrap_or(-1),
428 if detail.is_empty() {
429 "no output".to_string()
430 } else {
431 detail
432 }
433 ));
434 lines.push(format!(
435 "Next step: upload public key '{}' to {}",
436 key_path.with_extension("pub").display(),
437 AUR_ACCOUNT_URL
438 ));
439 AurSshSetupReport {
440 success: false,
441 lines,
442 }
443 }
444 Err(err) => AurSshSetupReport {
445 success: false,
446 lines: vec![setup_failure_line(
447 "connection check",
448 format!("could not run ssh validation command '{ssh_command}': {err}"),
449 )],
450 },
451 }
452}
453
454fn setup_failure_line(stage: &str, detail: impl AsRef<str>) -> String {
456 format!("Failed [{stage}]: {}", detail.as_ref())
457}
458
459#[must_use]
471pub fn spawn_aur_ssh_help_check(ssh_command: String) -> Arc<Mutex<Option<bool>>> {
472 let result = Arc::new(Mutex::new(None));
473 let result_clone = Arc::clone(&result);
474 std::thread::spawn(move || {
475 let ok = Command::new(&ssh_command)
476 .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"])
477 .arg("aur@aur.archlinux.org")
478 .arg("help")
479 .output()
480 .is_ok_and(|out| out.status.success());
481 if let Ok(mut slot) = result_clone.lock() {
482 *slot = Some(ok);
483 }
484 });
485 result
486}
487
488fn home_dir() -> Option<PathBuf> {
490 std::env::var("HOME")
491 .ok()
492 .filter(|v| !v.trim().is_empty())
493 .map(PathBuf::from)
494 .or_else(resolve_home_dir_unix_passwd)
495}
496
497#[cfg(unix)]
507fn resolve_home_dir_unix_passwd() -> Option<PathBuf> {
508 use nix::unistd::{Uid, User};
509
510 let uid = Uid::current();
511 User::from_uid(uid)
512 .ok()
513 .flatten()
514 .and_then(|user| (!user.dir.as_os_str().is_empty()).then_some(user.dir))
515}
516
517#[cfg(not(unix))]
527fn resolve_home_dir_unix_passwd() -> Option<PathBuf> {
528 None
529}
530
531fn desired_aur_host_block() -> String {
533 "Host aur.archlinux.org\n User aur\n IdentityFile ~/.ssh/aur_key\n IdentitiesOnly yes\n"
534 .to_string()
535}
536
537fn find_host_block(content: &str, host: &str) -> Option<(usize, usize, String)> {
542 let mut entries: Vec<(usize, &str)> = Vec::new();
543 let mut start = 0usize;
544 for line in content.lines() {
545 entries.push((start, line));
546 start = start.saturating_add(line.len()).saturating_add(1);
547 }
548 let mut block_start: Option<usize> = None;
549 let mut end = content.len();
550 for (line_start, line) in entries {
551 let trimmed = line.trim();
552 if !trimmed.starts_with("Host ") {
553 continue;
554 }
555 if block_start.is_none() {
556 let hosts = trimmed.trim_start_matches("Host ").split_whitespace();
557 if hosts.into_iter().any(|entry| entry == host) {
558 block_start = Some(line_start);
559 }
560 continue;
561 }
562 end = line_start;
563 break;
564 }
565 let start = block_start?;
566 Some((start, end, content[start..end].trim_end().to_string()))
567}
568
569fn block_has_required_directives(block: &str) -> bool {
571 let mut user_ok = false;
572 let mut id_ok = false;
573 let mut only_ok = false;
574 for line in block.lines() {
575 let trimmed = line.trim();
576 if trimmed.eq_ignore_ascii_case("User aur") {
577 user_ok = true;
578 } else if trimmed.eq_ignore_ascii_case("IdentityFile ~/.ssh/aur_key") {
579 id_ok = true;
580 } else if trimmed.eq_ignore_ascii_case("IdentitiesOnly yes") {
581 only_ok = true;
582 }
583 }
584 user_ok && id_ok && only_ok
585}
586
587fn write_or_update_aur_host_config(
593 config_path: &Path,
594 overwrite_existing_host: bool,
595 lines: &mut Vec<String>,
596) -> Result<Option<String>, String> {
597 let desired = desired_aur_host_block();
598 let mut content = fs::read_to_string(config_path).unwrap_or_default();
599 if let Some((start, end, existing)) = find_host_block(&content, AUR_HOST) {
600 if block_has_required_directives(&existing) {
601 lines.push(format!(
602 "SSH config already contains required '{AUR_HOST}'."
603 ));
604 return Ok(None);
605 }
606 if !overwrite_existing_host {
607 lines.push(format!(
608 "Existing '{AUR_HOST}' block detected. Confirmation required to overwrite."
609 ));
610 return Ok(Some(existing));
611 }
612 content.replace_range(start..end, &desired);
613 lines.push(format!("Overwrote existing '{AUR_HOST}' host block."));
614 } else {
615 if !content.is_empty() && !content.ends_with('\n') {
616 content.push('\n');
617 }
618 if !content.is_empty() {
619 content.push('\n');
620 }
621 content.push_str(&desired);
622 lines.push(format!("Added new '{AUR_HOST}' host block."));
623 }
624 fs::write(config_path, content).map_err(|e| e.to_string())?;
625 #[cfg(unix)]
626 {
627 use std::os::unix::fs::PermissionsExt;
628 if let Err(err) = fs::set_permissions(config_path, fs::Permissions::from_mode(0o600)) {
629 lines.push(format!(
630 "Warning: could not set '{}' permissions to 600: {err}",
631 config_path.display()
632 ));
633 }
634 }
635 Ok(None)
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use std::time::{SystemTime, UNIX_EPOCH};
642
643 fn temp_config_path(name: &str) -> PathBuf {
644 let stamp = SystemTime::now()
645 .duration_since(UNIX_EPOCH)
646 .map_or(0, |d| d.as_nanos());
647 std::env::temp_dir().join(format!(
648 "pacsea_{name}_{}_{}.conf",
649 std::process::id(),
650 stamp
651 ))
652 }
653
654 #[test]
655 fn block_directives_detected() {
656 let block = "Host aur.archlinux.org\n User aur\n IdentityFile ~/.ssh/aur_key\n IdentitiesOnly yes\n";
657 assert!(block_has_required_directives(block));
658 }
659
660 #[test]
661 fn block_directives_missing_detected() {
662 let block = "Host aur.archlinux.org\n User aur\n IdentityFile ~/.ssh/id_ed25519\n";
663 assert!(!block_has_required_directives(block));
664 }
665
666 #[test]
667 fn find_host_block_returns_range() {
668 let content = "Host github.com\n User git\n\nHost aur.archlinux.org\n User aur\n";
669 let found = find_host_block(content, "aur.archlinux.org")
670 .expect("expected aur host block to be found");
671 assert!(found.2.contains("Host aur.archlinux.org"));
672 }
673
674 #[test]
675 fn write_or_update_requests_overwrite_for_conflicting_block() {
676 let path = temp_config_path("ssh_setup_conflict");
677 let original = "Host aur.archlinux.org\n User aur\n IdentityFile ~/.ssh/id_ed25519\n";
678 fs::write(&path, original).expect("should write temp config");
679 let mut lines = Vec::new();
680 let result =
681 write_or_update_aur_host_config(&path, false, &mut lines).expect("should not error");
682 assert!(
683 result.is_some(),
684 "conflicting block should request overwrite"
685 );
686 let _ = fs::remove_file(path);
687 }
688
689 #[test]
690 fn write_or_update_writes_expected_block_when_missing() {
691 let path = temp_config_path("ssh_setup_missing");
692 let _ = fs::remove_file(&path);
693 let mut lines = Vec::new();
694 let result =
695 write_or_update_aur_host_config(&path, false, &mut lines).expect("should not error");
696 assert!(result.is_none(), "missing block should be written directly");
697 let body = fs::read_to_string(&path).expect("config should be created");
698 assert!(body.contains("Host aur.archlinux.org"));
699 assert!(body.contains("IdentityFile ~/.ssh/aur_key"));
700 let _ = fs::remove_file(path);
701 }
702
703 #[test]
704 fn openssh_check_honors_test_override() {
705 unsafe {
706 std::env::set_var("PACSEA_TEST_OPENSSH_INSTALLED", "1");
707 }
708 assert!(is_openssh_installed());
709 unsafe {
710 std::env::set_var("PACSEA_TEST_OPENSSH_INSTALLED", "0");
711 }
712 assert!(!is_openssh_installed());
713 unsafe {
714 std::env::remove_var("PACSEA_TEST_OPENSSH_INSTALLED");
715 }
716 }
717
718 #[test]
719 fn setup_failure_line_includes_stage_and_detail() {
720 let line = setup_failure_line("connection check", "network timeout");
721 assert_eq!(
722 line,
723 "Failed [connection check]: network timeout".to_string()
724 );
725 }
726
727 #[test]
728 fn ssh_public_key_line_from_status_finds_first_key_line() {
729 let lines = vec![
730 "Key exists: '/home/u/.ssh/aur_key'".to_string(),
731 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIABCD user@host".to_string(),
732 "Next step: open https://example.test/login".to_string(),
733 ];
734 assert_eq!(
735 ssh_public_key_line_from_status_lines(&lines).as_deref(),
736 Some("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIABCD user@host")
737 );
738 }
739
740 #[test]
741 fn ensure_known_hosts_file_exists_creates_missing_file() {
742 let stamp = SystemTime::now()
743 .duration_since(UNIX_EPOCH)
744 .map_or(0, |d| d.as_nanos());
745 let ssh_dir = std::env::temp_dir().join(format!(
746 "pacsea_ssh_known_hosts_{}_{}",
747 std::process::id(),
748 stamp
749 ));
750 fs::create_dir_all(&ssh_dir).expect("should create temp ssh dir");
751 let mut lines = Vec::new();
752 let path = ensure_known_hosts_file_exists(&ssh_dir, &mut lines)
753 .expect("should create known_hosts");
754 assert!(path.exists(), "known_hosts file should exist");
755 assert!(
756 lines
757 .iter()
758 .any(|line| line.contains("Created known_hosts file")),
759 "status lines should mention known_hosts creation"
760 );
761 let _ = fs::remove_file(path);
762 let _ = fs::remove_dir(ssh_dir);
763 }
764}