voa_openpgp/import/destructured/
mod.rs

1//! Import of destructured [OpenPGP certificates] as [VOA] verifiers.
2//!
3//! Destructured [OpenPGP certificates] are represented by (binary or ASCII-armored) [OpenPGP
4//! packet] data in separate files.
5//!
6//! # Note
7//!
8//! Destructured [OpenPGP certificates] are a non-standardized format.
9//!
10//! # Formats
11//!
12//! This module allows reading [OpenPGP certificates] from the following set of directory
13//! structures.
14//!
15//! # Flat structure
16//!
17//! A flat structure can be created by splitting an OpenPGP certificate using specialised tooling
18//! such as [`rpacket`]:
19//!
20//! ```bash
21//! rpacket split < cert.pgp
22//! ```
23//!
24//! This may create output similar to the following:
25//!
26//! ```text
27//! .
28//! ├── 000000-PublicKey
29//! ├── 000001-UserId
30//! ├── 000002-Signature
31//! ├── 000003-Signature
32//! ├── 000004-Signature
33//! ├── 000005-Signature
34//! ├── 000006-Signature
35//! ├── 000007-Signature
36//! ├── 000008-Signature
37//! ├── 000009-Signature
38//! ├── 000010-PublicSubkey
39//! ├── 000011-Signature
40//! ├── 000012-PublicSubkey
41//! ├── 000013-Signature
42//! ├── 000014-PublicSubkey
43//! └── 000015-Signature
44//! ```
45//!
46//! Here, each file contains raw [OpenPGP packet] data.
47//! The concatenation of all files in sequence represents a valid OpenPGP certificate, e.g.
48//!
49//! ```bash
50//! cat 0000* > cert-concant.pgp
51//! ```
52//!
53//! # Arch Linux keyring structure
54//!
55//! The [archlinux-keyring] project chose a more fine grained approach, that is based on a custom
56//! directory structure. Here, files containing raw ASCII-armored [OpenPGP packet] data are grouped
57//! by their specific use in an OpenPGP certificate.
58//!
59//! A single top-level file contains the primary component key, named after its [OpenPGP
60//! fingerprint] (e.g. `F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`).
61//!
62//! The following directories are used to group specific [OpenPGP packet] data:
63//!
64//! - `revocation`: If it exists, contains a file containing [Key Revocation Signature] data, named
65//!   after the [OpenPGP fingerprint] of the primary component key (e.g.
66//!   `revocation/F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`).
67//! - `directkey`: If it exists, contains a directory structure in which files containing [Direct
68//!   Key Signature] data reside. Individual files are located in a directory that reflects the
69//!   [OpenPGP fingerprint] of the targeted component key and are named after their specific
70//!   creation time (e.g.
71//!   `directkey/certification/F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15/2024-06-23_12-55-20.asc`)
72//! - `uid`: If it exists, contains a directory structure for each [User ID] or [User Attribute]
73//!   packet of the certificate. Here, [User ID]s are represented by directories named after their
74//!   string representation, with unusable characters replaced and an additional unique identifier
75//!   appended to prevent collision (e.g. `John Doe <jdoe@example.org>` ->
76//!   `uid/John_Doe__jdoe@example.org_d2ad250f`). Each [User ID] directory contains a top-level
77//!   file, which represents the [User ID] packet (e.g.
78//!   `uid/John_Doe__jdoe@example.org_d2ad250f/John_Doe__jdoe@example.org_d2ad250f.asc`). Further,
79//!   each such directory contains a `certification` and may contain a `revocation` directory. The
80//!   `certification` directory may contain User ID binding signatures and third-party
81//!   certifications (e.g. `uid/John_Doe__jdoe@example.org_d2ad250f/certification/
82//!   F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`). The `revocation` directory may contain User ID
83//!   revocation signatures or third-party certification revocation signatures (e.g.
84//!   `uid/John_Doe__jdoe@example.org_d2ad250f/revocation/
85//!   F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`).
86//! - `subkey`: If it exists, contains a directory structure for each subkey component key bound to
87//!   the primary component key of the certificate. A top-level directory is named after the
88//!   [OpenPGP fingerprint] of the component key (e.g.
89//!   `subkey/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F/`). Each top-level directory contains a file
90//!   containing [Public Subkey] data (e.g.
91//!   `subkey/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F.
92//!   asc`). Further, each such directory contains a `certification` and may contain a `revocation`
93//!   directory. The `certification` directory contains files containing [Subkey Binding Signature]
94//!   data, named after the [OpenPGP fingerprint] of the issuing key (e.g.
95//!   `subkey/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F/certification/
96//!   F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`). The `revocation` directory may contain files
97//!   containing [Subkey Revocation Signature] data, named after the [OpenPGP fingerprint] of the
98//!   issuing key (e.g. `subkey/E242ED3BFFCCDF271B7FBAF34ED72D089537B42F/revocation/
99//!   F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc`).
100//!
101//! The following example illustrates a destructured OpenPGP certificate using the
102//! [archlinux-keyring] specific directory format:
103//!
104//! ```text
105//! .
106//! ├── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
107//! ├── subkey
108//! │   ├── E242ED3BFFCCDF271B7FBAF34ED72D089537B42F
109//! │   │   ├── E242ED3BFFCCDF271B7FBAF34ED72D089537B42F.asc
110//! │   │   └── certification
111//! │   │       └── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
112//! │   ├── D3B0F7C0B825ECBB0F0D7398072947E7B1537B6F
113//! │   │   ├── D3B0F7C0B825ECBB0F0D7398072947E7B1537B6F.asc
114//! │   │   └── certification
115//! │   │       └── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
116//! │   └── 6EADEAC2DADE6347E87C0D24FD455FEFFA7069F0
117//! │       ├── 6EADEAC2DADE6347E87C0D24FD455FEFFA7069F0.asc
118//! │       └── certification
119//! │           └── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
120//! └── uid
121//!     └── John_Doe__jdoe@example.org_d2ad250f
122//!         ├── John_Doe__jdoe@example.org_d2ad250f.asc
123//!         └── certification
124//!             ├── B787A81C32997FD39A5F4C0188363902D3586E7B.asc
125//!             ├── 2072A695613E5103D9AC03C2885C5E2656CB5FF0.asc
126//!             ├── 68D61AF364B99AD0226A9C8859F18BF95A99BCE9.asc
127//!             ├── 033DB9A2637803F63BDA651106B2C4BEF184C21D.asc
128//!             ├── 868672B9CDB0BF449BF3782CFDA1DBE372838AA3.asc
129//!             ├── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
130//!             ├── 98EECC29ABC53C31B0DA5C85CB26CE720C7FF763.asc
131//!             └── 52428846EFFD79371A81D6C82D00FBFED9C654F3.asc
132//! ```
133//!
134//! [Direct Key Signature]: https://www.rfc-editor.org/rfc/rfc9580#name-direct-key-signature-type-i
135//! [Key Revocation Signature]: https://www.rfc-editor.org/rfc/rfc9580#name-key-revocation-signature-ty
136//! [OpenPGP certificates]: https://openpgp.dev/book/certificates.html
137//! [OpenPGP fingerprint]: https://openpgp.dev/book/certificates.html#fingerprint
138//! [OpenPGP packet]: https://openpgp.dev/book/zoom/certificates.html
139//! [Public Subkey]: https://www.rfc-editor.org/rfc/rfc9580#name-public-subkey-packet-type-i
140//! [Subkey Binding Signature]: https://www.rfc-editor.org/rfc/rfc9580#name-subkey-binding-signature-ty
141//! [Subkey Revocation Signature]: https://www.rfc-editor.org/rfc/rfc9580#name-subkey-revocation-signature
142//! [User ID]: https://www.rfc-editor.org/rfc/rfc9580#uid
143//! [User Attribute]: https://www.rfc-editor.org/rfc/rfc9580#name-user-attribute-packet-type-
144//! [VOA]: https://uapi-group.org/specifications/specs/file_hierarchy_for_the_verification_of_os_artifacts/
145//! [`rpacket`]: https://codeberg.org/heiko/rpacket
146//! [archlinux-keyring]: https://gitlab.archlinux.org/archlinux/archlinux-keyring/
147
148pub mod error;
149
150use std::{
151    fs::{DirEntry, File, Metadata, ReadDir},
152    io::{BufRead, BufReader, Read},
153    path::{Path, PathBuf},
154};
155
156use error::Error;
157use log::{debug, trace};
158use pgp::{
159    armor::Dearmor,
160    composed::{Deserializable, SignedPublicKey},
161    packet::{Packet, PacketParser, PacketTrait},
162    types::KeyDetails,
163};
164
165use crate::import::OpenPgpImport;
166
167/// The name of a directory used for OpenPGP packets that serve as certification.
168pub(crate) const CERTIFICATION_DIR: &str = "certification";
169/// The name of a directory used for OpenPGP packets that serve as revocation.
170pub(crate) const REVOCATION_DIR: &str = "revocation";
171/// The name of a directory used for OpenPGP packets that serve as direct key signatures.
172pub(crate) const DIRECTKEY_DIR: &str = "directkey";
173/// The name of a directory containing directory structures with OpenPGP packet files for User IDs.
174pub(crate) const UID_DIR: &str = "uid";
175/// The name of a directory containing directory structures with OpenPGP packet files for subkeys.
176pub(crate) const SUBKEY_DIR: &str = "subkey";
177
178/// Reads a directory `path`.
179fn read_dir(path: impl AsRef<Path>) -> Result<ReadDir, crate::Error> {
180    let path = path.as_ref();
181    path.read_dir().map_err(|source| crate::Error::IoPath {
182        path: path.to_path_buf(),
183        context: "reading entries of a directory",
184        source,
185    })
186}
187
188/// Maps a [`std::io::Error`] for a [`DirEntry`] to a [`crate::Error`].
189fn dir_entry(
190    entry: Result<DirEntry, std::io::Error>,
191    path: impl AsRef<Path>,
192) -> Result<DirEntry, crate::Error> {
193    let dir = path.as_ref();
194
195    entry.map_err(|source| crate::Error::IoPath {
196        path: dir.to_path_buf(),
197        context: "reading entry in directory",
198        source,
199    })
200}
201
202/// Retrieves [`Metadata`] for a `path` and maps [`std::io::Error`] to [`crate::Error`].
203fn path_metadata(path: impl AsRef<Path>) -> Result<Metadata, crate::Error> {
204    let path = path.as_ref();
205    path.metadata().map_err(|source| crate::Error::IoPath {
206        path: path.to_path_buf(),
207        context: "retrieving metadata for a path",
208        source,
209    })
210}
211
212/// Collects all regular files in a directory `path`.
213///
214/// # Errors
215///
216/// Returns an error if
217///
218/// - `path` is not a directory,
219/// - an entry in `path` cannot be read,
220/// - metadata for an entry in `path` cannot be retrieved,
221/// - or any path in `path` does not represent a regular file.
222fn files_in_dir(path: impl AsRef<Path>, list: &mut Vec<PathBuf>) -> Result<(), crate::Error> {
223    let dir = path.as_ref();
224    let entries = read_dir(dir)?;
225
226    for entry in entries {
227        let entry = dir_entry(entry, dir)?;
228        let path = entry.path();
229        let metadata = path_metadata(&path)?;
230
231        if !metadata.is_file() {
232            return Err(crate::import::Error::DestructuredImport(
233                Error::DirMustContainRegularFiles {
234                    dir: dir.to_path_buf(),
235                    path: path.clone(),
236                },
237            )
238            .into());
239        }
240
241        list.push(path.to_path_buf());
242    }
243
244    Ok(())
245}
246
247/// Creates a list of component directories from a directory.
248///
249/// Component directories may be those for OpenPGP User IDs or OpenPGP subkeys.
250///
251/// # Errors
252///
253/// Returns an error if
254///
255/// - `path` is not a directory,
256/// - an entry in `path` cannot be read,
257/// - metadata for an entry in `path` cannot be retrieved,
258/// - any path in `path` does not represent a directory,
259/// - or any path in `path` cannot be turned into a [`ComponentDir`].
260fn component_dirs_from_dir(path: impl AsRef<Path>) -> Result<Vec<ComponentDir>, crate::Error> {
261    let dir = path.as_ref();
262    let entries = read_dir(dir)?;
263
264    let mut dirs = Vec::new();
265
266    for entry in entries {
267        let entry = dir_entry(entry, dir)?;
268        let path = entry.path();
269        let metadata = path_metadata(&path)?;
270
271        if !metadata.is_dir() {
272            return Err(
273                crate::import::Error::DestructuredImport(Error::DirMustContainDirs {
274                    dir: dir.to_path_buf(),
275                    path: path.clone(),
276                })
277                .into(),
278            );
279        }
280        dirs.push(ComponentDir::try_from(path.as_path())?);
281    }
282
283    Ok(dirs)
284}
285
286/// Creates a list of direct key certification directories from a directory.
287///
288/// # Errors
289///
290/// Returns an error if
291///
292/// - `path` does not have a subdirectory named [`CERTIFICATION_DIR`], or reading entries from that
293///   subdirectory fails,
294/// - the [`CERTIFICATION_DIR`] subdirectory contains paths that are not directories,
295/// - or creating a [`DirectKeyCertificationDir`] from one of the paths in the [`CERTIFICATION_DIR`]
296///   subdirectory fails.
297fn direct_key_certification_dirs_from_dir(
298    path: impl AsRef<Path>,
299) -> Result<Vec<DirectKeyCertificationDir>, crate::Error> {
300    let dir = path.as_ref().join(CERTIFICATION_DIR);
301    let entries = read_dir(&dir)?;
302    let mut dirs = Vec::new();
303
304    for entry in entries {
305        let entry = dir_entry(entry, &dir)?;
306        let path = entry.path();
307        let metadata = path_metadata(&path)?;
308
309        if !metadata.is_dir() {
310            return Err(
311                crate::import::Error::DestructuredImport(Error::DirMustContainDirs {
312                    dir: dir.to_path_buf(),
313                    path: path.clone(),
314                })
315                .into(),
316            );
317        }
318
319        dirs.push(DirectKeyCertificationDir::try_from(path.as_path())?);
320    }
321
322    Ok(dirs)
323}
324
325/// The representation of a direct key certification directory.
326///
327/// A direct key certification directory consists of one directory with
328/// one or more direct key signature packets.
329///
330/// ```text
331/// E242ED3BFFCCDF271B7FBAF34ED72D089537B42F
332/// ├── 2024-04-01_15-17-48.asc
333/// └── 2024-11-05_18-52-46.asc
334/// ```
335pub(crate) struct DirectKeyCertificationDir(Vec<PathBuf>);
336
337impl DirectKeyCertificationDir {
338    /// Returns the list of tracked regular file paths.
339    pub fn paths(&self) -> Vec<PathBuf> {
340        self.0.to_vec()
341    }
342}
343
344impl TryFrom<&Path> for DirectKeyCertificationDir {
345    type Error = crate::Error;
346
347    /// Creates a new [`DirectKeyCertificationDir`] from a [`Path`] reference.
348    fn try_from(value: &Path) -> Result<Self, Self::Error> {
349        let mut paths = Vec::new();
350        files_in_dir(value, &mut paths)?;
351
352        Ok(Self(paths))
353    }
354}
355
356/// The representation of a component directory.
357///
358/// A component directory must contain exactly one top-level OpenPGP packet file and may contain
359/// zero or more OpenPGP packet files in the [`CERTIFICATION_DIR`] and/or [`REVOCATION_DIR`]
360/// subdirectories.
361/// It may be used to describe OpenPGP User ID directories
362///
363/// ```text
364/// John_Doe__jdoe@example.org_d2ad250f
365/// ├── John_Doe__jdoe@example.org_d2ad250f.asc
366/// └── certification
367///     ├── B787A81C32997FD39A5F4C0188363902D3586E7B.asc
368///     ├── 2072A695613E5103D9AC03C2885C5E2656CB5FF0.asc
369///     ├── 68D61AF364B99AD0226A9C8859F18BF95A99BCE9.asc
370///     ├── 033DB9A2637803F63BDA651106B2C4BEF184C21D.asc
371///     ├── 868672B9CDB0BF449BF3782CFDA1DBE372838AA3.asc
372///     ├── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
373///     ├── 98EECC29ABC53C31B0DA5C85CB26CE720C7FF763.asc
374///     └── 52428846EFFD79371A81D6C82D00FBFED9C654F3.asc
375/// ```
376///
377/// or subkey directories in an Arch Linux keyring structure.
378///
379/// ```text
380/// E242ED3BFFCCDF271B7FBAF34ED72D089537B42F
381/// ├── E242ED3BFFCCDF271B7FBAF34ED72D089537B42F.asc
382/// └── certification
383///     └── F1D2D2F924E986AC86FDF7B36C94BCDF32BEEC15.asc
384/// ```
385pub(crate) struct ComponentDir {
386    top_level: PathBuf,
387    certifications: Vec<PathBuf>,
388    revocations: Vec<PathBuf>,
389}
390
391impl ComponentDir {
392    /// Returns the list of tracked regular file paths.
393    ///
394    /// The top-level regular file path is returned before any regular file paths in the
395    /// "revocation" or "certification" subdirectories.
396    pub fn paths(&self) -> Vec<PathBuf> {
397        [
398            vec![self.top_level.clone()],
399            self.certifications.clone(),
400            self.revocations.clone(),
401        ]
402        .concat()
403    }
404}
405
406impl TryFrom<&Path> for ComponentDir {
407    type Error = crate::Error;
408
409    /// Creates a new [`ComponentDir`] from a [`Path`] reference.
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if
414    ///
415    /// - `value` is not a directory, or entries in it cannot be read,
416    /// - an entry in `value` cannot be read,
417    /// - metadata for a path in `value` cannot be retrieved,
418    /// - there is more than one top-level regular file in `value`,
419    /// - the directory [`CERTIFICATION_DIR`] exists in `value` but regular files from it cannot be
420    ///   retrieved,
421    /// - the directory [`REVOCATION_DIR`] exists in `value` but regular files from it cannot be
422    ///   retrieved,
423    /// - a directory not named [`CERTIFICATION_DIR`] or [`REVOCATION_DIR`] exists in `value`,
424    /// - a path in `value` is not a regular file or a directory,
425    /// - or there is not top-level regular file present in `value`.
426    fn try_from(value: &Path) -> Result<Self, Self::Error> {
427        let entries = read_dir(value)?;
428        let mut top_level = None;
429        let mut certifications = Vec::new();
430        let mut revocations = Vec::new();
431
432        for entry in entries {
433            let entry = dir_entry(entry, value)?;
434            let path = entry.path();
435            let metadata = path_metadata(&path)?;
436
437            if metadata.is_file() {
438                if top_level.is_some() {
439                    return Err(crate::import::Error::DestructuredImport(
440                        Error::MultipleTopLevelPackets {
441                            path: value.to_path_buf(),
442                        },
443                    )
444                    .into());
445                }
446
447                top_level = Some(path.clone());
448            } else if metadata.is_dir() {
449                if path.ends_with(CERTIFICATION_DIR) {
450                    files_in_dir(path, &mut certifications)?;
451                } else if path.ends_with(REVOCATION_DIR) {
452                    files_in_dir(path, &mut revocations)?;
453                } else {
454                    return Err(crate::import::Error::DestructuredImport(
455                        Error::InvalidComponentSubDirectory { path: path.clone() },
456                    )
457                    .into());
458                }
459            } else {
460                return Err(crate::import::Error::DestructuredImport(
461                    Error::DirMustContainRegularFilesOrDirs {
462                        dir: value.to_path_buf(),
463                        path: path.clone(),
464                    },
465                )
466                .into());
467            }
468        }
469
470        let Some(top_level) = top_level else {
471            return Err(
472                crate::import::Error::DestructuredImport(Error::NoTopLevelPacket {
473                    path: value.to_path_buf(),
474                })
475                .into(),
476            );
477        };
478
479        Ok(Self {
480            top_level,
481            certifications,
482            revocations,
483        })
484    }
485}
486
487/// Collects all regular files in a directory in the order of an [OpenPGP Transferable Public
488/// Key].
489///
490/// All top-level regular files in the directory are considered.
491/// Regular files located in the following list of directories (if they exist) are considered, in
492/// the following order:
493///
494/// - `revocation`: for Revocation Signature packets
495/// - `directkey`: for Direct Key Signature packets
496/// - `uid`: for User ID or User Attribute packets
497/// - `subkey`: for Subkey packets
498///
499/// # Errors
500///
501/// Returns an error if
502///
503/// - entries in `path` cannot be retrieved,
504/// - reading an entry in `path` fails,
505/// - or collecting files in any of the subdirectories (`revocation`, `directkey`, `uid` or
506///   `subkey`) fails.
507///
508/// [OpenPGP Transferable Public Key]: https://www.rfc-editor.org/rfc/rfc9580#name-transferable-public-keys
509fn collect_files_in_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>, crate::Error> {
510    let path = path.as_ref();
511    debug!("Collecting regular files in {path:?}");
512
513    let entries = read_dir(path)?;
514    let mut top_level: Vec<PathBuf> = Vec::new();
515    let mut revocation: Vec<PathBuf> = Vec::new();
516    let mut directkey: Vec<PathBuf> = Vec::new();
517    let mut subkey: Vec<PathBuf> = Vec::new();
518    let mut uid: Vec<PathBuf> = Vec::new();
519
520    for entry in entries {
521        let entry = dir_entry(entry, path)?;
522        let file_path = entry.path();
523        let metadata = path_metadata(&file_path)?;
524
525        if !(metadata.is_dir() || metadata.is_file()) {
526            return Err(crate::import::Error::DestructuredImport(
527                Error::DirMustContainRegularFilesOrDirs {
528                    dir: path.to_path_buf(),
529                    path: file_path.clone(),
530                },
531            )
532            .into());
533        }
534
535        if file_path.is_file() {
536            debug!("Found regular file {file_path:?}");
537            top_level.push(file_path);
538            continue;
539        }
540
541        if metadata.is_dir() && top_level.len() > 1 {
542            return Err(
543                crate::import::Error::DestructuredImport(Error::InvalidFlatStructure {
544                    path: path.to_path_buf(),
545                })
546                .into(),
547            );
548        }
549
550        if file_path.ends_with(REVOCATION_DIR) {
551            trace!("Found a directory for Revocation Signature packets");
552            files_in_dir(&file_path, &mut revocation)?;
553        } else if file_path.ends_with(DIRECTKEY_DIR) {
554            trace!("Found a directory for Direct Key Signature packets");
555            for direct_key_certification_dir in direct_key_certification_dirs_from_dir(&file_path)?
556            {
557                directkey.append(&mut direct_key_certification_dir.paths())
558            }
559        } else if file_path.ends_with(UID_DIR) {
560            trace!("Found a directory for User ID or User Attribute packets");
561            for user_id_dir in component_dirs_from_dir(&file_path)? {
562                uid.append(&mut user_id_dir.paths())
563            }
564        } else if file_path.ends_with(SUBKEY_DIR) {
565            trace!("Found a directory for Subkey packets");
566            for subkey_dir in component_dirs_from_dir(&file_path)? {
567                subkey.append(&mut subkey_dir.paths())
568            }
569        } else {
570            return Err(crate::import::Error::DestructuredImport(
571                Error::InvalidComponentSubDirectory {
572                    path: file_path.clone(),
573                },
574            )
575            .into());
576        }
577    }
578
579    top_level.sort();
580    revocation.sort();
581    directkey.sort();
582
583    // If there are no top-level OpenPGP packets, but some in subdirectories, this is not a valid
584    // Arch Linux keyring structure.
585    if top_level.is_empty()
586        && (!revocation.is_empty()
587            || !directkey.is_empty()
588            || !uid.is_empty()
589            || !subkey.is_empty())
590    {
591        return Err(crate::import::Error::DestructuredImport(
592            Error::InvalidArchLinuxKeyringStructure {
593                path: path.to_path_buf(),
594            },
595        )
596        .into());
597    }
598
599    // If there is more than one top-level OpenPGP packet and any OpenPGP packets in one of the Arch
600    // Linux keyring specific subdirectories, this is not a valid flat structure.
601    if top_level.len() > 1
602        && (!revocation.is_empty()
603            || !directkey.is_empty()
604            || !uid.is_empty()
605            || !subkey.is_empty())
606    {
607        return Err(
608            crate::import::Error::DestructuredImport(Error::InvalidFlatStructure {
609                path: path.to_path_buf(),
610            })
611            .into(),
612        );
613    }
614
615    Ok([top_level, revocation, directkey, uid, subkey].concat())
616}
617
618/// Recognizes a _single_ [OpenPGP packet] in a reader.
619///
620/// # Note
621///
622/// The `path` parameter is meant to reflect the file path from which the reader is created.
623/// It is only used for better error reporting.
624///
625/// # Errors
626///
627/// Returns an error if
628///
629/// - an OpenPGP packet cannot be parsed from `reader`,
630/// - there is no OpenPGP packet in `reader`,
631/// - there is at least one additional OpenPGP packet in `reader`,
632/// - or there is unparsable data after an initial `OpenPGP` packet.
633///
634/// [OpenPGP packet]: https://openpgp.dev/book/zoom/certificates.html
635fn parse_packet_from_reader<T: Read>(
636    reader: BufReader<T>,
637    path: &Path,
638) -> Result<Packet, crate::Error> {
639    let mut packet_parser = PacketParser::new(reader);
640
641    let packet = match packet_parser.next() {
642        Some(Ok(packet)) => packet,
643        Some(Err(source)) => {
644            return Err(crate::Error::OpenPgpPath {
645                path: path.to_path_buf(),
646                context: "parsing an OpenPGP packet from a buffer",
647                source,
648            });
649        }
650        None => {
651            return Err(
652                crate::import::Error::DestructuredImport(Error::NoPacketInFile {
653                    path: path.to_path_buf(),
654                })
655                .into(),
656            );
657        }
658    };
659
660    match packet_parser.next() {
661        Some(Ok(packet)) => {
662            return Err(
663                crate::import::Error::DestructuredImport(Error::ExcessPacket {
664                    path: path.to_path_buf(),
665                    tag: packet.tag(),
666                })
667                .into(),
668            );
669        }
670        Some(Err(source)) => {
671            return Err(crate::Error::OpenPgpPath {
672                path: path.to_path_buf(),
673                context: "parsing an excess OpenPGP packet from a buffer",
674                source,
675            });
676        }
677        None => {}
678    }
679
680    Ok(packet)
681}
682
683/// Reads a _single_ OpenPGP packet from a file.
684///
685/// The file contents may be binary or ASCII-armored.
686///
687/// # Errors
688///
689/// Returns an error if
690///
691/// - the file at `path` cannot be opened for reading,
692/// - the first byte of the file at `path` cannot be read,
693/// - the file at `path` is empty,
694/// - or if not exactly one OpenPGP packet is found in the file at `path`.
695fn read_packet_from_file(path: impl AsRef<Path>) -> Result<Packet, crate::Error> {
696    let path = path.as_ref();
697    debug!("Reading a single OpenPGP packet from file {path:?}");
698
699    let file = File::open(path).map_err(|source| crate::Error::IoPath {
700        path: path.to_path_buf(),
701        context: "reading the file",
702        source,
703    })?;
704    let mut reader = BufReader::new(file);
705
706    // Check whether the file contains OpenPGP binary or ASCII-armored data.
707    //
708    // NOTE: In OpenPGP binary data, the highest bit of the first byte is a one.
709    //       The first bit in ASCII is **always** `0`, which makes this a solid heuristic.
710    let is_binary = {
711        // Read (at least) the first byte into a buffer, without consuming it from the reader.
712        let buffer = reader.fill_buf().map_err(|source| crate::Error::IoPath {
713            path: path.to_path_buf(),
714            context: "filling the buffer",
715            source,
716        })?;
717        if buffer.is_empty() {
718            return Err(
719                crate::import::Error::DestructuredImport(Error::FileIsEmpty {
720                    path: path.to_path_buf(),
721                })
722                .into(),
723            );
724        }
725
726        // If the highest bit of the first byte is set, we assume this is OpenPGP binary data.
727        buffer[0] & 0x80 != 0
728    };
729
730    if is_binary {
731        parse_packet_from_reader(reader, path)
732    } else {
733        parse_packet_from_reader(BufReader::new(Dearmor::new(reader)), path)
734    }
735}
736
737/// Creates a _single_ [`SignedPublicKey`] from regular files in a directory.
738///
739/// First collects the paths of all regular files in `path`.
740/// Then parses each regular file as a _single_ [OpenPGP packet].
741/// Finally, reads a _single_ [OpenPGP certificate] from the packets.
742///
743/// # Errors
744///
745/// Returns an error if
746///
747/// - recursively collecting regular files from `path` fails,
748/// - parsing a _single_ OpenPGP packet from each collected regular file fails,
749/// - no [`SignedPublicKey`] can be created from the OpenPGP packets,
750/// - creating a [`SignedPublicKey`] from the OpenPGP packets fails,
751/// - an additional, unwanted [`SignedPublicKey`] is created from the OpenPGP packets,
752/// - or creating an additional, unwanted [`SignedPublicKey`] from the OpenPGP packets fails.
753///
754/// [OpenPGP packet]: https://openpgp.dev/book/zoom/certificates.html
755/// [OpenPGP certificate]: https://openpgp.dev/book/certificates.html
756fn signed_public_key_from_dir(path: impl AsRef<Path>) -> Result<SignedPublicKey, crate::Error> {
757    let path = path.as_ref();
758    debug!("Reading a single OpenPGP certificate from OpenPGP packets in directory {path:?}");
759
760    let paths = collect_files_in_dir(path)?;
761    let mut packets: Vec<_> = Vec::new();
762
763    for file_path in paths.iter() {
764        debug!("Reading regular file {file_path:?} as an OpenPGP packet");
765        packets.push(Ok(read_packet_from_file(file_path)?));
766    }
767
768    let mut cert_iter = SignedPublicKey::from_packets(packets.into_iter().peekable());
769    let pubkey = match cert_iter.next() {
770        Some(Ok(cert)) => cert,
771        Some(Err(source)) => {
772            return Err(crate::Error::OpenPgpPath {
773                path: path.to_path_buf(),
774                context: "reading an OpenPGP certificate from OpenPGP packets in a directory",
775                source,
776            });
777        }
778        None => {
779            return Err(
780                crate::import::Error::DestructuredImport(Error::NoOpenPgpCertInDir {
781                    path: path.to_path_buf(),
782                })
783                .into(),
784            );
785        }
786    };
787
788    match cert_iter.next() {
789        Some(Ok(cert)) => {
790            return Err(
791                crate::import::Error::DestructuredImport(Error::ExcessCertificateInDir {
792                    path: path.to_path_buf(),
793                    fingerprint: cert.fingerprint(),
794                })
795                .into(),
796            );
797        }
798        Some(Err(source)) => {
799            return Err(crate::Error::OpenPgpPath {
800                path: path.to_path_buf(),
801                context: "finding additional, unwanted OpenPGP packets in a directory",
802                source,
803            });
804        }
805        None => {}
806    }
807
808    Ok(pubkey)
809}
810
811/// Creates an [`OpenPgpImport`] from a directory containing OpenPGP packet files.
812///
813/// Recursively collects all regular files in the directory, concatenates them and
814/// attempts to create a single [`SignedPublicKey`] from the accumulated data.
815/// Supports both binary and ASCII-armored data.
816///
817/// The collected regular files must be sorted in the order of an [OpenPGP Transferable Public
818/// Key].
819/// Both **flat** and **Arch Linux keyring** structures are supported (see the
820/// [`import::destructured`][crate::import::destructured] module documentation for details).
821///
822/// # Errors
823///
824/// Returns an error if
825///
826/// - `path` is not a directory,
827/// - or a [`SignedPublicKey`] cannot be created from the accumulated data.
828///
829/// [OpenPGP Transferable Public Key]: https://www.rfc-editor.org/rfc/rfc9580#name-transferable-public-keys
830pub fn load_from_dir(path: impl AsRef<Path>) -> Result<OpenPgpImport, crate::Error> {
831    let path = path.as_ref();
832    debug!("Reading an OpenPGP certificate from directory: {path:?}");
833
834    if !path.is_dir() {
835        return Err(
836            crate::import::Error::DestructuredImport(Error::PathIsNotADir {
837                path: path.to_path_buf(),
838            })
839            .into(),
840        );
841    }
842
843    Ok(OpenPgpImport(signed_public_key_from_dir(path)?))
844}
845
846#[cfg(test)]
847mod tests {
848
849    use std::{
850        fs::{create_dir_all, remove_file},
851        ops::Add,
852        time::{Duration, SystemTime},
853    };
854
855    use log::{info, warn};
856    use pgp::{
857        armor::{BlockType, write as armor_write},
858        bytes::Buf,
859        composed::{KeyType, SecretKeyParamsBuilder, SignedSecretKey, SubkeyParamsBuilder},
860        crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
861        packet::{
862            Features,
863            KeyFlags,
864            PacketParser,
865            PacketTrait,
866            RevocationCode,
867            SignatureConfig,
868            SignatureType,
869            Subpacket,
870            SubpacketData,
871        },
872        ser::Serialize,
873        types::{Fingerprint, Password, SecretKeyTrait, Tag},
874    };
875    use rand::thread_rng;
876    use rstest::{fixture, rstest};
877    use simplelog::{ColorChoice, Config, LevelFilter, TermLogger, TerminalMode};
878    use tempfile::tempdir;
879    use testresult::TestResult;
880    use voa_core::VerifierWriter;
881
882    use super::*;
883
884    /// The configuration for splitting of input files.
885    enum SplitConfig {
886        /// Place all split OpenPGP packet files in the top-level directory.
887        Flat,
888        /// Place split OpenPGP packet files in subdirectory structures.
889        ArchLinuxKeyring,
890    }
891
892    /// The configuration for tests.
893    struct TestConfig {
894        /// Whether to ASCII-armor the input file(s).
895        pub armor: bool,
896        /// Whether to split the input file into files per packet.
897        pub split: SplitConfig,
898    }
899
900    /// Init logger
901    fn init_logger() {
902        if TermLogger::init(
903            LevelFilter::Trace,
904            Config::default(),
905            TerminalMode::Stderr,
906            ColorChoice::Auto,
907        )
908        .is_err()
909        {
910            debug!("Not initializing another logger, as one is initialized already.");
911        }
912    }
913
914    /// The current component when iterating over packets of an OpenPGP certificate.
915    ///
916    /// Tracks either subkey fingerprints or User IDs.
917    enum CurrentComponent {
918        /// No current component yet.
919        None,
920        /// A primary key.
921        Primary,
922        /// A subkey.
923        Subkey(Fingerprint),
924        /// A User ID.
925        UserId(String),
926    }
927
928    /// Splits an OpenPGP certificate into a list of packets in a single directory.
929    ///
930    /// The packets are written to separate files in `path`.
931    /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
932    fn split_openpgp_cert_flat(
933        openpgp_cert: &SignedPublicKey,
934        path: impl AsRef<Path>,
935        armor: bool,
936    ) -> TestResult {
937        let path = path.as_ref();
938        info!("Splitting OpenPGP certificate into packets in a top-level directory.");
939        for (i, packet_result) in PacketParser::new(openpgp_cert.to_bytes()?.reader()).enumerate() {
940            let packet = packet_result?;
941            let packet_file = path.join(format!("{:06}-{:?}", i, packet.tag()));
942            info!("Creating packet file {packet_file:?}");
943            let mut file = File::create(packet_file)?;
944            if armor {
945                armor_write(&packet, BlockType::PublicKey, &mut file, None, true)?;
946            } else {
947                packet.to_writer(&mut file)?;
948            }
949        }
950        Ok(())
951    }
952
953    /// Writes a single OpenPGP `packet` file to a directory `path`.
954    ///
955    /// Creates the parent directory.
956    /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
957    ///
958    /// # Errors
959    ///
960    /// Returns an error if
961    ///
962    /// - the parent directory cannot be created,
963    /// - creating a file at `path` fails,
964    /// - or writing the `packet` data to `path` fails.
965    fn write_openpgp_packet_file_to_dir(
966        path: impl AsRef<Path>,
967        packet: Packet,
968        armor: bool,
969    ) -> TestResult {
970        let path = path.as_ref();
971
972        if let Some(parent) = path.parent() {
973            create_dir_all(parent)?;
974        }
975
976        info!("Creating packet file {path:?}");
977        let mut file = File::create(path)?;
978        if armor {
979            armor_write(&packet, BlockType::PublicKey, &mut file, None, true)?;
980        } else {
981            packet.to_writer(&mut file)?;
982        }
983
984        Ok(())
985    }
986
987    /// Splits the OpenPGP certificate `openpgp_cert` into packets in a directory structure.
988    ///
989    /// The packets are written to separate files in a directory structure in `path`.
990    /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
991    ///
992    /// # Note
993    ///
994    /// This implementation is mostly compatible with the Arch Linux keyring structure.
995    /// However, it does not (yet) adjust the names of User ID directories and packet files
996    /// accordingly.
997    /// For test purposes this is sufficient though.
998    fn split_openpgp_cert_arch_linux(
999        openpgp_cert: &SignedPublicKey,
1000        path: impl AsRef<Path>,
1001        armor: bool,
1002    ) -> TestResult {
1003        let path = path.as_ref();
1004
1005        info!("Splitting OpenPGP certificate into packets in subdirectories.");
1006        let pubkey_fingerprint: Fingerprint = openpgp_cert.fingerprint();
1007        let mut current_component = CurrentComponent::None;
1008
1009        for packet_result in PacketParser::new(openpgp_cert.to_bytes()?.reader()) {
1010            let packet = packet_result?;
1011
1012            match &packet {
1013                Packet::PublicKey(_) => {
1014                    let file_path = path.join(format!("{pubkey_fingerprint}.asc"));
1015                    current_component = CurrentComponent::Primary;
1016
1017                    write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1018                }
1019                Packet::PublicSubkey(subkey) => {
1020                    let fingerprint = subkey.fingerprint();
1021                    let file_path = path
1022                        .join(SUBKEY_DIR)
1023                        .join(fingerprint.to_string())
1024                        .join(format!("{fingerprint}.asc"));
1025                    current_component = CurrentComponent::Subkey(fingerprint.clone());
1026
1027                    write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1028                }
1029                Packet::UserId(user_id) => {
1030                    let user_id_string = user_id
1031                        .as_str()
1032                        .ok_or("The User ID contains invalid UTF-8")?
1033                        .to_string();
1034                    let file_path = path
1035                        .join(UID_DIR)
1036                        .join(&user_id_string)
1037                        .join(format!("{user_id_string}.asc"));
1038                    current_component = CurrentComponent::UserId(user_id_string.clone());
1039
1040                    write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1041                }
1042                Packet::Signature(signature) => {
1043                    info!(
1044                        "Found OpenPGP signature packet of type: {:?}",
1045                        signature.typ()
1046                    );
1047                    // NOTE: There may be zero or more "Issuer Fingerprint" subpackets.
1048                    //
1049                    // - Older signatures may have no "Isser Fingerprint" subpacket at all, but
1050                    //   instead rely on "Issuer" subpackets. This test setup currently only deals
1051                    //   with the modern "Issuer Fingerprint" scenario.
1052                    // - If one or more "Issuer Fingerprint" subpackets exist, we only use the first
1053                    //   for this test setup.
1054                    let issuer_fingerprints: Vec<Fingerprint> = signature
1055                        .issuer_fingerprint()
1056                        .into_iter()
1057                        .cloned()
1058                        .collect();
1059                    let issuer_fingerprint = issuer_fingerprints
1060                        .as_slice()
1061                        .first()
1062                        .map(|f| f.to_string())
1063                        .unwrap_or("empty".to_string());
1064
1065                    match signature.typ() {
1066                        Some(SignatureType::SubkeyBinding) => 'inner: {
1067                            let CurrentComponent::Subkey(subkey_fingerprint) = &current_component
1068                            else {
1069                                warn!(
1070                                    "Skipping SubkeyBinding, because there is no subkey fingerprint"
1071                                );
1072                                break 'inner;
1073                            };
1074
1075                            let file_path = path
1076                                .join(SUBKEY_DIR)
1077                                .join(subkey_fingerprint.to_string())
1078                                .join(CERTIFICATION_DIR)
1079                                .join(format!("{issuer_fingerprint}.asc"));
1080
1081                            write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1082                        }
1083                        Some(SignatureType::CertPositive)
1084                        | Some(SignatureType::CertGeneric) => 'inner: {
1085                            let CurrentComponent::UserId(user_id) = &current_component else {
1086                                warn!("Skipping CertPositive, because there is no User ID");
1087                                break 'inner;
1088                            };
1089                            let file_path = path
1090                                .join(UID_DIR)
1091                                .join(user_id)
1092                                .join(CERTIFICATION_DIR)
1093                                .join(format!("{issuer_fingerprint}.asc"));
1094
1095                            write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1096                        }
1097                        Some(SignatureType::CertRevocation) => 'inner: {
1098                            let CurrentComponent::UserId(user_id) = &current_component else {
1099                                warn!("Skipping CertRevocation, because there is no User ID");
1100                                break 'inner;
1101                            };
1102                            let file_path = path
1103                                .join(UID_DIR)
1104                                .join(user_id)
1105                                .join(REVOCATION_DIR)
1106                                .join(format!("{issuer_fingerprint}.asc"));
1107
1108                            write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1109                        }
1110                        Some(SignatureType::Key) => {
1111                            let created_at_date = signature
1112                                .created()
1113                                .map(|d| d.to_string())
1114                                .unwrap_or("empty".to_string());
1115                            let file_path = path
1116                                .join(DIRECTKEY_DIR)
1117                                .join(CERTIFICATION_DIR)
1118                                .join(issuer_fingerprint)
1119                                .join(format!("{created_at_date}.asc"));
1120
1121                            write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1122                        }
1123                        Some(SignatureType::KeyRevocation) => {
1124                            let file_path = path
1125                                .join(REVOCATION_DIR)
1126                                .join(format!("{}.asc", issuer_fingerprint));
1127
1128                            write_openpgp_packet_file_to_dir(file_path, packet.clone(), armor)?;
1129                        }
1130                        _ => {
1131                            warn!("Not using unknown signature packet {signature:?}");
1132                        }
1133                    }
1134                }
1135                _ => {
1136                    warn!("Not using unknown packet {packet:?}");
1137                }
1138            }
1139        }
1140        Ok(())
1141    }
1142
1143    /// Splits an OpenPGP certificate into separate OpenPGP packets.
1144    ///
1145    /// The packets are written to separate files in `path`.
1146    /// If `armor` is `true`, each packet is written ASCII-armored using [`BlockType::PublicKey`].
1147    fn split_openpgp_cert(
1148        openpgp_cert: &SignedPublicKey,
1149        path: impl AsRef<Path>,
1150        armor: bool,
1151        config: SplitConfig,
1152    ) -> TestResult {
1153        let path = path.as_ref();
1154
1155        match config {
1156            SplitConfig::Flat => split_openpgp_cert_flat(openpgp_cert, path, armor)?,
1157            SplitConfig::ArchLinuxKeyring => {
1158                split_openpgp_cert_arch_linux(openpgp_cert, path, armor)?
1159            }
1160        }
1161
1162        Ok(())
1163    }
1164
1165    /// Creates a baseline OpenPGP private key for further specialization.
1166    fn openpgp_private_key() -> TestResult<SignedSecretKey> {
1167        let mut signkey = SubkeyParamsBuilder::default();
1168        signkey
1169            .key_type(KeyType::Ed25519Legacy)
1170            .can_sign(true)
1171            .can_encrypt(false)
1172            .can_authenticate(false);
1173        let mut key_params = SecretKeyParamsBuilder::default();
1174        key_params
1175            .key_type(KeyType::Ed25519Legacy)
1176            .can_certify(true)
1177            .can_sign(false)
1178            .can_encrypt(false)
1179            .primary_user_id("John Doe <jdoe@example.org>".to_string())
1180            .subkeys(vec![signkey.build()?]);
1181
1182        let secret_key_params = key_params.build()?;
1183        let secret_key = secret_key_params.generate(thread_rng())?;
1184
1185        // Produce binding self-signatures that link all the components together
1186        Ok(secret_key.sign(&mut thread_rng(), &Password::from(""))?)
1187    }
1188
1189    /// Creates an OpenPGP certificate ([`SignedPublicKey`]).
1190    #[fixture]
1191    fn openpgp_cert() -> TestResult<SignedPublicKey> {
1192        let private = openpgp_private_key()?;
1193
1194        Ok(SignedPublicKey::from(private))
1195    }
1196
1197    /// Creates a [`SystemTime`] rounded to a full second.
1198    ///
1199    /// This is a helper function for rPGP signature creation times.
1200    /// If creation times are not rounded to a full second, an OpenPGP implementation may otherwise
1201    /// round it during import and write, which makes it impossible to object compare the OpenPGP
1202    /// certificate later on.
1203    fn now_rounded_seconds() -> SystemTime {
1204        let now = SystemTime::now();
1205        let secs = now
1206            .duration_since(SystemTime::UNIX_EPOCH)
1207            .expect("now is guaranteed to be after UNIX_EPOCH")
1208            .as_secs();
1209        SystemTime::UNIX_EPOCH.add(Duration::from_secs(secs))
1210    }
1211
1212    /// Creates an OpenPGP certificate ([`SignedPublicKey`]) with a direct key signature.
1213    fn openpgp_cert_dks() -> TestResult<SignedPublicKey> {
1214        let mut private = openpgp_private_key()?;
1215
1216        //  additionally produce a direct key signature with the certificate metadata
1217        let mut config =
1218            SignatureConfig::v4(SignatureType::Key, private.algorithm(), private.hash_alg());
1219
1220        let mut flags = KeyFlags::default();
1221        flags.set_certify(true);
1222
1223        let mut features = Features::default();
1224        features.set_seipd_v1(true);
1225
1226        config.hashed_subpackets = vec![
1227            Subpacket::regular(SubpacketData::SignatureCreationTime(
1228                now_rounded_seconds().into(),
1229            ))?,
1230            Subpacket::regular(SubpacketData::IssuerFingerprint(private.fingerprint()))?,
1231            Subpacket::regular(SubpacketData::KeyFlags(flags))?,
1232            Subpacket::regular(SubpacketData::Features(features))?,
1233            Subpacket::regular(SubpacketData::PreferredSymmetricAlgorithms(
1234                vec![
1235                    SymmetricKeyAlgorithm::AES256,
1236                    SymmetricKeyAlgorithm::AES192,
1237                    SymmetricKeyAlgorithm::AES128,
1238                ]
1239                .into(),
1240            ))?,
1241            Subpacket::regular(SubpacketData::PreferredHashAlgorithms(
1242                vec![
1243                    HashAlgorithm::Sha256,
1244                    HashAlgorithm::Sha384,
1245                    HashAlgorithm::Sha512,
1246                ]
1247                .into(),
1248            ))?,
1249        ];
1250
1251        let dks = config.sign_key(
1252            &private.primary_key,
1253            &Password::empty(),
1254            private.primary_key.public_key(),
1255        )?;
1256        private.details.direct_signatures.push(dks);
1257
1258        Ok(SignedPublicKey::from(private))
1259    }
1260
1261    /// Creates an OpenPGP certificate ([`SignedPublicKey`]) with a revocation signature.
1262    ///
1263    /// The resulting OpenPGP certificate is considered "revoked".
1264    fn openpgp_cert_revoked() -> TestResult<SignedPublicKey> {
1265        let mut private = openpgp_private_key()?;
1266
1267        // produce a revocation signature and add it to the top level key
1268        let mut config = SignatureConfig::v4(
1269            SignatureType::KeyRevocation,
1270            private.algorithm(),
1271            private.hash_alg(),
1272        );
1273
1274        let mut flags = KeyFlags::default();
1275        flags.set_certify(true);
1276
1277        let mut features = Features::default();
1278        features.set_seipd_v1(true);
1279
1280        config.hashed_subpackets = vec![
1281            Subpacket::regular(SubpacketData::SignatureCreationTime(
1282                now_rounded_seconds().into(),
1283            ))?,
1284            Subpacket::regular(SubpacketData::IssuerFingerprint(private.fingerprint()))?,
1285            Subpacket::regular(SubpacketData::RevocationReason(
1286                RevocationCode::KeyRetired,
1287                "Key has been replaced by 0x1234".into(),
1288            ))?,
1289        ];
1290
1291        let revocation = config.sign_key(
1292            &private.primary_key,
1293            &Password::empty(),
1294            private.primary_key.public_key(),
1295        )?;
1296        private.details.revocation_signatures.push(revocation);
1297
1298        let pubkey = SignedPublicKey::from(private);
1299        Ok(pubkey)
1300    }
1301
1302    /// Creates an OpenPGP certificate ([`SignedPublicKey`]) with a User ID revocation signature.
1303    ///
1304    /// The User ID of the resulting OpenPGP certificate is considered "revoked".
1305    fn openpgp_cert_revoked_primary_user() -> TestResult<SignedPublicKey> {
1306        let mut private = openpgp_private_key()?;
1307
1308        // produce a revocation signature and add it to the top level key
1309        let mut config = SignatureConfig::v4(
1310            SignatureType::CertRevocation,
1311            private.algorithm(),
1312            private.hash_alg(),
1313        );
1314
1315        let mut flags = KeyFlags::default();
1316        flags.set_certify(true);
1317
1318        let mut features = Features::default();
1319        features.set_seipd_v1(true);
1320
1321        config.hashed_subpackets = vec![
1322            Subpacket::regular(SubpacketData::SignatureCreationTime(
1323                now_rounded_seconds().into(),
1324            ))?,
1325            Subpacket::regular(SubpacketData::IssuerFingerprint(private.fingerprint()))?,
1326            Subpacket::regular(SubpacketData::RevocationReason(
1327                RevocationCode::CertUserIdInvalid,
1328                "This User ID is now invalid".into(),
1329            ))?,
1330        ];
1331
1332        let revocation = config.sign_certification(
1333            &private.primary_key,
1334            private.primary_key.public_key(),
1335            &Password::empty(),
1336            Tag::UserId,
1337            &private.details.users[0].id,
1338        )?;
1339        private.details.users[0].signatures.push(revocation);
1340
1341        let pubkey = SignedPublicKey::from(private);
1342        Ok(pubkey)
1343    }
1344
1345    /// Ensures that [`OpenPgpImport`] can be created from OpenPGP certificate.
1346    ///
1347    /// Further ensures that the export of the verifier with [`OpenPgpImport::write_to_hierarchy`]
1348    /// works.
1349    ///
1350    /// Provided OpenPGP packet files are created based on a [`TestConfig`].
1351    #[rstest]
1352    #[case::split_top_level_binary(TestConfig{armor: false, split: SplitConfig::Flat}, openpgp_cert())]
1353    #[case::split_top_level_armor(TestConfig{armor: true, split: SplitConfig::Flat}, openpgp_cert())]
1354    #[case::split_dirs_binary(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert())]
1355    #[case::split_dirs_armor(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert())]
1356    #[case::split_top_level_binary_dks(TestConfig{armor: false, split: SplitConfig::Flat}, openpgp_cert_dks())]
1357    #[case::split_top_level_armor_dks(TestConfig{armor: true, split: SplitConfig::Flat}, openpgp_cert_dks())]
1358    #[case::split_dirs_binary_dks(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_dks())]
1359    #[case::split_dirs_armor_dks(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_dks())]
1360    #[case::split_top_level_binary_revoked(TestConfig{armor: false, split: SplitConfig::Flat}, openpgp_cert_revoked())]
1361    #[case::split_top_level_armor_revoked(TestConfig{armor: true, split: SplitConfig::Flat}, openpgp_cert_revoked())]
1362    #[case::split_dirs_binary_revoked(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_revoked())]
1363    #[case::split_dirs_armor_revoked(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_revoked())]
1364    #[case::split_top_level_binary_revoked_primary_user(TestConfig{armor: false, split: SplitConfig::Flat}, openpgp_cert_revoked_primary_user())]
1365    #[case::split_top_level_armor_revoked_primary_user(TestConfig{armor: true, split: SplitConfig::Flat}, openpgp_cert_revoked_primary_user())]
1366    #[case::split_dirs_binary_revoked_primary_user(TestConfig{armor: false, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_revoked_primary_user())]
1367    #[case::split_dirs_armor_revoked_primary_user(TestConfig{armor: true, split: SplitConfig::ArchLinuxKeyring}, openpgp_cert_revoked_primary_user())]
1368    fn write_to_hierarchy_succeeds(
1369        #[case] config: TestConfig,
1370        #[case] cert: TestResult<SignedPublicKey>,
1371    ) -> TestResult {
1372        init_logger();
1373
1374        let input_pubkey = cert?;
1375
1376        let temp_dir = tempdir()?;
1377        let path = temp_dir.path().to_path_buf();
1378        split_openpgp_cert(&input_pubkey, &path, config.armor, config.split)?;
1379
1380        let import = load_from_dir(path.as_path())?;
1381
1382        let temp_dir = tempdir()?;
1383        let output_dir = temp_dir.path();
1384        import.write_to_hierarchy(
1385            output_dir,
1386            "os".parse()?,
1387            "purpose".parse()?,
1388            Some("context".parse()?),
1389        )?;
1390
1391        let output_file = output_dir
1392            .join("os")
1393            .join("purpose")
1394            .join("context")
1395            .join(import.technology().to_string())
1396            .join(import.file_name());
1397
1398        let (output_pubkey, _) = SignedPublicKey::from_armor_file(&output_file)?;
1399
1400        assert_eq!(input_pubkey, output_pubkey);
1401
1402        Ok(())
1403    }
1404
1405    /// Ensures that [`openpgp_import_from_destructured_dir`] fails on invalid flat structures.
1406    #[rstest]
1407    fn openpgp_import_from_destructured_dir_fails_on_invalid_flat_structure(
1408        openpgp_cert: TestResult<SignedPublicKey>,
1409    ) -> TestResult {
1410        init_logger();
1411
1412        let input_pubkey = openpgp_cert?;
1413
1414        let temp_dir = tempdir()?;
1415        let path = temp_dir.path().to_path_buf();
1416
1417        // Write both flat and Arch Linux keyring structures to the import directory.
1418        // This renders this structure an invalid flat structure.
1419        split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::Flat)?;
1420        split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::ArchLinuxKeyring)?;
1421
1422        match load_from_dir(path.as_path()) {
1423            Ok(verifier) => {
1424                return Err(format!(
1425                    "Should have failed, but succeeded to create a verifier: {verifier:?}"
1426                )
1427                .into());
1428            }
1429            Err(error) => match error {
1430                crate::Error::Import(crate::import::error::Error::DestructuredImport(
1431                    Error::InvalidFlatStructure { .. },
1432                )) => {}
1433                error => {
1434                    return Err(format!("Did not return the correct error, got: {error}").into());
1435                }
1436            },
1437        }
1438
1439        Ok(())
1440    }
1441
1442    /// Ensures that [`openpgp_import_from_destructured_dir`] fails on invalid Arch Linux keyring
1443    /// structures.
1444    #[rstest]
1445    fn openpgp_import_from_destructured_dir_fails_on_invalid_arch_linux_keyring_structure(
1446        openpgp_cert: TestResult<SignedPublicKey>,
1447    ) -> TestResult {
1448        init_logger();
1449
1450        let input_pubkey = openpgp_cert?;
1451
1452        let temp_dir = tempdir()?;
1453        let path = temp_dir.path().to_path_buf();
1454        split_openpgp_cert(&input_pubkey, &path, true, SplitConfig::ArchLinuxKeyring)?;
1455
1456        // Remove the only top-level file in the import directory.
1457        // This renders this structure an invalid Arch Linux keyring structure
1458        for dir_entry in path.read_dir()? {
1459            let dir_entry = dir_entry?;
1460            let path = dir_entry.path();
1461            if path.is_file() {
1462                remove_file(path)?;
1463            }
1464        }
1465
1466        match load_from_dir(path.as_path()) {
1467            Ok(verifier) => {
1468                return Err(format!(
1469                    "Should have failed, but succeeded to create a verifier: {verifier:?}"
1470                )
1471                .into());
1472            }
1473            Err(error) => match error {
1474                crate::Error::Import(crate::import::error::Error::DestructuredImport(
1475                    Error::InvalidArchLinuxKeyringStructure { .. },
1476                )) => {}
1477                error => {
1478                    return Err(format!("Did not return the correct error, got: {error}").into());
1479                }
1480            },
1481        }
1482
1483        Ok(())
1484    }
1485
1486    /// Ensures that [`OpenPgpImport::new`] fails if the input path is not a file or a directory.
1487    #[test]
1488    #[cfg(target_os = "linux")]
1489    fn openpgp_import_from_destructured_dir_fails_on_path_not_a_dir() -> TestResult {
1490        match load_from_dir("/dev/null") {
1491            Ok(verifier) => {
1492                return Err(format!(
1493                    "Should have failed, but succeeded to create a verifier: {verifier:?}"
1494                )
1495                .into());
1496            }
1497            Err(error) => match error {
1498                crate::Error::Import(crate::import::error::Error::DestructuredImport(
1499                    crate::import::destructured::error::Error::PathIsNotADir { .. },
1500                )) => {}
1501                error => {
1502                    return Err(format!("Did not return the correct error, got: {error}").into());
1503                }
1504            },
1505        }
1506        Ok(())
1507    }
1508}