Skip to main content

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