Skip to main content

provenant/assembly/
file_ref_resolve.rs

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