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