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