1use std::process::Stdio;
2use tokio::io::{AsyncBufReadExt, BufReader};
3use tokio::process::Command;
4
5use crate::error::{Error, Result};
6use crate::parse::{parse_keys, parse_signatures};
7use crate::types::{Key, RefreshOptions, RefreshProgress, Signature};
8use crate::validation::{validate_keyid, validate_keyring_name};
9
10const DEFAULT_GPG_HOMEDIR: &str = "/etc/pacman.d/gnupg";
11
12pub struct ReadOnlyKeyring {
30 gpg_homedir: String,
31}
32
33impl ReadOnlyKeyring {
34 pub async fn list_keys(&self) -> Result<Vec<Key>> {
36 let output = Command::new("gpg")
37 .env("LC_ALL", "C")
38 .arg(format!("--homedir={}", self.gpg_homedir))
39 .args(["--list-keys", "--with-colons"])
40 .output()
41 .await?;
42
43 if !output.status.success() {
44 return Err(check_gpg_error(&self.gpg_homedir, output.status, &output.stderr));
45 }
46
47 let stdout = String::from_utf8_lossy(&output.stdout);
48 parse_keys(&stdout)
49 }
50
51 pub async fn list_signatures(&self, keyid: Option<&str>) -> Result<Vec<Signature>> {
56 let mut cmd = Command::new("gpg");
57 cmd.env("LC_ALL", "C")
58 .arg(format!("--homedir={}", self.gpg_homedir))
59 .args(["--list-sigs", "--with-colons"]);
60
61 if let Some(id) = keyid {
62 let validated = validate_keyid(id)?;
63 cmd.arg(validated);
64 }
65
66 let output = cmd.output().await?;
67
68 if !output.status.success() {
69 return Err(check_gpg_error(&self.gpg_homedir, output.status, &output.stderr));
70 }
71
72 let stdout = String::from_utf8_lossy(&output.stdout);
73 parse_signatures(&stdout)
74 }
75}
76
77fn check_gpg_error(homedir: &str, status: std::process::ExitStatus, stderr: &[u8]) -> Error {
78 let msg = String::from_utf8_lossy(stderr);
79
80 if msg.contains("Permission denied") || msg.contains("permission denied") {
81 return Error::PermissionDenied;
82 }
83
84 if msg.contains("No such file or directory") && msg.contains(homedir) {
85 return Error::KeyringNotInitialized;
86 }
87
88 Error::PacmanKey {
89 status: status.code().unwrap_or(-1),
90 stderr: msg.to_string(),
91 }
92}
93
94pub struct Keyring {
112 gpg_homedir: String,
113}
114
115impl Default for Keyring {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl Keyring {
122 #[must_use]
124 pub fn new() -> Self {
125 Self {
126 gpg_homedir: DEFAULT_GPG_HOMEDIR.to_string(),
127 }
128 }
129
130 #[must_use]
148 pub fn with_homedir(path: impl Into<String>) -> ReadOnlyKeyring {
149 ReadOnlyKeyring {
150 gpg_homedir: path.into(),
151 }
152 }
153
154 async fn run_pacman_key<I, S>(&self, args: I) -> Result<()>
155 where
156 I: IntoIterator<Item = S>,
157 S: AsRef<std::ffi::OsStr>,
158 {
159 let output = Command::new("pacman-key")
160 .env("LC_ALL", "C")
161 .args(args)
162 .output()
163 .await?;
164
165 if !output.status.success() {
166 return Err(self.check_error(output.status, &output.stderr));
167 }
168
169 Ok(())
170 }
171
172 pub async fn list_keys(&self) -> Result<Vec<Key>> {
188 let output = Command::new("gpg")
189 .env("LC_ALL", "C")
190 .arg(format!("--homedir={}", self.gpg_homedir))
191 .args(["--list-keys", "--with-colons"])
192 .output()
193 .await?;
194
195 if !output.status.success() {
196 return Err(self.check_error(output.status, &output.stderr));
197 }
198
199 let stdout = String::from_utf8_lossy(&output.stdout);
200 parse_keys(&stdout)
201 }
202
203 pub async fn list_signatures(&self, keyid: Option<&str>) -> Result<Vec<Signature>> {
208 let mut cmd = Command::new("gpg");
209 cmd.env("LC_ALL", "C")
210 .arg(format!("--homedir={}", self.gpg_homedir))
211 .args(["--list-sigs", "--with-colons"]);
212
213 if let Some(id) = keyid {
214 let validated = validate_keyid(id)?;
215 cmd.arg(validated);
216 }
217
218 let output = cmd.output().await?;
219
220 if !output.status.success() {
221 return Err(self.check_error(output.status, &output.stderr));
222 }
223
224 let stdout = String::from_utf8_lossy(&output.stdout);
225 parse_signatures(&stdout)
226 }
227
228 pub async fn init_keyring(&self) -> Result<()> {
233 self.run_pacman_key(&["--init"]).await
234 }
235
236 pub async fn populate(&self, keyrings: &[&str]) -> Result<()> {
247 for name in keyrings {
248 validate_keyring_name(name)?;
249 }
250
251 let keyring_args: &[&str] = if keyrings.is_empty() {
252 &["archlinux"]
253 } else {
254 keyrings
255 };
256
257 self.run_pacman_key(
258 std::iter::once("--populate").chain(keyring_args.iter().copied()),
259 )
260 .await
261 }
262
263 pub async fn receive_keys(&self, keyids: &[&str]) -> Result<()> {
272 if keyids.is_empty() {
273 return Ok(());
274 }
275
276 let validated: Vec<String> = keyids
277 .iter()
278 .map(|k| validate_keyid(k))
279 .collect::<Result<_>>()?;
280
281 self.run_pacman_key(
282 std::iter::once("--recv-keys".to_string()).chain(validated),
283 )
284 .await
285 }
286
287 pub async fn locally_sign_key(&self, keyid: &str) -> Result<()> {
296 let validated = validate_keyid(keyid)?;
297 self.run_pacman_key(&["--lsign-key", &validated]).await
298 }
299
300 pub async fn delete_key(&self, keyid: &str) -> Result<()> {
309 let validated = validate_keyid(keyid)?;
310 self.run_pacman_key(&["--delete", &validated]).await
311 }
312
313 pub async fn refresh_keys<F>(
348 &self,
349 callback: F,
350 options: RefreshOptions,
351 ) -> Result<()>
352 where
353 F: Fn(RefreshProgress),
354 {
355 let refresh_future = self.refresh_keys_inner(&callback);
356
357 match options.timeout_secs {
358 Some(secs) => {
359 tokio::time::timeout(std::time::Duration::from_secs(secs), refresh_future)
360 .await
361 .map_err(|_| Error::Timeout(secs))?
362 }
363 None => refresh_future.await,
364 }
365 }
366
367 async fn refresh_keys_inner<F>(&self, callback: &F) -> Result<()>
368 where
369 F: Fn(RefreshProgress),
370 {
371 let keys = self.list_keys().await?;
372 let total = keys.len();
373
374 callback(RefreshProgress::Starting { total_keys: total });
375
376 let mut child = Command::new("pacman-key")
377 .env("LC_ALL", "C")
378 .arg("--refresh-keys")
379 .stdout(Stdio::null())
380 .stderr(Stdio::piped())
381 .spawn()?;
382
383 let stderr = child.stderr.take().ok_or(Error::StderrCaptureFailed)?;
384 let mut reader = BufReader::new(stderr).lines();
385
386 let mut current = 0;
387 while let Some(line) = reader.next_line().await? {
388 if line.contains("refreshing") || line.contains("requesting") {
389 current += 1;
390 let keyid = extract_keyid_from_line(&line);
391 callback(RefreshProgress::Refreshing {
392 current,
393 total,
394 keyid,
395 });
396 } else if line.contains("error")
397 || line.contains("failed")
398 || line.contains("not found")
399 {
400 let keyid = extract_keyid_from_line(&line);
401 callback(RefreshProgress::Error {
402 keyid,
403 message: line.clone(),
404 });
405 }
406 }
407
408 let status = child.wait().await?;
409 if !status.success() {
410 return Err(Error::PacmanKey {
411 status: status.code().unwrap_or(-1),
412 stderr: "refresh failed".to_string(),
413 });
414 }
415
416 callback(RefreshProgress::Completed);
417 Ok(())
418 }
419
420 fn check_error(&self, status: std::process::ExitStatus, stderr: &[u8]) -> Error {
421 check_gpg_error(&self.gpg_homedir, status, stderr)
422 }
423}
424
425fn extract_keyid_from_line(line: &str) -> String {
426 for word in line.split_whitespace().rev() {
427 let trimmed = word.trim_end_matches([':', ',', '.']);
428 let normalized = trimmed
429 .strip_prefix("0x")
430 .or_else(|| trimmed.strip_prefix("0X"))
431 .unwrap_or(trimmed);
432 if !normalized.is_empty()
433 && normalized.chars().all(|c| c.is_ascii_hexdigit())
434 && matches!(normalized.len(), 8 | 16 | 40)
435 {
436 return trimmed.to_uppercase();
437 }
438 }
439 String::new()
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn test_extract_keyid_from_refresh_line() {
448 assert_eq!(
449 extract_keyid_from_line("gpg: refreshing 1 key from hkps://keyserver.ubuntu.com"),
450 ""
451 );
452 assert_eq!(
453 extract_keyid_from_line(
454 "gpg: requesting key DEADBEEF from hkps://keyserver.ubuntu.com"
455 ),
456 "DEADBEEF"
457 );
458 assert_eq!(
459 extract_keyid_from_line("gpg: key 786C63F330D7CB92: public key imported"),
460 "786C63F330D7CB92"
461 );
462 }
463
464 #[test]
465 fn test_extract_keyid_lowercase_normalized() {
466 assert_eq!(
467 extract_keyid_from_line("gpg: key deadbeef: something"),
468 "DEADBEEF"
469 );
470 }
471
472 #[test]
473 fn test_extract_keyid_no_match() {
474 assert_eq!(extract_keyid_from_line("gpg: some other message"), "");
475 assert_eq!(extract_keyid_from_line(""), "");
476 }
477
478 #[test]
479 fn test_check_error_permission_denied() {
480 let keyring = Keyring::new();
481 let stderr = b"gpg: Permission denied";
482 let status = std::process::Command::new("false").status().unwrap();
483
484 let err = keyring.check_error(status, stderr);
485 assert!(matches!(err, Error::PermissionDenied));
486 }
487
488 #[test]
489 fn test_check_error_permission_denied_lowercase() {
490 let keyring = Keyring::new();
491 let stderr = b"gpg: permission denied (are you root?)";
492 let status = std::process::Command::new("false").status().unwrap();
493
494 let err = keyring.check_error(status, stderr);
495 assert!(matches!(err, Error::PermissionDenied));
496 }
497
498 #[test]
499 fn test_check_error_keyring_not_initialized() {
500 let keyring = Keyring::new();
501 let stderr = b"gpg: keybox '/etc/pacman.d/gnupg/pubring.kbx': No such file or directory";
502 let status = std::process::Command::new("false").status().unwrap();
503
504 let err = keyring.check_error(status, stderr);
505 assert!(matches!(err, Error::KeyringNotInitialized));
506 }
507
508 #[test]
509 fn test_check_error_generic() {
510 let keyring = Keyring::new();
511 let stderr = b"gpg: some unknown error";
512 let status = std::process::Command::new("false").status().unwrap();
513
514 let err = keyring.check_error(status, stderr);
515 match err {
516 Error::PacmanKey { status: _, stderr } => {
517 assert!(stderr.contains("some unknown error"));
518 }
519 _ => panic!("expected PacmanKey error"),
520 }
521 }
522}