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