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_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::{BTreeSet, 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 groups = active_config_keys(files, file_indices, assembler_lookup);
109
110        for &config_key in &groups {
111            let config = assembler_config_lookup
112                .get(&config_key)
113                .copied()
114                .expect("assembler config must exist");
115
116            if topology_plan.claims_directory_assembly(config, file_indices, files) {
117                continue;
118            }
119
120            if let Some(special_merger) = assemblers::special_directory_merger_for(config_key) {
121                let results = special_merger.run(config, files, file_indices);
122                apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
123                continue;
124            }
125
126            match config.mode {
127                AssemblyMode::SiblingMerge => {
128                    let results = sibling_merge::assemble_siblings(config, files, file_indices);
129                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
130                }
131                AssemblyMode::OnePerPackageData => {
132                    let results = assemble_one_per_package_data(config, files, file_indices)
133                        .into_iter()
134                        .map(|(pkg, deps, affected_idx)| (Some(pkg), deps, vec![affected_idx]))
135                        .collect();
136                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
137                }
138            }
139        }
140    }
141
142    topology_plan.apply_directory_scoped_domains(files, &mut packages, &mut dependencies);
143
144    for config in ASSEMBLERS {
145        if config.mode != AssemblyMode::SiblingMerge {
146            continue;
147        }
148        if let Some((pkg, deps, affected_indices)) =
149            nested_merge::assemble_nested_patterns(files, config)
150        {
151            let package_uid = pkg.package_uid.clone();
152
153            // The nested merge subsumes exactly the per-directory packages that were
154            // previously created for the files it consumed. Those packages are recorded
155            // on the affected files' `for_packages`, so key dedup off those specific UIDs
156            // rather than a blanket purl match (which would also delete unrelated packages,
157            // including every purl-less package scan-wide when the merged purl is `None`).
158            let removed_package_uids: HashSet<PackageUid> = affected_indices
159                .iter()
160                .flat_map(|idx| files[*idx].for_packages.iter().cloned())
161                .filter(|uid| *uid != package_uid)
162                .collect();
163
164            packages.retain(|p| !removed_package_uids.contains(&p.package_uid));
165            dependencies.retain(|d| {
166                d.for_package_uid
167                    .as_ref()
168                    .is_none_or(|old_uid| !removed_package_uids.contains(old_uid))
169            });
170
171            for idx in &affected_indices {
172                files[*idx].for_packages.clear();
173                files[*idx].for_packages.push(package_uid.clone());
174            }
175
176            packages.push(pkg);
177            dependencies.extend(deps);
178        }
179    }
180
181    assemblers::run_post_assembly_passes(files, &mut packages, &mut dependencies, &topology_plan);
182    hoist_unassembled_file_dependencies(files, &mut dependencies);
183
184    for package in &mut packages {
185        package.datafile_paths.sort();
186        package.datafile_paths.dedup();
187        package.datasource_ids.sort_by_key(|left| left.to_string());
188        package.datasource_ids.dedup();
189    }
190
191    for file in files.iter_mut() {
192        file.for_packages
193            .sort_by(|left, right| left.stable_key().cmp(right.stable_key()));
194        file.for_packages.dedup();
195    }
196
197    packages
198        .sort_by(|left, right| stable_package_sort_key(left).cmp(&stable_package_sort_key(right)));
199    dependencies.sort_by(|left, right| {
200        left.purl
201            .as_deref()
202            .cmp(&right.purl.as_deref())
203            .then_with(|| {
204                left.extracted_requirement
205                    .as_deref()
206                    .cmp(&right.extracted_requirement.as_deref())
207            })
208            .then_with(|| left.scope.as_deref().cmp(&right.scope.as_deref()))
209            .then_with(|| left.datafile_path.cmp(&right.datafile_path))
210            .then_with(|| {
211                left.datasource_id
212                    .to_string()
213                    .cmp(&right.datasource_id.to_string())
214            })
215            .then_with(|| {
216                left.for_package_uid
217                    .as_ref()
218                    .map(|uid| uid.stable_key())
219                    .cmp(&right.for_package_uid.as_ref().map(|uid| uid.stable_key()))
220            })
221    });
222
223    AssemblyResult {
224        packages,
225        dependencies,
226    }
227}
228
229fn apply_directory_merge_results(
230    files: &mut [FileInfo],
231    packages: &mut Vec<Package>,
232    dependencies: &mut Vec<TopLevelDependency>,
233    results: Vec<DirectoryMergeOutput>,
234) {
235    for (package, deps, affected_indices) in results {
236        if let Some(package) = package {
237            let package_uid = package.package_uid.clone();
238            for idx in &affected_indices {
239                if !files[*idx].for_packages.contains(&package_uid) {
240                    files[*idx].for_packages.push(package_uid.clone());
241                }
242            }
243            packages.push(package);
244        }
245        dependencies.extend(deps);
246    }
247}
248
249fn hoist_unassembled_file_dependencies(
250    files: &[FileInfo],
251    dependencies: &mut Vec<TopLevelDependency>,
252) {
253    for file in files {
254        if !file.for_packages.is_empty() {
255            continue;
256        }
257
258        for pkg_data in &file.package_data {
259            let Some(datasource_id) = pkg_data.datasource_id else {
260                continue;
261            };
262
263            if !should_hoist_unassembled_dependencies(datasource_id) {
264                continue;
265            }
266
267            dependencies.extend(pkg_data.dependencies.iter().map(|dep| {
268                TopLevelDependency::from_dependency(dep, file.path.clone(), datasource_id, None)
269            }));
270        }
271    }
272}
273
274const HOIST_IF_UNOWNED_DATASOURCE_IDS: &[DatasourceId] = &[DatasourceId::PipRequirements];
275
276fn should_hoist_unassembled_dependencies(datasource_id: DatasourceId) -> bool {
277    if HOIST_IF_UNOWNED_DATASOURCE_IDS.contains(&datasource_id) {
278        return true;
279    }
280
281    if !assemblers::UNASSEMBLED_DATASOURCE_IDS.contains(&datasource_id) {
282        return false;
283    }
284
285    !matches!(
286        datasource_id,
287        DatasourceId::NugetDirectoryBuildProps | DatasourceId::NugetDirectoryPackagesProps
288    )
289}
290
291fn stable_package_sort_key(package: &Package) -> (Option<&str>, Option<&str>, Option<&str>, &str) {
292    (
293        package.purl.as_deref(),
294        package.name.as_deref(),
295        package.version.as_deref(),
296        package
297            .datafile_paths
298            .first()
299            .map(String::as_str)
300            .unwrap_or(""),
301    )
302}
303
304fn assemble_one_per_package_data(
305    config: &AssemblerConfig,
306    files: &[FileInfo],
307    file_indices: &[usize],
308) -> Vec<(Package, Vec<TopLevelDependency>, usize)> {
309    let mut results = Vec::new();
310
311    for &idx in file_indices {
312        let file = &files[idx];
313        for pkg_data in &file.package_data {
314            let dsid_matches = pkg_data
315                .datasource_id
316                .is_some_and(|dsid| config.datasource_ids.contains(&dsid));
317
318            if !dsid_matches
319                || pkg_data.purl.is_none()
320                || should_skip_placeholder_only_cocoapods_podspec(pkg_data)
321            {
322                continue;
323            }
324
325            let datafile_path = file.path.clone();
326            let datasource_id = pkg_data.datasource_id.expect("datasource_id must be Some");
327            let pkg = Package::from_package_data(pkg_data, datafile_path.clone());
328            let for_package_uid = Some(pkg.package_uid.clone());
329
330            let deps: Vec<TopLevelDependency> = pkg_data
331                .dependencies
332                .iter()
333                .filter(|dep| dep.purl.is_some() || dep.extracted_requirement.is_some())
334                .map(|dep| {
335                    TopLevelDependency::from_dependency(
336                        dep,
337                        datafile_path.clone(),
338                        datasource_id,
339                        for_package_uid.clone(),
340                    )
341                })
342                .collect();
343
344            results.push((pkg, deps, idx));
345        }
346    }
347
348    results
349}
350
351pub(super) fn should_skip_placeholder_only_cocoapods_podspec(
352    pkg_data: &crate::models::PackageData,
353) -> bool {
354    pkg_data.datasource_id == Some(DatasourceId::CocoapodsPodspec)
355        && pkg_data
356            .extra_data
357            .as_ref()
358            .and_then(|data| data.get("dynamic_identity_placeholders"))
359            .and_then(|value| value.as_bool())
360            == Some(true)
361}
362
363/// Collect the assembler config keys active in a directory in a deterministic
364/// order.
365///
366/// The keys are gathered into a `BTreeSet` so iteration order is stable across
367/// runs and processes. A `HashSet` here made per-directory assembler execution
368/// order depend on hash seeding, which produced run-to-run nondeterministic
369/// output in polyglot directories (issue #1026).
370fn active_config_keys(
371    files: &[FileInfo],
372    file_indices: &[usize],
373    assembler_lookup: &HashMap<DatasourceId, DatasourceId>,
374) -> BTreeSet<DatasourceId> {
375    let mut groups: BTreeSet<DatasourceId> = BTreeSet::new();
376    for &idx in file_indices {
377        for pkg_data in &files[idx].package_data {
378            if let Some(dsid) = pkg_data.datasource_id
379                && let Some(&config_key) = assembler_lookup.get(&dsid)
380            {
381                groups.insert(config_key);
382            }
383        }
384    }
385    groups
386}
387
388/// Group file indices by their parent directory path.
389fn group_files_by_directory(files: &[FileInfo]) -> HashMap<PathBuf, Vec<usize>> {
390    let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
391    for (idx, file) in files.iter().enumerate() {
392        if let Some(parent) = std::path::Path::new(&file.path).parent() {
393            groups.entry(parent.to_path_buf()).or_default().push(idx);
394        }
395    }
396    groups
397}