Skip to main content

provenant/assembly/
mod.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6mod assemblers;
7#[cfg(test)]
8mod assembly_test;
9mod bazel_merge;
10mod bazel_prune;
11mod cargo_resource_assign;
12mod cargo_workspace_merge;
13mod composer_resource_assign;
14mod conda_rootfs_merge;
15mod debian_source_merge;
16pub mod file_ref_resolve;
17mod hackage_merge;
18mod huggingface_merge;
19mod nested_merge;
20mod nix_flake_compat_merge;
21mod npm_resource_assign;
22mod npm_workspace_merge;
23mod nuget_cpm_resolve;
24mod python_requirements_assign;
25mod ruby_resource_assign;
26mod sibling_merge;
27mod swift_merge;
28mod topology;
29mod windows_update_merge;
30
31use std::collections::{HashMap, HashSet};
32use std::path::PathBuf;
33use std::sync::LazyLock;
34
35use crate::models::{DatasourceId, FileInfo, Package, PackageUid, TopLevelDependency};
36
37pub use assemblers::ASSEMBLERS;
38
39type DirectoryMergeOutput = (Option<Package>, Vec<TopLevelDependency>, Vec<usize>);
40
41/// Pre-computed lookup: DatasourceId → config key (first DatasourceId in config).
42/// Built once on first use, avoiding HashMap allocation on every `assemble()` call.
43static ASSEMBLER_LOOKUP: LazyLock<HashMap<DatasourceId, DatasourceId>> = LazyLock::new(|| {
44    let mut lookup = HashMap::new();
45    for config in ASSEMBLERS {
46        let key = *config
47            .datasource_ids
48            .first()
49            .expect("assembler must have at least one datasource_id");
50        for &dsid in config.datasource_ids {
51            lookup.insert(dsid, key);
52        }
53    }
54    lookup
55});
56
57static ASSEMBLER_CONFIG_LOOKUP: LazyLock<HashMap<DatasourceId, &'static AssemblerConfig>> =
58    LazyLock::new(|| {
59        let mut lookup = HashMap::new();
60        for config in ASSEMBLERS {
61            let key = *config
62                .datasource_ids
63                .first()
64                .expect("assembler must have at least one datasource_id");
65            lookup.insert(key, config);
66        }
67        lookup
68    });
69
70/// Result of the assembly phase: top-level packages and dependencies,
71/// plus updated file-to-package associations.
72pub struct AssemblyResult {
73    pub packages: Vec<Package>,
74    pub dependencies: Vec<TopLevelDependency>,
75}
76
77/// How an assembler groups PackageData into Packages.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum AssemblyMode {
80    /// Merge related files in the same directory (or nested) into one Package.
81    SiblingMerge,
82    /// Each PackageData becomes its own independent Package (e.g., database files
83    /// containing many installed packages like Alpine DB, RPM DB, Debian status).
84    OnePerPackageData,
85}
86
87pub struct AssemblerConfig {
88    pub datasource_ids: &'static [DatasourceId],
89    pub sibling_file_patterns: &'static [&'static str],
90    pub mode: AssemblyMode,
91}
92
93/// Run the assembly phase over all scanned files.
94///
95/// Groups files by parent directory, finds related manifests/lockfiles,
96/// merges them into top-level `Package` objects, and hoists dependencies.
97/// Updates each `FileInfo.for_packages` with the UIDs of packages it belongs to.
98pub fn assemble(files: &mut [FileInfo]) -> AssemblyResult {
99    let assembler_lookup = &*ASSEMBLER_LOOKUP;
100    let assembler_config_lookup = &*ASSEMBLER_CONFIG_LOOKUP;
101    let mut packages = Vec::new();
102    let mut dependencies = Vec::new();
103
104    let dir_files = group_files_by_directory(files);
105    let topology_plan = topology::TopologyPlan::build(files, &dir_files);
106
107    for file_indices in dir_files.values() {
108        let mut groups: HashSet<DatasourceId> = HashSet::new();
109
110        for &idx in file_indices {
111            for pkg_data in &files[idx].package_data {
112                if let Some(dsid) = pkg_data.datasource_id
113                    && let Some(&config_key) = assembler_lookup.get(&dsid)
114                {
115                    groups.insert(config_key);
116                }
117            }
118        }
119
120        for &config_key in &groups {
121            let config = assembler_config_lookup
122                .get(&config_key)
123                .copied()
124                .expect("assembler config must exist");
125
126            if topology_plan.claims_directory_assembly(config, file_indices, files) {
127                continue;
128            }
129
130            if let Some(special_merger) = assemblers::special_directory_merger_for(config_key) {
131                let results = special_merger.run(config, files, file_indices);
132                apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
133                continue;
134            }
135
136            match config.mode {
137                AssemblyMode::SiblingMerge => {
138                    let results = sibling_merge::assemble_siblings(config, files, file_indices);
139                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
140                }
141                AssemblyMode::OnePerPackageData => {
142                    let results = assemble_one_per_package_data(config, files, file_indices)
143                        .into_iter()
144                        .map(|(pkg, deps, affected_idx)| (Some(pkg), deps, vec![affected_idx]))
145                        .collect();
146                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
147                }
148            }
149        }
150    }
151
152    topology_plan.apply_directory_scoped_domains(files, &mut packages, &mut dependencies);
153
154    for config in ASSEMBLERS {
155        if config.mode != AssemblyMode::SiblingMerge {
156            continue;
157        }
158        if let Some((pkg, deps, affected_indices)) =
159            nested_merge::assemble_nested_patterns(files, config)
160        {
161            let package_uid = pkg.package_uid.clone();
162            let purl = pkg.purl.clone();
163            let removed_package_uids: Vec<PackageUid> = packages
164                .iter()
165                .filter(|p| p.purl == purl)
166                .map(|p| p.package_uid.clone())
167                .collect();
168
169            packages.retain(|p| p.purl != purl);
170            dependencies.retain(|d| {
171                d.for_package_uid.as_ref() != Some(&package_uid)
172                    && !removed_package_uids
173                        .iter()
174                        .any(|old_uid| d.for_package_uid.as_ref() == Some(old_uid))
175            });
176
177            for idx in &affected_indices {
178                files[*idx].for_packages.clear();
179                files[*idx].for_packages.push(package_uid.clone());
180            }
181
182            packages.push(pkg);
183            dependencies.extend(deps);
184        }
185    }
186
187    assemblers::run_post_assembly_passes(files, &mut packages, &mut dependencies, &topology_plan);
188    hoist_unassembled_file_dependencies(files, &mut dependencies);
189
190    for package in &mut packages {
191        package.datafile_paths.sort();
192        package.datafile_paths.dedup();
193        package.datasource_ids.sort_by_key(|left| left.to_string());
194        package.datasource_ids.dedup();
195    }
196
197    for file in files.iter_mut() {
198        file.for_packages
199            .sort_by(|left, right| left.stable_key().cmp(right.stable_key()));
200        file.for_packages.dedup();
201    }
202
203    packages
204        .sort_by(|left, right| stable_package_sort_key(left).cmp(&stable_package_sort_key(right)));
205    dependencies.sort_by(|left, right| {
206        left.purl
207            .as_deref()
208            .cmp(&right.purl.as_deref())
209            .then_with(|| {
210                left.extracted_requirement
211                    .as_deref()
212                    .cmp(&right.extracted_requirement.as_deref())
213            })
214            .then_with(|| left.scope.as_deref().cmp(&right.scope.as_deref()))
215            .then_with(|| left.datafile_path.cmp(&right.datafile_path))
216            .then_with(|| {
217                left.datasource_id
218                    .to_string()
219                    .cmp(&right.datasource_id.to_string())
220            })
221            .then_with(|| {
222                left.for_package_uid
223                    .as_ref()
224                    .map(|uid| uid.stable_key())
225                    .cmp(&right.for_package_uid.as_ref().map(|uid| uid.stable_key()))
226            })
227    });
228
229    AssemblyResult {
230        packages,
231        dependencies,
232    }
233}
234
235fn apply_directory_merge_results(
236    files: &mut [FileInfo],
237    packages: &mut Vec<Package>,
238    dependencies: &mut Vec<TopLevelDependency>,
239    results: Vec<DirectoryMergeOutput>,
240) {
241    for (package, deps, affected_indices) in results {
242        if let Some(package) = package {
243            let package_uid = package.package_uid.clone();
244            for idx in &affected_indices {
245                if !files[*idx].for_packages.contains(&package_uid) {
246                    files[*idx].for_packages.push(package_uid.clone());
247                }
248            }
249            packages.push(package);
250        }
251        dependencies.extend(deps);
252    }
253}
254
255fn hoist_unassembled_file_dependencies(
256    files: &[FileInfo],
257    dependencies: &mut Vec<TopLevelDependency>,
258) {
259    for file in files {
260        if !file.for_packages.is_empty() {
261            continue;
262        }
263
264        for pkg_data in &file.package_data {
265            let Some(datasource_id) = pkg_data.datasource_id else {
266                continue;
267            };
268
269            if !should_hoist_unassembled_dependencies(datasource_id) {
270                continue;
271            }
272
273            dependencies.extend(pkg_data.dependencies.iter().map(|dep| {
274                TopLevelDependency::from_dependency(dep, file.path.clone(), datasource_id, None)
275            }));
276        }
277    }
278}
279
280const HOIST_IF_UNOWNED_DATASOURCE_IDS: &[DatasourceId] = &[DatasourceId::PipRequirements];
281
282fn should_hoist_unassembled_dependencies(datasource_id: DatasourceId) -> bool {
283    if HOIST_IF_UNOWNED_DATASOURCE_IDS.contains(&datasource_id) {
284        return true;
285    }
286
287    if !assemblers::UNASSEMBLED_DATASOURCE_IDS.contains(&datasource_id) {
288        return false;
289    }
290
291    !matches!(
292        datasource_id,
293        DatasourceId::NugetDirectoryBuildProps | DatasourceId::NugetDirectoryPackagesProps
294    )
295}
296
297fn stable_package_sort_key(package: &Package) -> (Option<&str>, Option<&str>, Option<&str>, &str) {
298    (
299        package.purl.as_deref(),
300        package.name.as_deref(),
301        package.version.as_deref(),
302        package
303            .datafile_paths
304            .first()
305            .map(String::as_str)
306            .unwrap_or(""),
307    )
308}
309
310fn assemble_one_per_package_data(
311    config: &AssemblerConfig,
312    files: &[FileInfo],
313    file_indices: &[usize],
314) -> Vec<(Package, Vec<TopLevelDependency>, usize)> {
315    let mut results = Vec::new();
316
317    for &idx in file_indices {
318        let file = &files[idx];
319        for pkg_data in &file.package_data {
320            let dsid_matches = pkg_data
321                .datasource_id
322                .is_some_and(|dsid| config.datasource_ids.contains(&dsid));
323
324            if !dsid_matches
325                || pkg_data.purl.is_none()
326                || should_skip_placeholder_only_cocoapods_podspec(pkg_data)
327            {
328                continue;
329            }
330
331            let datafile_path = file.path.clone();
332            let datasource_id = pkg_data.datasource_id.expect("datasource_id must be Some");
333            let pkg = Package::from_package_data(pkg_data, datafile_path.clone());
334            let for_package_uid = Some(pkg.package_uid.clone());
335
336            let deps: Vec<TopLevelDependency> = pkg_data
337                .dependencies
338                .iter()
339                .filter(|dep| dep.purl.is_some() || dep.extracted_requirement.is_some())
340                .map(|dep| {
341                    TopLevelDependency::from_dependency(
342                        dep,
343                        datafile_path.clone(),
344                        datasource_id,
345                        for_package_uid.clone(),
346                    )
347                })
348                .collect();
349
350            results.push((pkg, deps, idx));
351        }
352    }
353
354    results
355}
356
357pub(super) fn should_skip_placeholder_only_cocoapods_podspec(
358    pkg_data: &crate::models::PackageData,
359) -> bool {
360    pkg_data.datasource_id == Some(DatasourceId::CocoapodsPodspec)
361        && pkg_data
362            .extra_data
363            .as_ref()
364            .and_then(|data| data.get("dynamic_identity_placeholders"))
365            .and_then(|value| value.as_bool())
366            == Some(true)
367}
368
369/// Group file indices by their parent directory path.
370fn group_files_by_directory(files: &[FileInfo]) -> HashMap<PathBuf, Vec<usize>> {
371    let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
372    for (idx, file) in files.iter().enumerate() {
373        if let Some(parent) = std::path::Path::new(&file.path).parent() {
374            groups.entry(parent.to_path_buf()).or_default().push(idx);
375        }
376    }
377    groups
378}