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 cocoapods_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    /// Like [`AssemblyMode::SiblingMerge`], but a directory holding multiple
84    /// manifests with distinct package identities (purls) yields one package per
85    /// identity instead of collapsing them into one. A directory with a single
86    /// identity (a normal one-module directory plus its purl-less supplementary
87    /// siblings) falls back to the `SiblingMerge` result unchanged. Use this for
88    /// ecosystems whose directories can legitimately hold several independent
89    /// manifests, e.g. a flat set of standalone Maven `.pom` files.
90    SiblingMergePerIdentity,
91    /// Each PackageData becomes its own independent Package (e.g., database files
92    /// containing many installed packages like Alpine DB, RPM DB, Debian status).
93    OnePerPackageData,
94}
95
96pub struct AssemblerConfig {
97    pub datasource_ids: &'static [DatasourceId],
98    pub sibling_file_patterns: &'static [&'static str],
99    pub mode: AssemblyMode,
100}
101
102/// Run the assembly phase over all scanned files.
103///
104/// Groups files by parent directory, finds related manifests/lockfiles,
105/// merges them into top-level `Package` objects, and hoists dependencies.
106/// Updates each `FileInfo.for_packages` with the UIDs of packages it belongs to.
107pub fn assemble(files: &mut [FileInfo]) -> AssemblyResult {
108    let assembler_lookup = &*ASSEMBLER_LOOKUP;
109    let assembler_config_lookup = &*ASSEMBLER_CONFIG_LOOKUP;
110    let mut packages = Vec::new();
111    let mut dependencies = Vec::new();
112
113    let dir_files = group_files_by_directory(files);
114    let topology_plan = topology::TopologyPlan::build(files, &dir_files);
115
116    for file_indices in dir_files.values() {
117        let groups = active_config_keys(files, file_indices, assembler_lookup);
118
119        for &config_key in &groups {
120            let config = assembler_config_lookup
121                .get(&config_key)
122                .copied()
123                .expect("assembler config must exist");
124
125            if topology_plan.claims_directory_assembly(config, file_indices, files) {
126                continue;
127            }
128
129            if let Some(special_merger) = assemblers::special_directory_merger_for(config_key) {
130                let results = special_merger.run(config, files, file_indices);
131                apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
132                continue;
133            }
134
135            match config.mode {
136                AssemblyMode::SiblingMerge => {
137                    let results = sibling_merge::assemble_siblings(config, files, file_indices);
138                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
139                }
140                AssemblyMode::SiblingMergePerIdentity => {
141                    let results =
142                        sibling_merge::assemble_siblings_per_identity(config, files, file_indices);
143                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
144                }
145                AssemblyMode::OnePerPackageData => {
146                    let results = assemble_one_per_package_data(config, files, file_indices);
147                    apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
148                }
149            }
150        }
151    }
152
153    topology_plan.apply_directory_scoped_domains(files, &mut packages, &mut dependencies);
154
155    for config in ASSEMBLERS {
156        if !matches!(
157            config.mode,
158            AssemblyMode::SiblingMerge | AssemblyMode::SiblingMergePerIdentity
159        ) {
160            continue;
161        }
162        if let Some((pkg, deps, affected_indices)) =
163            nested_merge::assemble_nested_patterns(files, config)
164        {
165            let package_uid = pkg.package_uid.clone();
166
167            // The nested merge subsumes exactly the per-directory packages that were
168            // previously created for the files it consumed. Those packages are recorded
169            // on the affected files' `for_packages`, so key dedup off those specific UIDs
170            // rather than a blanket purl match (which would also delete unrelated packages,
171            // including every purl-less package scan-wide when the merged purl is `None`).
172            let removed_package_uids: HashSet<PackageUid> = affected_indices
173                .iter()
174                .flat_map(|idx| files[*idx].for_packages.iter().cloned())
175                .filter(|uid| *uid != package_uid)
176                .collect();
177
178            packages.retain(|p| !removed_package_uids.contains(&p.package_uid));
179            dependencies.retain(|d| {
180                d.for_package_uid
181                    .as_ref()
182                    .is_none_or(|old_uid| !removed_package_uids.contains(old_uid))
183            });
184
185            for idx in &affected_indices {
186                files[*idx].for_packages.clear();
187                files[*idx].for_packages.push(package_uid.clone());
188            }
189
190            packages.push(pkg);
191            dependencies.extend(deps);
192        }
193    }
194
195    assemblers::run_post_assembly_passes(files, &mut packages, &mut dependencies, &topology_plan);
196    hoist_unassembled_file_dependencies(files, &mut dependencies);
197
198    for package in &mut packages {
199        package.datafile_paths.sort();
200        package.datafile_paths.dedup();
201        package.datasource_ids.sort_by_key(|left| left.to_string());
202        package.datasource_ids.dedup();
203    }
204
205    for file in files.iter_mut() {
206        file.for_packages
207            .sort_by(|left, right| left.stable_key().cmp(right.stable_key()));
208        file.for_packages.dedup();
209    }
210
211    packages
212        .sort_by(|left, right| stable_package_sort_key(left).cmp(&stable_package_sort_key(right)));
213    dependencies.sort_by(|left, right| {
214        left.purl
215            .as_deref()
216            .cmp(&right.purl.as_deref())
217            .then_with(|| {
218                left.extracted_requirement
219                    .as_deref()
220                    .cmp(&right.extracted_requirement.as_deref())
221            })
222            .then_with(|| left.scope.as_deref().cmp(&right.scope.as_deref()))
223            .then_with(|| left.datafile_path.cmp(&right.datafile_path))
224            .then_with(|| {
225                left.datasource_id
226                    .to_string()
227                    .cmp(&right.datasource_id.to_string())
228            })
229            .then_with(|| {
230                left.for_package_uid
231                    .as_ref()
232                    .map(|uid| uid.stable_key())
233                    .cmp(&right.for_package_uid.as_ref().map(|uid| uid.stable_key()))
234            })
235    });
236
237    AssemblyResult {
238        packages,
239        dependencies,
240    }
241}
242
243fn apply_directory_merge_results(
244    files: &mut [FileInfo],
245    packages: &mut Vec<Package>,
246    dependencies: &mut Vec<TopLevelDependency>,
247    results: Vec<DirectoryMergeOutput>,
248) {
249    for (package, deps, affected_indices) in results {
250        if let Some(package) = package {
251            let package_uid = package.package_uid.clone();
252            for idx in &affected_indices {
253                if !files[*idx].for_packages.contains(&package_uid) {
254                    files[*idx].for_packages.push(package_uid.clone());
255                }
256            }
257            packages.push(package);
258        }
259        dependencies.extend(deps);
260    }
261}
262
263fn hoist_unassembled_file_dependencies(
264    files: &[FileInfo],
265    dependencies: &mut Vec<TopLevelDependency>,
266) {
267    for file in files {
268        if !file.for_packages.is_empty() {
269            continue;
270        }
271
272        for pkg_data in &file.package_data {
273            let Some(datasource_id) = pkg_data.datasource_id else {
274                continue;
275            };
276
277            if !should_hoist_unassembled_dependencies(datasource_id) {
278                continue;
279            }
280
281            dependencies.extend(pkg_data.dependencies.iter().map(|dep| {
282                TopLevelDependency::from_dependency(dep, file.path.clone(), datasource_id, None)
283            }));
284        }
285    }
286}
287
288const HOIST_IF_UNOWNED_DATASOURCE_IDS: &[DatasourceId] = &[DatasourceId::PipRequirements];
289
290fn should_hoist_unassembled_dependencies(datasource_id: DatasourceId) -> bool {
291    if HOIST_IF_UNOWNED_DATASOURCE_IDS.contains(&datasource_id) {
292        return true;
293    }
294
295    if !assemblers::is_unassembled_datasource(datasource_id) {
296        return false;
297    }
298
299    !matches!(
300        datasource_id,
301        DatasourceId::NugetDirectoryBuildProps | DatasourceId::NugetDirectoryPackagesProps
302    )
303}
304
305fn stable_package_sort_key(package: &Package) -> (Option<&str>, Option<&str>, Option<&str>, &str) {
306    (
307        package.purl.as_deref(),
308        package.name.as_deref(),
309        package.version.as_deref(),
310        package
311            .datafile_paths
312            .first()
313            .map(String::as_str)
314            .unwrap_or(""),
315    )
316}
317
318fn assemble_one_per_package_data(
319    config: &AssemblerConfig,
320    files: &[FileInfo],
321    file_indices: &[usize],
322) -> Vec<DirectoryMergeOutput> {
323    let mut results = Vec::new();
324
325    for &idx in file_indices {
326        let file = &files[idx];
327        for pkg_data in &file.package_data {
328            let dsid_matches = pkg_data
329                .datasource_id
330                .is_some_and(|dsid| config.datasource_ids.contains(&dsid));
331
332            if !dsid_matches || should_skip_placeholder_only_cocoapods_podspec(pkg_data) {
333                continue;
334            }
335
336            let Some(datasource_id) = pkg_data.datasource_id else {
337                continue;
338            };
339            let datafile_path = file.path.clone();
340
341            // A record carrying an identity becomes its own package that owns its
342            // dependencies. A purl-less record cannot be a package, but its
343            // dependencies are still hoisted (unowned) rather than dropped — the
344            // same visibility they had before this datasource was assembled.
345            let (package, affected) = if pkg_data.purl.is_some() {
346                (
347                    Some(Package::from_package_data(pkg_data, datafile_path.clone())),
348                    vec![idx],
349                )
350            } else {
351                (None, Vec::new())
352            };
353            let for_package_uid = package.as_ref().map(|pkg| pkg.package_uid.clone());
354
355            let deps: Vec<TopLevelDependency> = pkg_data
356                .dependencies
357                .iter()
358                .filter(|dep| dep.purl.is_some() || dep.extracted_requirement.is_some())
359                .map(|dep| {
360                    TopLevelDependency::from_dependency(
361                        dep,
362                        datafile_path.clone(),
363                        datasource_id,
364                        for_package_uid.clone(),
365                    )
366                })
367                .collect();
368
369            if package.is_none() && deps.is_empty() {
370                continue;
371            }
372
373            results.push((package, deps, affected));
374        }
375    }
376
377    results
378}
379
380pub(super) fn should_skip_placeholder_only_cocoapods_podspec(
381    pkg_data: &crate::models::PackageData,
382) -> bool {
383    pkg_data.datasource_id == Some(DatasourceId::CocoapodsPodspec)
384        && pkg_data
385            .extra_data
386            .as_ref()
387            .and_then(|data| data.get("dynamic_identity_placeholders"))
388            .and_then(|value| value.as_bool())
389            == Some(true)
390}
391
392/// Collect the assembler config keys active in a directory in a deterministic
393/// order.
394///
395/// The keys are gathered into a `BTreeSet` so iteration order is stable across
396/// runs and processes. A `HashSet` here made per-directory assembler execution
397/// order depend on hash seeding, which produced run-to-run nondeterministic
398/// output in polyglot directories (issue #1026).
399fn active_config_keys(
400    files: &[FileInfo],
401    file_indices: &[usize],
402    assembler_lookup: &HashMap<DatasourceId, DatasourceId>,
403) -> BTreeSet<DatasourceId> {
404    let mut groups: BTreeSet<DatasourceId> = BTreeSet::new();
405    for &idx in file_indices {
406        for pkg_data in &files[idx].package_data {
407            if let Some(dsid) = pkg_data.datasource_id
408                && let Some(&config_key) = assembler_lookup.get(&dsid)
409            {
410                groups.insert(config_key);
411            }
412        }
413    }
414    groups
415}
416
417/// Group file indices by their parent directory path.
418fn group_files_by_directory(files: &[FileInfo]) -> HashMap<PathBuf, Vec<usize>> {
419    let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
420    for (idx, file) in files.iter().enumerate() {
421        if let Some(parent) = std::path::Path::new(&file.path).parent() {
422            groups.entry(parent.to_path_buf()).or_default().push(idx);
423        }
424    }
425    groups
426}