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