Skip to main content

dev_scripts/
testing.rs

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