Skip to main content

provenant/assembly/
file_ref_resolve.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10
11use crate::models::{DatasourceId, FileInfo, Package, PackageData, TopLevelDependency};
12
13use super::path_identity::scanned_path;
14use packageurl::PackageUrl;
15use strum::EnumIter;
16
17struct DbPathConfig {
18    datasource_ids: &'static [DatasourceId],
19    path_suffix: &'static str,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, EnumIter)]
23enum FileReferenceResolverKind {
24    About,
25    AttachedManifest,
26    CondaMeta,
27    DebianExtractedDeb,
28    InstalledDb,
29    PythonMetadata,
30    RelativeToDatafileParent,
31}
32
33struct FileReferenceResolverConfig {
34    datasource_ids: &'static [DatasourceId],
35    kind: FileReferenceResolverKind,
36}
37
38const DB_PATH_CONFIGS: &[DbPathConfig] = &[
39    DbPathConfig {
40        datasource_ids: &[DatasourceId::AlpineInstalledDb],
41        path_suffix: "lib/apk/db/installed",
42    },
43    DbPathConfig {
44        datasource_ids: &[DatasourceId::RpmInstalledDatabaseBdb],
45        path_suffix: "var/lib/rpm/Packages",
46    },
47    DbPathConfig {
48        datasource_ids: &[DatasourceId::RpmInstalledDatabaseBdb],
49        path_suffix: "usr/lib/sysimage/rpm/Packages",
50    },
51    DbPathConfig {
52        datasource_ids: &[DatasourceId::RpmInstalledDatabaseNdb],
53        path_suffix: "var/lib/rpm/Packages.db",
54    },
55    DbPathConfig {
56        datasource_ids: &[DatasourceId::RpmInstalledDatabaseNdb],
57        path_suffix: "usr/lib/sysimage/rpm/Packages.db",
58    },
59    DbPathConfig {
60        datasource_ids: &[DatasourceId::RpmInstalledDatabaseSqlite],
61        path_suffix: "usr/lib/sysimage/rpm/rpmdb.sqlite",
62    },
63    DbPathConfig {
64        datasource_ids: &[DatasourceId::RpmInstalledDatabaseSqlite],
65        path_suffix: "var/lib/rpm/rpmdb.sqlite",
66    },
67    DbPathConfig {
68        datasource_ids: &[DatasourceId::RpmMarinerManifest],
69        path_suffix: "var/lib/rpmmanifest/container-manifest-2",
70    },
71    DbPathConfig {
72        datasource_ids: &[DatasourceId::DebianInstalledStatusDb],
73        path_suffix: "var/lib/dpkg/status",
74    },
75    DbPathConfig {
76        datasource_ids: &[DatasourceId::DebianDistrolessInstalledDb],
77        path_suffix: "var/lib/dpkg/status.d/",
78    },
79];
80
81const RPM_DATASOURCE_IDS: &[DatasourceId] = &[
82    DatasourceId::RpmInstalledDatabaseBdb,
83    DatasourceId::RpmInstalledDatabaseNdb,
84    DatasourceId::RpmInstalledDatabaseSqlite,
85    DatasourceId::RpmMarinerManifest,
86];
87const RPM_YUMDB_PATH_SUFFIX: &str = "var/lib/yum/yumdb/";
88const CONDA_META_PATH_SEGMENT: &str = "conda-meta/";
89const PYTHON_METADATA_DATASOURCE_IDS: &[DatasourceId] = &[
90    DatasourceId::PypiWheelMetadata,
91    DatasourceId::PypiSdistPkginfo,
92    DatasourceId::PypiEggPkginfo,
93    DatasourceId::PypiEditableEggPkginfo,
94];
95const PYTHON_SITE_PACKAGES_SEGMENTS: &[&str] = &["site-packages/", "dist-packages/"];
96const DEBIAN_INSTALLED_SUPPLEMENTAL_DATASOURCE_IDS: &[DatasourceId] = &[
97    DatasourceId::DebianInstalledFilesList,
98    DatasourceId::DebianInstalledMd5Sums,
99];
100const RPM_INSTALLED_SUPPLEMENTAL_DATASOURCE_IDS: &[DatasourceId] =
101    &[DatasourceId::RpmPackageLicenses];
102
103const INSTALLED_DB_DATASOURCE_IDS: &[DatasourceId] = &[
104    DatasourceId::AlpineInstalledDb,
105    DatasourceId::RpmInstalledDatabaseBdb,
106    DatasourceId::RpmInstalledDatabaseNdb,
107    DatasourceId::RpmInstalledDatabaseSqlite,
108    DatasourceId::RpmMarinerManifest,
109    DatasourceId::DebianInstalledStatusDb,
110    DatasourceId::DebianDistrolessInstalledDb,
111];
112
113const FILE_REFERENCE_RESOLVER_CONFIGS: &[FileReferenceResolverConfig] = &[
114    FileReferenceResolverConfig {
115        datasource_ids: &[DatasourceId::AboutFile],
116        kind: FileReferenceResolverKind::About,
117    },
118    FileReferenceResolverConfig {
119        datasource_ids: &[DatasourceId::CpanManifest],
120        kind: FileReferenceResolverKind::AttachedManifest,
121    },
122    FileReferenceResolverConfig {
123        datasource_ids: &[DatasourceId::CondaMetaJson],
124        kind: FileReferenceResolverKind::CondaMeta,
125    },
126    FileReferenceResolverConfig {
127        datasource_ids: &[DatasourceId::DebianMd5SumsInExtractedDeb],
128        kind: FileReferenceResolverKind::DebianExtractedDeb,
129    },
130    FileReferenceResolverConfig {
131        datasource_ids: INSTALLED_DB_DATASOURCE_IDS,
132        kind: FileReferenceResolverKind::InstalledDb,
133    },
134    FileReferenceResolverConfig {
135        datasource_ids: PYTHON_METADATA_DATASOURCE_IDS,
136        kind: FileReferenceResolverKind::PythonMetadata,
137    },
138    FileReferenceResolverConfig {
139        datasource_ids: &[
140            DatasourceId::GradleModule,
141            DatasourceId::BitbakeRecipe,
142            DatasourceId::BitbakeRecipeAppend,
143        ],
144        kind: FileReferenceResolverKind::RelativeToDatafileParent,
145    },
146];
147
148struct PythonMetadataResolution {
149    base_path: String,
150    allowed_root: String,
151}
152
153pub fn resolve_file_references(
154    files: &mut [FileInfo],
155    packages: &mut [Package],
156    dependencies: &mut [TopLevelDependency],
157) {
158    if packages.is_empty() || !has_relevant_file_reference_inputs(files) {
159        return;
160    }
161
162    let path_index = build_path_index(&*files);
163
164    for package in packages.iter_mut() {
165        let Some(config) = find_file_reference_resolver(files, package) else {
166            continue;
167        };
168
169        match config.kind {
170            FileReferenceResolverKind::About
171            | FileReferenceResolverKind::RelativeToDatafileParent => {
172                resolve_relative_to_datafile_parent(
173                    files,
174                    &path_index,
175                    package,
176                    config.datasource_ids,
177                );
178            }
179            FileReferenceResolverKind::AttachedManifest => {
180                resolve_attached_manifest_file_references(
181                    files,
182                    &path_index,
183                    package,
184                    config.datasource_ids[0],
185                );
186            }
187            FileReferenceResolverKind::CondaMeta => {
188                resolve_conda_file_references(files, &path_index, package);
189            }
190            FileReferenceResolverKind::DebianExtractedDeb => {
191                resolve_debian_extracted_deb_file_references(files, &path_index, package)
192            }
193            FileReferenceResolverKind::InstalledDb => {
194                resolve_installed_db_file_references(files, &path_index, package, dependencies);
195            }
196            FileReferenceResolverKind::PythonMetadata => {
197                resolve_python_metadata_file_references(files, &path_index, package);
198            }
199        }
200    }
201}
202
203pub(super) fn has_relevant_file_reference_datasource_ids(
204    file_datasource_ids: &HashSet<DatasourceId>,
205) -> bool {
206    FILE_REFERENCE_RESOLVER_CONFIGS.iter().any(|config| {
207        config
208            .datasource_ids
209            .iter()
210            .any(|datasource_id| file_datasource_ids.contains(datasource_id))
211    })
212}
213
214fn has_relevant_file_reference_inputs(files: &[FileInfo]) -> bool {
215    let file_datasource_ids: HashSet<DatasourceId> = files
216        .iter()
217        .flat_map(|file| {
218            file.package_data
219                .iter()
220                .filter_map(|package_data| package_data.datasource_id)
221        })
222        .collect();
223
224    has_relevant_file_reference_datasource_ids(&file_datasource_ids)
225}
226
227fn resolve_relative_to_datafile_parent(
228    files: &mut [FileInfo],
229    path_index: &HashMap<String, usize>,
230    package: &mut Package,
231    datasource_ids: &[DatasourceId],
232) {
233    let mut missing_refs = Vec::new();
234    for datafile_path in &package.datafile_paths {
235        let root = Path::new(datafile_path)
236            .parent()
237            .map(|p| p.to_string_lossy().to_string())
238            .unwrap_or_default();
239
240        let file_references = collect_file_references(
241            files,
242            path_index,
243            datafile_path,
244            &package.datasource_ids,
245            datasource_ids,
246            package.purl.as_deref(),
247        );
248
249        for file_ref in &file_references {
250            let resolved_path = if root.is_empty() {
251                file_ref.path.clone()
252            } else {
253                format!("{}/{}", root, file_ref.path.trim_start_matches('/'))
254            };
255            if let Some(file_idx) = path_index_lookup(path_index, &resolved_path) {
256                let package_uid = package.package_uid.clone();
257                if !files[file_idx].for_packages.contains(&package_uid) {
258                    files[file_idx].for_packages.push(package_uid);
259                }
260            } else {
261                missing_refs.push(file_ref.path.clone());
262            }
263        }
264    }
265
266    record_missing_file_references(package, missing_refs);
267}
268
269fn resolve_attached_manifest_file_references(
270    files: &mut [FileInfo],
271    path_index: &HashMap<String, usize>,
272    package: &mut Package,
273    datasource_id: DatasourceId,
274) {
275    let Some((datafile_path, file_references)) =
276        find_attached_manifest_file_references(files, package, datasource_id)
277    else {
278        return;
279    };
280
281    let root = Path::new(datafile_path)
282        .parent()
283        .map(|p| p.to_string_lossy().to_string())
284        .unwrap_or_default();
285
286    let mut missing_refs = Vec::new();
287    for file_ref in &file_references {
288        let resolved_path = if root.is_empty() {
289            file_ref.path.clone()
290        } else {
291            format!("{}/{}", root, file_ref.path.trim_start_matches('/'))
292        };
293
294        if let Some(file_idx) = path_index_lookup(path_index, &resolved_path) {
295            let package_uid = package.package_uid.clone();
296            if !files[file_idx].for_packages.contains(&package_uid) {
297                files[file_idx].for_packages.push(package_uid);
298            }
299        } else {
300            missing_refs.push(file_ref.path.clone());
301        }
302    }
303
304    record_missing_file_references(package, missing_refs);
305}
306
307fn resolve_conda_file_references(
308    files: &mut [FileInfo],
309    path_index: &HashMap<String, usize>,
310    package: &mut Package,
311) {
312    let Some(conda_meta_path) = package
313        .datafile_paths
314        .iter()
315        .find(|path| path.contains(CONDA_META_PATH_SEGMENT))
316    else {
317        return;
318    };
319    let Some(root) = compute_conda_root(Some(conda_meta_path.as_str())) else {
320        return;
321    };
322
323    let file_references = collect_file_references(
324        files,
325        path_index,
326        conda_meta_path,
327        &package.datasource_ids,
328        &[DatasourceId::CondaMetaJson],
329        package.purl.as_deref(),
330    );
331
332    let mut missing_refs = Vec::new();
333    for file_ref in &file_references {
334        let resolved_path = format!("{}{}", root, file_ref.path.trim_start_matches('/'));
335        if let Some(file_idx) = path_index_lookup(path_index, &resolved_path) {
336            let package_uid = package.package_uid.clone();
337            if !files[file_idx].for_packages.contains(&package_uid) {
338                files[file_idx].for_packages.push(package_uid);
339            }
340        } else {
341            missing_refs.push(file_ref.path.clone());
342        }
343    }
344
345    record_missing_file_references(package, missing_refs);
346}
347
348fn resolve_installed_db_file_references(
349    files: &mut [FileInfo],
350    path_index: &HashMap<String, usize>,
351    package: &mut Package,
352    dependencies: &mut [TopLevelDependency],
353) {
354    let Some(config) = find_db_config(package) else {
355        return;
356    };
357    let Some(datafile_path) = package.datafile_paths.first() else {
358        return;
359    };
360
361    let root = compute_root(datafile_path, config.path_suffix);
362
363    let mut file_references = collect_file_references(
364        files,
365        path_index,
366        datafile_path,
367        &package.datasource_ids,
368        config.datasource_ids,
369        package.purl.as_deref(),
370    );
371
372    if is_debian_installed_package(package) {
373        merge_file_references(
374            &mut file_references,
375            collect_debian_installed_file_references(files, package),
376        );
377    }
378
379    if is_rpm_package(package) {
380        merge_rpm_installed_supplemental_package_data(files, package, dependencies, &root);
381    }
382
383    let mut missing_refs = Vec::new();
384    for file_ref in &file_references {
385        let ref_path = file_ref.path.trim_start_matches('/');
386        let resolved_path = if root.is_empty() {
387            ref_path.to_string()
388        } else {
389            format!("{}{}", root, ref_path)
390        };
391
392        if let Some(file_idx) = path_index_lookup(path_index, &resolved_path) {
393            let package_uid = package.package_uid.clone();
394            if !files[file_idx].for_packages.contains(&package_uid) {
395                files[file_idx].for_packages.push(package_uid);
396            }
397        } else {
398            missing_refs.push(file_ref.path.clone());
399        }
400    }
401
402    record_missing_file_references(package, missing_refs);
403
404    if is_rpm_package(package)
405        && let Some(namespace) = resolve_rpm_namespace(files, path_index, &root)
406    {
407        apply_rpm_namespace(files, package, dependencies, &namespace);
408    }
409}
410
411fn resolve_debian_extracted_deb_file_references(
412    files: &mut [FileInfo],
413    path_index: &HashMap<String, usize>,
414    package: &mut Package,
415) {
416    let Some(datafile_path) = package
417        .datafile_paths
418        .iter()
419        .find(|path| path.ends_with("/md5sums"))
420    else {
421        return;
422    };
423
424    let Some(md5sums_parent) = Path::new(datafile_path).parent() else {
425        return;
426    };
427    let Some(extracted_root) = md5sums_parent.parent() else {
428        return;
429    };
430    let root = extracted_root.to_string_lossy().to_string();
431
432    let Some(file_idx) = path_index_lookup(path_index, datafile_path) else {
433        return;
434    };
435    let file_references: Vec<_> = files[file_idx]
436        .package_data
437        .iter()
438        .filter(|pkg_data| {
439            pkg_data.datasource_id == Some(DatasourceId::DebianMd5SumsInExtractedDeb)
440        })
441        .flat_map(|pkg_data| pkg_data.file_references.clone())
442        .collect();
443
444    let mut missing_refs = Vec::new();
445    for file_ref in &file_references {
446        let resolved_path = if root.is_empty() {
447            file_ref.path.trim_start_matches('/').to_string()
448        } else {
449            format!("{}/{}", root, file_ref.path.trim_start_matches('/'))
450        };
451
452        if let Some(file_idx) = path_index_lookup(path_index, &resolved_path) {
453            let package_uid = package.package_uid.clone();
454            if !files[file_idx].for_packages.contains(&package_uid) {
455                files[file_idx].for_packages.push(package_uid);
456            }
457        } else {
458            missing_refs.push(file_ref.path.clone());
459        }
460    }
461
462    record_missing_file_references(package, missing_refs);
463}
464
465fn resolve_python_metadata_file_references(
466    files: &mut [FileInfo],
467    path_index: &HashMap<String, usize>,
468    package: &mut Package,
469) {
470    let Some(python_resolution) = find_python_metadata_root(package) else {
471        return;
472    };
473    let Some(datafile_path) = package
474        .datafile_paths
475        .iter()
476        .find(|path| is_python_metadata_layout(path))
477    else {
478        return;
479    };
480
481    let file_references = collect_file_references(
482        files,
483        path_index,
484        datafile_path,
485        &package.datasource_ids,
486        PYTHON_METADATA_DATASOURCE_IDS,
487        package.purl.as_deref(),
488    );
489
490    let mut missing_refs = Vec::new();
491    for file_ref in &file_references {
492        let Some(resolved_path) = normalize_relative_path(
493            &python_resolution.base_path,
494            &python_resolution.allowed_root,
495            &file_ref.path,
496        ) else {
497            missing_refs.push(file_ref.path.clone());
498            continue;
499        };
500
501        if let Some(file_idx) = path_index_lookup(path_index, &resolved_path) {
502            let package_uid = package.package_uid.clone();
503            if !files[file_idx].for_packages.contains(&package_uid) {
504                files[file_idx].for_packages.push(package_uid);
505            }
506        } else {
507            missing_refs.push(file_ref.path.clone());
508        }
509    }
510
511    record_missing_file_references(package, missing_refs);
512}
513
514fn record_missing_file_references(package: &mut Package, mut missing_refs: Vec<String>) {
515    if missing_refs.is_empty() {
516        return;
517    }
518
519    missing_refs.sort();
520    let missing_refs_json: Vec<serde_json::Value> = missing_refs
521        .into_iter()
522        .map(|path| serde_json::json!({"path": path}))
523        .collect();
524
525    let extra_data = package.extra_data.get_or_insert_with(HashMap::new);
526    extra_data.insert(
527        "missing_file_references".to_string(),
528        serde_json::Value::Array(missing_refs_json),
529    );
530}
531
532fn find_file_reference_resolver(
533    files: &[FileInfo],
534    package: &Package,
535) -> Option<&'static FileReferenceResolverConfig> {
536    FILE_REFERENCE_RESOLVER_CONFIGS
537        .iter()
538        .find(|config| match config.kind {
539            FileReferenceResolverKind::AttachedManifest => {
540                config.datasource_ids.iter().any(|datasource_id| {
541                    files.iter().any(|file| {
542                        file.for_packages.contains(&package.package_uid)
543                            && file
544                                .package_data
545                                .iter()
546                                .any(|pkg_data| pkg_data.datasource_id == Some(*datasource_id))
547                    })
548                })
549            }
550            _ => config
551                .datasource_ids
552                .iter()
553                .any(|datasource_id| package.datasource_ids.contains(datasource_id)),
554        })
555}
556
557fn is_python_metadata_layout(path: &str) -> bool {
558    path.ends_with("/METADATA") || path.ends_with("/PKG-INFO")
559}
560
561fn find_python_metadata_root(package: &Package) -> Option<PythonMetadataResolution> {
562    let datafile_path = package
563        .datafile_paths
564        .iter()
565        .find(|path| is_python_metadata_layout(path))?;
566
567    if !package
568        .datasource_ids
569        .iter()
570        .any(|datasource_id| PYTHON_METADATA_DATASOURCE_IDS.contains(datasource_id))
571    {
572        return None;
573    }
574
575    for segment in PYTHON_SITE_PACKAGES_SEGMENTS {
576        if let Some(idx) = datafile_path.rfind(segment) {
577            if datafile_path.ends_with("/METADATA") {
578                let root_end = idx + segment.len();
579                let root = datafile_path[..root_end].to_string();
580                return Some(PythonMetadataResolution {
581                    base_path: root.clone(),
582                    allowed_root: root,
583                });
584            }
585
586            if datafile_path.ends_with("/PKG-INFO") {
587                let parent = Path::new(datafile_path).parent()?;
588                let allowed_root = datafile_path[..idx + segment.len()].to_string();
589                return Some(PythonMetadataResolution {
590                    base_path: parent.to_string_lossy().to_string(),
591                    allowed_root,
592                });
593            }
594        }
595    }
596
597    if datafile_path.ends_with(".egg-info/PKG-INFO") {
598        let metadata_parent = Path::new(datafile_path).parent()?;
599        let project_root = metadata_parent.parent()?;
600        let project_root = project_root.to_string_lossy().to_string();
601        return Some(PythonMetadataResolution {
602            base_path: project_root.clone(),
603            allowed_root: project_root,
604        });
605    }
606
607    None
608}
609
610fn normalize_relative_path(base: &str, allowed_root: &str, relative: &str) -> Option<String> {
611    let joined = Path::new(base).join(relative.trim_start_matches('/'));
612    let normalized = normalize_file_ref_path(&joined);
613    let allowed = normalize_file_ref_path(Path::new(allowed_root));
614
615    let normalized_str = normalized.to_string_lossy().to_string();
616    if normalized.starts_with(&allowed) {
617        Some(normalized_str)
618    } else {
619        None
620    }
621}
622
623/// Lexically collapse `.` / `..` for file-reference joins.
624///
625/// Unlike assembly [`super::path_identity::normalize_lexical_path`], unresolved
626/// `..` components are discarded (via `pop` on an empty path) so references
627/// cannot escape the allowed root by over-escaping.
628fn normalize_file_ref_path(path: &Path) -> PathBuf {
629    let mut normalized = PathBuf::new();
630    for component in path.components() {
631        match component {
632            std::path::Component::CurDir => {}
633            std::path::Component::ParentDir => {
634                normalized.pop();
635            }
636            other => normalized.push(other.as_os_str()),
637        }
638    }
639    normalized
640}
641
642fn compute_conda_root(datafile_path: Option<&str>) -> Option<String> {
643    let path = datafile_path?;
644    let idx = path.rfind(CONDA_META_PATH_SEGMENT)?;
645    Some(path[..idx].to_string())
646}
647
648pub fn merge_rpm_yumdb_metadata(files: &mut [FileInfo], packages: &mut Vec<Package>) {
649    let yumdb_indices: Vec<usize> = packages
650        .iter()
651        .enumerate()
652        .filter_map(|(idx, package)| {
653            package
654                .datasource_ids
655                .contains(&DatasourceId::RpmYumdb)
656                .then_some(idx)
657        })
658        .collect();
659    let mut removal_indices = Vec::new();
660
661    for yumdb_idx in yumdb_indices {
662        let yumdb_package = packages[yumdb_idx].clone();
663        let Some(yumdb_path) = yumdb_package.datafile_paths.first() else {
664            continue;
665        };
666        let yumdb_root = compute_root(yumdb_path, RPM_YUMDB_PATH_SUFFIX);
667        let yumdb_arch = yumdb_package
668            .qualifiers
669            .as_ref()
670            .and_then(|qualifiers| qualifiers.get("arch"));
671
672        let Some(target_idx) = packages.iter().enumerate().find_map(|(idx, package)| {
673            if idx == yumdb_idx || !is_rpm_package(package) {
674                return None;
675            }
676
677            let config = find_db_config(package)?;
678            let datafile_path = package.datafile_paths.first()?;
679            let target_root = compute_root(datafile_path, config.path_suffix);
680            let target_arch = package
681                .qualifiers
682                .as_ref()
683                .and_then(|qualifiers| qualifiers.get("arch"));
684
685            (target_root == yumdb_root
686                && package.name == yumdb_package.name
687                && rpm_yumdb_versions_match(&package.version, &yumdb_package.version)
688                && target_arch == yumdb_arch)
689                .then_some(idx)
690        }) else {
691            continue;
692        };
693
694        let target_package_uid = packages[target_idx].package_uid.clone();
695        {
696            let target = &mut packages[target_idx];
697            target
698                .datafile_paths
699                .extend(yumdb_package.datafile_paths.clone());
700            target
701                .datasource_ids
702                .extend(yumdb_package.datasource_ids.clone());
703
704            if let Some(yumdb_extra) = yumdb_package.extra_data.clone()
705                && !yumdb_extra.is_empty()
706            {
707                let extra_data = target.extra_data.get_or_insert_with(HashMap::new);
708                let mut merged_yumdb = extra_data
709                    .get("yumdb")
710                    .and_then(|value| value.as_object().cloned())
711                    .unwrap_or_default();
712                for (key, value) in yumdb_extra {
713                    merged_yumdb.insert(key, value);
714                }
715                extra_data.insert("yumdb".to_string(), serde_json::Value::Object(merged_yumdb));
716            }
717        }
718
719        for file in files.iter_mut() {
720            for package_uid in &mut file.for_packages {
721                if *package_uid == yumdb_package.package_uid {
722                    *package_uid = target_package_uid.clone();
723                }
724            }
725        }
726
727        removal_indices.push(yumdb_idx);
728    }
729
730    removal_indices.sort_unstable();
731    removal_indices.dedup();
732    for idx in removal_indices.into_iter().rev() {
733        packages.remove(idx);
734    }
735}
736
737fn rpm_yumdb_versions_match(
738    rpmdb_version: &Option<String>,
739    yumdb_version: &Option<String>,
740) -> bool {
741    match (rpmdb_version.as_deref(), yumdb_version.as_deref()) {
742        (Some(rpmdb), Some(yumdb)) => {
743            rpmdb == yumdb
744                || rpmdb
745                    .split_once(':')
746                    .is_some_and(|(_, version)| version == yumdb)
747        }
748        (None, None) => true,
749        _ => false,
750    }
751}
752
753fn build_path_index(files: &[FileInfo]) -> HashMap<String, usize> {
754    files
755        .iter()
756        .enumerate()
757        .map(|(idx, file)| (path_index_key(&file.path), idx))
758        .collect()
759}
760
761fn path_index_key(path: &str) -> String {
762    scanned_path(path).to_string_lossy().into_owned()
763}
764
765fn path_index_lookup(path_index: &HashMap<String, usize>, path: &str) -> Option<usize> {
766    path_index.get(&path_index_key(path)).copied()
767}
768
769fn find_db_config(package: &Package) -> Option<&'static DbPathConfig> {
770    let datafile_paths = &package.datafile_paths;
771
772    for config in DB_PATH_CONFIGS {
773        if !datafile_paths.is_empty()
774            && !datafile_paths
775                .iter()
776                .any(|path| path.ends_with(config.path_suffix))
777        {
778            continue;
779        }
780
781        for &config_dsid in config.datasource_ids {
782            for &pkg_dsid in &package.datasource_ids {
783                if config_dsid == pkg_dsid {
784                    return Some(config);
785                }
786            }
787        }
788    }
789
790    for config in DB_PATH_CONFIGS {
791        for &config_dsid in config.datasource_ids {
792            for &pkg_dsid in &package.datasource_ids {
793                if config_dsid == pkg_dsid {
794                    return Some(config);
795                }
796            }
797        }
798    }
799
800    None
801}
802
803fn compute_root(datafile_path: &str, suffix: &str) -> String {
804    if let Some(pos) = datafile_path.rfind(suffix) {
805        let root = &datafile_path[..pos];
806        if root.is_empty() {
807            String::new()
808        } else {
809            root.to_string()
810        }
811    } else {
812        String::new()
813    }
814}
815
816fn collect_file_references(
817    files: &[FileInfo],
818    path_index: &HashMap<String, usize>,
819    datafile_path: &str,
820    package_datasource_ids: &[DatasourceId],
821    config_datasource_ids: &[DatasourceId],
822    package_purl: Option<&str>,
823) -> Vec<crate::models::FileReference> {
824    let Some(file_idx) = path_index_lookup(path_index, datafile_path) else {
825        return Vec::new();
826    };
827
828    let file = &files[file_idx];
829    let mut refs = Vec::new();
830
831    for pkg_data in &file.package_data {
832        let dsid_matches = pkg_data.datasource_id.is_some_and(|dsid| {
833            package_datasource_ids.contains(&dsid) || config_datasource_ids.contains(&dsid)
834        });
835
836        if !dsid_matches {
837            continue;
838        }
839
840        let purl_matches = match (package_purl, pkg_data.purl.as_deref()) {
841            (Some(pkg_purl), Some(data_purl)) => pkg_purl == data_purl,
842            _ => true,
843        };
844
845        if purl_matches {
846            refs.extend(pkg_data.file_references.clone());
847        }
848    }
849
850    refs
851}
852
853fn is_rpm_package(package: &Package) -> bool {
854    for &dsid in &package.datasource_ids {
855        for &rpm_dsid in RPM_DATASOURCE_IDS {
856            if rpm_dsid == dsid {
857                return true;
858            }
859        }
860    }
861    false
862}
863
864fn is_debian_installed_package(package: &Package) -> bool {
865    package
866        .datasource_ids
867        .contains(&DatasourceId::DebianInstalledStatusDb)
868        || package
869            .datasource_ids
870            .contains(&DatasourceId::DebianDistrolessInstalledDb)
871}
872
873fn collect_debian_installed_file_references(
874    files: &[FileInfo],
875    package: &Package,
876) -> Vec<crate::models::FileReference> {
877    let mut refs = Vec::new();
878
879    for file in files {
880        for pkg_data in &file.package_data {
881            let Some(dsid) = pkg_data.datasource_id else {
882                continue;
883            };
884            if !DEBIAN_INSTALLED_SUPPLEMENTAL_DATASOURCE_IDS.contains(&dsid) {
885                continue;
886            }
887
888            if pkg_data.name != package.name {
889                continue;
890            }
891            if !debian_installed_namespace_matches(&pkg_data.namespace, &package.namespace) {
892                continue;
893            }
894            if !debian_installed_arch_matches(&pkg_data.qualifiers, &package.qualifiers) {
895                continue;
896            }
897
898            merge_file_references(&mut refs, pkg_data.file_references.clone());
899        }
900    }
901
902    refs
903}
904
905fn merge_rpm_installed_supplemental_package_data(
906    files: &mut [FileInfo],
907    package: &mut Package,
908    dependencies: &mut [TopLevelDependency],
909    root: &str,
910) {
911    let mut supplemental_package_data: Vec<(usize, PackageData, String)> = Vec::new();
912    for (file_idx, file) in files.iter().enumerate() {
913        for pkg_data in &file.package_data {
914            let Some(dsid) = pkg_data.datasource_id else {
915                continue;
916            };
917            if !RPM_INSTALLED_SUPPLEMENTAL_DATASOURCE_IDS.contains(&dsid) {
918                continue;
919            }
920            if !rpm_installed_license_file_in_root(&file.path, root) {
921                continue;
922            }
923            if pkg_data.name != package.name {
924                continue;
925            }
926            if !rpm_installed_namespace_matches(&pkg_data.namespace, &package.namespace) {
927                continue;
928            }
929
930            supplemental_package_data.push((file_idx, pkg_data.clone(), file.path.clone()));
931        }
932    }
933
934    for (file_idx, pkg_data, datafile_path) in supplemental_package_data {
935        let old_package_uid = package.package_uid.clone();
936        package.update(&pkg_data, datafile_path);
937        if package.package_uid != old_package_uid {
938            replace_package_uid(files, dependencies, &old_package_uid, &package.package_uid);
939        }
940
941        if !files[file_idx].for_packages.contains(&package.package_uid) {
942            files[file_idx]
943                .for_packages
944                .push(package.package_uid.clone());
945        }
946    }
947}
948
949fn rpm_installed_license_file_in_root(path: &str, root: &str) -> bool {
950    let expected_prefix = format!("{}usr/share/licenses/", root);
951    path.starts_with(&expected_prefix)
952}
953
954fn rpm_installed_namespace_matches(
955    supplemental_namespace: &Option<String>,
956    package_namespace: &Option<String>,
957) -> bool {
958    match (
959        supplemental_namespace.as_deref(),
960        package_namespace.as_deref(),
961    ) {
962        (Some(left), Some(right)) => left == right,
963        _ => true,
964    }
965}
966
967fn replace_package_uid(
968    files: &mut [FileInfo],
969    dependencies: &mut [TopLevelDependency],
970    old_package_uid: &crate::models::PackageUid,
971    new_package_uid: &crate::models::PackageUid,
972) {
973    for file in files.iter_mut() {
974        for package_uid in &mut file.for_packages {
975            if package_uid == old_package_uid {
976                *package_uid = new_package_uid.clone();
977            }
978        }
979    }
980
981    for dep in dependencies.iter_mut() {
982        if dep.for_package_uid.as_ref() == Some(old_package_uid) {
983            dep.for_package_uid = Some(new_package_uid.clone());
984        }
985    }
986}
987
988fn find_attached_manifest_file_references<'a>(
989    files: &'a [FileInfo],
990    package: &Package,
991    datasource_id: DatasourceId,
992) -> Option<(&'a str, Vec<crate::models::FileReference>)> {
993    for file in files {
994        if !file.for_packages.contains(&package.package_uid) {
995            continue;
996        }
997
998        for pkg_data in &file.package_data {
999            if pkg_data.datasource_id == Some(datasource_id) {
1000                return Some((&file.path, pkg_data.file_references.clone()));
1001            }
1002        }
1003    }
1004
1005    None
1006}
1007
1008fn debian_installed_namespace_matches(
1009    supplemental_namespace: &Option<String>,
1010    package_namespace: &Option<String>,
1011) -> bool {
1012    match (
1013        supplemental_namespace.as_deref(),
1014        package_namespace.as_deref(),
1015    ) {
1016        (None, _) => true,
1017        (Some("debian"), Some("ubuntu")) => true,
1018        (Some(left), Some(right)) => left == right,
1019        (Some(_), None) => true,
1020    }
1021}
1022
1023fn debian_installed_arch_matches(
1024    supplemental_qualifiers: &Option<HashMap<String, String>>,
1025    package_qualifiers: &Option<HashMap<String, String>>,
1026) -> bool {
1027    let supplemental_arch = supplemental_qualifiers
1028        .as_ref()
1029        .and_then(|qualifiers| qualifiers.get("arch"));
1030    let package_arch = package_qualifiers
1031        .as_ref()
1032        .and_then(|qualifiers| qualifiers.get("arch"));
1033
1034    match (supplemental_arch, package_arch) {
1035        (Some(left), Some(right)) => left == right,
1036        (Some(_), None) => false,
1037        _ => true,
1038    }
1039}
1040
1041fn merge_file_references(
1042    target: &mut Vec<crate::models::FileReference>,
1043    incoming: Vec<crate::models::FileReference>,
1044) {
1045    for file_ref in incoming {
1046        if let Some(existing) = target
1047            .iter_mut()
1048            .find(|existing| existing.path == file_ref.path)
1049        {
1050            if existing.size.is_none() {
1051                existing.size = file_ref.size;
1052            }
1053            if existing.sha1.is_none() {
1054                existing.sha1 = file_ref.sha1;
1055            }
1056            if existing.md5.is_none() {
1057                existing.md5 = file_ref.md5;
1058            }
1059            if existing.sha256.is_none() {
1060                existing.sha256 = file_ref.sha256;
1061            }
1062            if existing.sha512.is_none() {
1063                existing.sha512 = file_ref.sha512;
1064            }
1065            if existing.extra_data.is_none() {
1066                existing.extra_data = file_ref.extra_data.clone();
1067            }
1068        } else {
1069            target.push(file_ref);
1070        }
1071    }
1072}
1073
1074fn resolve_rpm_namespace(
1075    files: &[FileInfo],
1076    path_index: &HashMap<String, usize>,
1077    root: &str,
1078) -> Option<String> {
1079    let os_release_paths = [
1080        format!("{}etc/os-release", root),
1081        format!("{}usr/lib/os-release", root),
1082    ];
1083
1084    for os_release_path in &os_release_paths {
1085        if let Some(file_idx) = path_index_lookup(path_index, os_release_path) {
1086            let file = &files[file_idx];
1087            for pkg_data in &file.package_data {
1088                if pkg_data.datasource_id == Some(DatasourceId::EtcOsRelease)
1089                    && let Some(namespace) = &pkg_data.namespace
1090                {
1091                    return Some(namespace.clone());
1092                }
1093            }
1094        }
1095    }
1096
1097    None
1098}
1099
1100fn rewrite_purl_namespace(existing_purl: &str, namespace: &str) -> Option<String> {
1101    let parsed = PackageUrl::from_str(existing_purl).ok()?;
1102    let mut updated = PackageUrl::new(parsed.ty(), parsed.name()).ok()?;
1103
1104    updated.with_namespace(namespace).ok()?;
1105
1106    if let Some(version) = parsed.version() {
1107        updated.with_version(version).ok()?;
1108    }
1109
1110    if let Some(subpath) = parsed.subpath() {
1111        updated.with_subpath(subpath).ok()?;
1112    }
1113
1114    for (key, value) in parsed.qualifiers() {
1115        updated
1116            .add_qualifier(key.to_string(), value.to_string())
1117            .ok()?;
1118    }
1119
1120    Some(updated.to_string())
1121}
1122
1123fn apply_rpm_namespace(
1124    files: &mut [FileInfo],
1125    package: &mut Package,
1126    dependencies: &mut [TopLevelDependency],
1127    namespace: &str,
1128) {
1129    if package.namespace.is_some() {
1130        return;
1131    }
1132
1133    let old_package_uid = package.package_uid.clone();
1134
1135    package.namespace = Some(namespace.to_string());
1136
1137    if let Some(current_purl) = package.purl.as_deref()
1138        && let Some(updated_purl) = rewrite_purl_namespace(current_purl, namespace)
1139    {
1140        package.purl = Some(updated_purl.clone());
1141        package.package_uid = old_package_uid.replace_base(&updated_purl);
1142    }
1143
1144    replace_package_uid(files, dependencies, &old_package_uid, &package.package_uid);
1145
1146    for dep in dependencies.iter_mut() {
1147        if dep.for_package_uid.as_ref() == Some(&package.package_uid) {
1148            dep.namespace = Some(namespace.to_string());
1149
1150            if let Some(current_purl) = dep.purl.as_deref()
1151                && let Some(updated_purl) = rewrite_purl_namespace(current_purl, namespace)
1152            {
1153                dep.purl = Some(updated_purl.clone());
1154                dep.dependency_uid = dep.dependency_uid.replace_base(&updated_purl);
1155            }
1156        }
1157    }
1158}
1159
1160#[cfg(test)]
1161#[path = "file_ref_resolve_test.rs"]
1162mod tests;