dev_scripts/
testing.rs

1//! Tests against downloaded artifacts.
2
3use std::{collections::HashSet, fs::read_dir, path::PathBuf, str::FromStr};
4
5use alpm_buildinfo::BuildInfo;
6use alpm_common::MetadataFile;
7use alpm_mtree::Mtree;
8use alpm_pkginfo::PackageInfo;
9use alpm_srcinfo::SourceInfo;
10use log::{debug, info};
11use rayon::iter::{IntoParallelIterator, ParallelIterator};
12use voa::{
13    commands::{
14        PurposeAndContext,
15        get_technology_settings,
16        get_voa_config,
17        openpgp_verify,
18        read_openpgp_signatures,
19        read_openpgp_verifiers,
20    },
21    core::{Context, Os, Purpose},
22    openpgp::ModelBasedVerifier,
23    utils::RegularFile,
24};
25
26use crate::{
27    CacheDir,
28    Error,
29    cli::TestFileType,
30    consts::{AUR_DIR, DATABASES_DIR, DOWNLOAD_DIR, PACKAGES_DIR, PKGSRC_DIR},
31    sync::PackageRepositories,
32    ui::get_progress_bar,
33};
34
35/// Verifies a `file` using a `signature` and a [`ModelBasedVerifier`].
36///
37/// The success or failure of the verification is transmitted through logging.
38///
39/// # Errors
40///
41/// Returns an error if
42///
43/// - the `signature` cannot be read as an OpenPGP signature
44/// - the `file` cannot be read
45fn openpgp_verify_file(
46    file: PathBuf,
47    signature: PathBuf,
48    model_verifier: &ModelBasedVerifier,
49) -> Result<(), Error> {
50    debug!("Verifying {file:?} with {signature:?}");
51
52    let signatures = read_openpgp_signatures(&HashSet::from_iter([RegularFile::try_from(
53        signature.clone(),
54    )?]))?;
55
56    let check_results = openpgp_verify(
57        model_verifier,
58        &signatures,
59        &RegularFile::try_from(file.clone())?,
60    )?;
61
62    // Look at the signer info of all check results and return an error if there is none.
63    for check_result in check_results {
64        if let Some(signer_info) = check_result.signer_info() {
65            debug!(
66                "Successfully verified using {} {}",
67                signer_info
68                    .certificate()
69                    .fingerprint()
70                    .map_err(voa::Error::VoaOpenPgp)?,
71                signer_info.component_fingerprint()
72            )
73        } else {
74            return Err(Error::VoaVerificationFailed {
75                file,
76                signature,
77                context: "".to_string(),
78            });
79        }
80    }
81
82    Ok(())
83}
84
85/// This is the entry point for running validation tests of parsers on ALPM metadata files.
86#[derive(Clone, Debug)]
87pub struct TestRunner {
88    /// The directory in which test data is stored.
89    pub cache_dir: CacheDir,
90    /// The type of file that is targeted in the test.
91    pub file_type: TestFileType,
92    /// The list of repositories against which the test runs.
93    pub repositories: Vec<PackageRepositories>,
94}
95
96impl TestRunner {
97    /// Run validation on all local test files that have been downloaded via the
98    /// `test-files download` command.
99    pub fn run_tests(&self) -> Result<(), Error> {
100        let test_files = self.find_files_of_type()?;
101        info!(
102            "Found {} {} files for testing",
103            test_files.len(),
104            self.file_type
105        );
106
107        // Cache the certificates used for VOA-based verification as that significantly increases
108        // speed.
109        let os = Os::from_str("arch").map_err(voa::Error::VoaCore)?;
110
111        let (artifact_verifiers, anchors) = if matches!(self.file_type, TestFileType::Signatures) {
112            let artifact_verifiers = read_openpgp_verifiers(
113                os.clone(),
114                Purpose::from_str("package").map_err(voa::Error::VoaCore)?,
115                Context::Default,
116            );
117            let anchors = read_openpgp_verifiers(
118                os.clone(),
119                Purpose::from_str("trust-anchor-package").map_err(voa::Error::VoaCore)?,
120                Context::Default,
121            );
122            (artifact_verifiers, anchors)
123        } else {
124            (Vec::new(), Vec::new())
125        };
126
127        let config = get_voa_config();
128        let purpose_and_context = PurposeAndContext::new(
129            Some(Purpose::from_str("package").map_err(voa::Error::VoaCore)?),
130            Some(Context::Default),
131        );
132        let openpgp_settings =
133            get_technology_settings(&config, &os, purpose_and_context.as_ref()).openpgp_settings();
134
135        let model_verifier =
136            ModelBasedVerifier::new(openpgp_settings, &artifact_verifiers, &anchors);
137
138        let progress_bar = get_progress_bar(test_files.len() as u64);
139
140        // Run the validate subcommand for all files in parallel.
141        let asserts: Vec<(PathBuf, Result<(), Error>)> = test_files
142            .into_par_iter()
143            .map(|file| {
144                let result = match self.file_type {
145                    TestFileType::BuildInfo => BuildInfo::from_file_with_schema(&file, None)
146                        .map(|_| ())
147                        .map_err(|err| err.into()),
148                    TestFileType::SrcInfo => SourceInfo::from_file_with_schema(&file, None)
149                        .map(|_| ())
150                        .map_err(|err| err.into()),
151                    TestFileType::MTree => Mtree::from_file_with_schema(&file, None)
152                        .map(|_| ())
153                        .map_err(|err| err.into()),
154                    TestFileType::PackageInfo => PackageInfo::from_file_with_schema(&file, None)
155                        .map(|_| ())
156                        .map_err(|err| err.into()),
157                    TestFileType::RemoteDesc => unimplemented!(),
158                    TestFileType::RemoteFiles => unimplemented!(),
159                    TestFileType::LocalDesc => unimplemented!(),
160                    TestFileType::LocalFiles => unimplemented!(),
161                    TestFileType::Signatures => {
162                        let data = {
163                            let mut data = file.clone();
164                            data.set_extension("");
165                            data
166                        };
167
168                        openpgp_verify_file(data, file.clone(), &model_verifier)
169                    }
170                };
171
172                progress_bar.inc(1);
173                (file, result)
174            })
175            .collect();
176
177        // Finish the progress_bar
178        progress_bar.finish_with_message("Validation run finished.");
179
180        // Get all files and the respective error for which validation failed.
181        let failures: Vec<(PathBuf, Error)> = asserts
182            .into_iter()
183            .filter_map(|(path, result)| {
184                if let Err(err) = result {
185                    Some((path, err))
186                } else {
187                    None
188                }
189            })
190            .collect();
191
192        if !failures.is_empty() {
193            return Err(Error::TestFailed {
194                failures: failures
195                    .iter()
196                    .enumerate()
197                    .map(|(index, failure)| (index, failure.0.clone(), failure.1.to_string()))
198                    .collect::<Vec<_>>(),
199            });
200        }
201
202        Ok(())
203    }
204
205    /// Searches the download directory for all files of the given type.
206    ///
207    /// Returns a list of Paths that were found in the process.
208    pub fn find_files_of_type(&self) -> Result<Vec<PathBuf>, Error> {
209        debug!("Searching for files of type {}", self.file_type);
210
211        let mut files = Vec::new();
212
213        // First up, determine which folders we should look at while searching for files.
214        let type_folders = match self.file_type {
215            // All package related file types are nested in the subdirectories of the respective
216            // package's package repository.
217            TestFileType::BuildInfo | TestFileType::PackageInfo | TestFileType::MTree => self
218                .repositories
219                .iter()
220                .map(|repo| {
221                    self.cache_dir
222                        .as_ref()
223                        .join(PACKAGES_DIR)
224                        .join(repo.to_string())
225                })
226                .collect(),
227            TestFileType::SrcInfo => vec![
228                self.cache_dir.as_ref().join(PKGSRC_DIR),
229                self.cache_dir.as_ref().join(AUR_DIR),
230            ],
231            // The `desc` and `files` file types are nested in the subdirectories of the respective
232            // package's package repository.
233            TestFileType::RemoteDesc | TestFileType::RemoteFiles => self
234                .repositories
235                .iter()
236                .map(|repo| {
237                    self.cache_dir
238                        .as_ref()
239                        .join(DATABASES_DIR)
240                        .join(repo.to_string())
241                })
242                .collect(),
243            TestFileType::Signatures => {
244                let dirs: Vec<PathBuf> = self
245                    .repositories
246                    .iter()
247                    .map(|repo| {
248                        self.cache_dir
249                            .as_ref()
250                            .join(DOWNLOAD_DIR)
251                            .join(PACKAGES_DIR)
252                            .join(repo.to_string())
253                    })
254                    .collect();
255
256                // We return early because we are collecting files based on extension.
257                return files_in_dirs_by_extension(
258                    dirs.as_slice(),
259                    &TestFileType::Signatures.to_string(),
260                );
261            }
262            TestFileType::LocalDesc | TestFileType::LocalFiles => {
263                unimplemented!();
264            }
265        };
266
267        for folder in type_folders {
268            debug!("Looking for files in {folder:?}");
269            if !folder.exists() {
270                info!("The directory {folder:?} doesn't exist, skipping.");
271                continue;
272            }
273            // Each top-level folder contains a number of sub-folders where each sub-folder
274            // represents a single package. Check if the file we're interested in exists
275            // for said package. If so, add it to the list
276            for pkg_folder in read_dir(&folder).map_err(|source| Error::IoPath {
277                path: folder.clone(),
278                context: "reading entries in directory".to_string(),
279                source,
280            })? {
281                let pkg_folder = pkg_folder.map_err(|source| Error::IoPath {
282                    path: folder.clone(),
283                    context: "reading an entry of the directory".to_string(),
284                    source,
285                })?;
286                let file_path = pkg_folder.path().join(self.file_type.to_string());
287                if file_path.exists() {
288                    files.push(file_path);
289                }
290            }
291        }
292
293        Ok(files)
294    }
295}
296
297/// Collects all regular files in a list of directories and filters them by extension.
298///
299/// Skips non-existent paths in `dirs`.
300/// Only considers regular files, that have a matching `extension`.
301///
302/// # Errors
303///
304/// Returns an error if
305///
306/// - the entries in a directory in one of the paths in `dirs` cannot be read
307/// - one of the entries in a directory cannot be read
308fn files_in_dirs_by_extension(dirs: &[PathBuf], extension: &str) -> Result<Vec<PathBuf>, Error> {
309    let mut files = Vec::new();
310
311    for dir in dirs {
312        debug!("Looking for files in {dir:?}");
313        if !dir.exists() {
314            info!("Skipping directory {dir:?} as it does not exist.");
315            continue;
316        }
317
318        for entry in read_dir(dir).map_err(|source| Error::IoPath {
319            path: dir.clone(),
320            context: "reading entries in directory".to_string(),
321            source,
322        })? {
323            let entry = entry.map_err(|source| Error::IoPath {
324                path: dir.clone(),
325                context: "reading an entry of the directory".to_string(),
326                source,
327            })?;
328
329            let file_path = entry.path();
330            if file_path.is_file()
331                && file_path
332                    .extension()
333                    .is_some_and(|ext| ext.to_str() == Some(extension))
334            {
335                files.push(file_path);
336            }
337        }
338    }
339
340    Ok(files)
341}
342
343#[cfg(test)]
344mod tests {
345    use std::{
346        collections::HashSet,
347        fs::{OpenOptions, create_dir},
348    };
349
350    use rstest::rstest;
351    use strum::IntoEnumIterator;
352    use testresult::TestResult;
353
354    use super::*;
355
356    const PKG_NAMES: &[&str] = &[
357        "pipewire-alsa-1:1.0.7-1-x86_64",
358        "xorg-xvinfo-1.1.5-1-x86_64",
359        "acl-2.3.2-1-x86_64",
360        "archlinux-keyring-20240520-1-any",
361    ];
362
363    /// Ensure that files can be found in case they're nested inside
364    /// sub-subdirectories if the directory structure is:
365    /// `target-dir/packages/${pacman-repo}/${package-name}`
366    #[rstest]
367    #[case(TestFileType::BuildInfo)]
368    #[case(TestFileType::PackageInfo)]
369    #[case(TestFileType::MTree)]
370    fn test_find_files_for_packages(#[case] file_type: TestFileType) -> TestResult {
371        // Create a temporary directory for testing.
372        let tmp_dir = tempfile::tempdir()?;
373        let packages_dir = tmp_dir.path().join(PACKAGES_DIR);
374        create_dir(&packages_dir)?;
375
376        // The list of files we're expecting to find.
377        let mut expected_files = HashSet::new();
378
379        // Create a test file for each repo.
380        for (index, repo) in PackageRepositories::iter().enumerate() {
381            // Create the repository folder
382            let repo_dir = packages_dir.join(repo.to_string());
383            create_dir(&repo_dir)?;
384
385            // Create a package subfolder inside that repository folder.
386            let pkg = PKG_NAMES[index];
387            let pkg_dir = repo_dir.join(pkg);
388            create_dir(&pkg_dir)?;
389
390            // Touch the file inside the package folder.
391            let file_path = pkg_dir.join(file_type.to_string());
392            OpenOptions::new()
393                .create(true)
394                .write(true)
395                .truncate(true)
396                .open(&file_path)?;
397            expected_files.insert(file_path);
398        }
399
400        // Run the logic to find the files in question.
401        let runner = TestRunner {
402            cache_dir: CacheDir::from(tmp_dir.path().to_owned()),
403            file_type,
404            repositories: PackageRepositories::iter().collect(),
405        };
406        let found_files = HashSet::from_iter(runner.find_files_of_type()?.into_iter());
407
408        assert_eq!(
409            found_files, expected_files,
410            "Expected that all created package files are also found."
411        );
412
413        Ok(())
414    }
415
416    /// Ensure that files can be found in case they're nested inside
417    /// sub-subdirectories if the directory structure is:
418    /// `target-dir/databases/${pacman-repo}/${package-name}`
419    #[rstest]
420    #[case(TestFileType::RemoteFiles)]
421    #[case(TestFileType::RemoteDesc)]
422    fn test_find_files_for_databases(#[case] file_type: TestFileType) -> TestResult {
423        // Create a temporary directory for testing.
424        let tmp_dir = tempfile::tempdir()?;
425        let databases_dir = tmp_dir.path().join(DATABASES_DIR);
426        create_dir(&databases_dir)?;
427
428        // The list of files we're expecting to find.
429        let mut expected_files = HashSet::new();
430
431        // Create a test file for each repo.
432        for (index, repo) in PackageRepositories::iter().enumerate() {
433            // Create the repository folder
434            let repo_dir = databases_dir.join(repo.to_string());
435            create_dir(&repo_dir)?;
436
437            // Create a package subfolder inside that repository folder.
438            let pkg = PKG_NAMES[index];
439            let pkg_dir = repo_dir.join(pkg);
440            create_dir(&pkg_dir)?;
441
442            // Touch the file inside the package folder.
443            let file_path = pkg_dir.join(file_type.to_string());
444            OpenOptions::new()
445                .create(true)
446                .write(true)
447                .truncate(true)
448                .open(&file_path)?;
449            expected_files.insert(file_path);
450        }
451
452        // Run the logic to find the files in question.
453        let runner = TestRunner {
454            cache_dir: CacheDir::from(tmp_dir.path().to_owned()),
455            file_type,
456            repositories: PackageRepositories::iter().collect(),
457        };
458        let found_files = HashSet::from_iter(runner.find_files_of_type()?.into_iter());
459
460        assert_eq!(
461            found_files, expected_files,
462            "Expected that all created databases files are also found."
463        );
464
465        Ok(())
466    }
467
468    /// Ensure that files can be found in case they're nested inside
469    /// sub-subdirectories if the directory structure is:
470    /// `target-dir/pkgsrc/${package-name}`
471    #[rstest]
472    #[case(TestFileType::SrcInfo)]
473    fn test_find_files_for_pkgsrc(#[case] file_type: TestFileType) -> TestResult {
474        // Create a temporary directory for testing.
475        let tmp_dir = tempfile::tempdir()?;
476        let pkgsrc_dir = tmp_dir.path().join(PKGSRC_DIR);
477        create_dir(&pkgsrc_dir)?;
478
479        // The list of files we're expecting to find.
480        let mut expected_files = HashSet::new();
481
482        // Create one subdirectory for each package name.
483        // Then create the file in question for that package.
484        for pkg in PKG_NAMES {
485            let pkg_dir = pkgsrc_dir.join(pkg);
486            create_dir(&pkg_dir)?;
487
488            // Touch the file inside the package folder.
489            let file_path = pkg_dir.join(file_type.to_string());
490            OpenOptions::new()
491                .create(true)
492                .write(true)
493                .truncate(true)
494                .open(&file_path)?;
495            expected_files.insert(file_path);
496        }
497
498        // Run the logic to find the files in question.
499        let runner = TestRunner {
500            cache_dir: CacheDir::from(tmp_dir.path().to_owned()),
501            file_type,
502            repositories: PackageRepositories::iter().collect(),
503        };
504        let found_files = HashSet::from_iter(runner.find_files_of_type()?.into_iter());
505
506        assert_eq!(
507            found_files, expected_files,
508            "Expected that all created pkgsrc files are also found."
509        );
510
511        Ok(())
512    }
513}