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