pacman_key/
keyring.rs

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
12/// Read-only interface for querying a GPG keyring.
13///
14/// This type is returned by [`Keyring::with_homedir`] and only provides
15/// read operations (`list_keys`, `list_signatures`). Write operations
16/// require a [`Keyring`] which targets the system pacman keyring.
17///
18/// # Example
19///
20/// ```no_run
21/// # async fn example() -> pacman_key::Result<()> {
22/// use pacman_key::Keyring;
23///
24/// let reader = Keyring::with_homedir("/custom/gnupg");
25/// let keys = reader.list_keys().await?;
26/// # Ok(())
27/// # }
28/// ```
29pub struct ReadOnlyKeyring {
30    gpg_homedir: String,
31}
32
33impl ReadOnlyKeyring {
34    /// Lists all keys in the keyring.
35    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    /// Lists signatures on keys in the keyring.
52    ///
53    /// If `keyid` is provided, lists signatures only for that key.
54    /// Otherwise lists all signatures in the keyring.
55    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
94/// Interface for managing the pacman keyring.
95///
96/// Provides async methods for key listing, importing, signing, and keyring management.
97/// Most write operations require root privileges.
98///
99/// # Example
100///
101/// ```no_run
102/// use pacman_key::Keyring;
103///
104/// # async fn example() -> pacman_key::Result<()> {
105/// let keyring = Keyring::new();
106/// let keys = keyring.list_keys().await?;
107/// println!("Found {} keys", keys.len());
108/// # Ok(())
109/// # }
110/// ```
111pub 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    /// Creates a new Keyring using the default pacman keyring path.
123    #[must_use]
124    pub fn new() -> Self {
125        Self {
126            gpg_homedir: DEFAULT_GPG_HOMEDIR.to_string(),
127        }
128    }
129
130    /// Creates a read-only keyring interface for a custom GPG home directory.
131    ///
132    /// Returns a [`ReadOnlyKeyring`] that can only perform read operations
133    /// (`list_keys`, `list_signatures`). This is useful for inspecting
134    /// alternative keyrings without risking modifications.
135    ///
136    /// # Example
137    ///
138    /// ```no_run
139    /// # async fn example() -> pacman_key::Result<()> {
140    /// use pacman_key::Keyring;
141    ///
142    /// let reader = Keyring::with_homedir("/custom/gnupg");
143    /// let keys = reader.list_keys().await?;
144    /// # Ok(())
145    /// # }
146    /// ```
147    #[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    /// Lists all keys in the keyring.
173    ///
174    /// # Example
175    ///
176    /// ```no_run
177    /// # async fn example() -> pacman_key::Result<()> {
178    /// use pacman_key::Keyring;
179    ///
180    /// let keyring = Keyring::new();
181    /// for key in keyring.list_keys().await? {
182    ///     println!("{} - {:?}", key.uid, key.validity);
183    /// }
184    /// # Ok(())
185    /// # }
186    /// ```
187    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    /// Lists signatures on keys in the keyring.
204    ///
205    /// If `keyid` is provided, lists signatures only for that key.
206    /// Otherwise lists all signatures in the keyring.
207    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    /// Initializes the pacman keyring.
229    ///
230    /// Creates the keyring directory and generates a local signing key.
231    /// Requires root privileges.
232    pub async fn init_keyring(&self) -> Result<()> {
233        self.run_pacman_key(&["--init"]).await
234    }
235
236    /// Populates the keyring with keys from distribution keyrings.
237    ///
238    /// If no keyrings are specified, defaults to "archlinux".
239    /// Requires root privileges.
240    ///
241    /// # Keyring Names
242    ///
243    /// Keyring names must contain only alphanumeric characters, hyphens, or
244    /// underscores. Common valid names include "archlinux", "archlinuxarm",
245    /// and "manjaro".
246    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    /// Receives keys from a keyserver.
264    ///
265    /// Requires root privileges.
266    ///
267    /// # Note
268    ///
269    /// This function validates key IDs before making the request. Key IDs must be
270    /// 8, 16, or 40 hexadecimal characters (with optional "0x" prefix).
271    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    /// Locally signs a key to mark it as trusted.
288    ///
289    /// Requires root privileges.
290    ///
291    /// # Note
292    ///
293    /// This function validates key IDs before making the request. Key IDs must be
294    /// 8, 16, or 40 hexadecimal characters (with optional "0x" prefix).
295    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    /// Deletes a key from the keyring.
301    ///
302    /// Requires root privileges.
303    ///
304    /// # Note
305    ///
306    /// This function validates key IDs before making the request. Key IDs must be
307    /// 8, 16, or 40 hexadecimal characters (with optional "0x" prefix).
308    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    /// Refreshes all keys from the keyserver.
314    ///
315    /// This is a long-running operation. The callback receives progress updates
316    /// as keys are refreshed.
317    ///
318    /// # Example
319    ///
320    /// ```no_run
321    /// # async fn example() -> pacman_key::Result<()> {
322    /// use pacman_key::{Keyring, RefreshOptions, RefreshProgress};
323    ///
324    /// let keyring = Keyring::new();
325    ///
326    /// // With timeout
327    /// let options = RefreshOptions { timeout_secs: Some(300) };
328    ///
329    /// keyring.refresh_keys(|progress| {
330    ///     match progress {
331    ///         RefreshProgress::Starting { total_keys } => {
332    ///             println!("Refreshing {} keys...", total_keys);
333    ///         }
334    ///         RefreshProgress::Refreshing { current, total, keyid } => {
335    ///             println!("[{}/{}] {}", current, total, keyid);
336    ///         }
337    ///         RefreshProgress::Completed => println!("Done!"),
338    ///         RefreshProgress::Error { keyid, message } => {
339    ///             eprintln!("Error refreshing {}: {}", keyid, message);
340    ///         }
341    ///         _ => {}
342    ///     }
343    /// }, options).await?;
344    /// # Ok(())
345    /// # }
346    /// ```
347    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}